Files
superwork/vscode/src/gitea/api.ts
T
cruldra faf227412e feat(vscode): 多人协作二期:状态分层/写校验/签名/团队视图
- 本机状态(会话id/worktree/profile/prDiffFile)迁 workspaceState,
  共享 state JSON 只留团队字段;读侧本地记录叠加,旧数据兜底
- mergeStateJsonComment 写后读回校验,并发覆盖时在最新状态上重放一次
- webhook 支持 Gitea secret,校验 X-Gitea-Signature(HMAC-SHA256),
  不匹配 401;设置面板新增 Webhook Secret 字段
- 看板新增 我的/全部 范围切换(团队视图),按工作区持久化
2026-08-19 00:32:06 +08:00

644 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Fetch-based Gitea API client.
*
* Replaces the previous `tea` CLI shell-outs with direct REST calls over
* Node 20+'s global `fetch`. All requests are authenticated with a personal
* access token via the `Authorization: token <PAT>` header.
*
* Errors from non-2xx responses surface as `GiteaApiError` so callers can
* distinguish 401 (token invalid) from other failure modes.
*/
import { logger } from '../logging/logger'
const PAGE_SIZE = 50
export interface GiteaUser {
login: string
id: number
email?: string
}
export interface GiteaIssue {
id: number
number: number
title: string
state: 'open' | 'closed'
comments: number
created_at: string
updated_at: string
body: string
html_url: string
user: { login: string } | null
assignees: Array<{ login: string }> | null
labels: Array<{ name: string, color: string }> | null
}
export interface GiteaComment {
id: number
body: string
issue_url: string
created_at: string
updated_at: string
}
export class GiteaApiError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'GiteaApiError'
this.status = status
}
}
function baseUrl(host: string): string {
return `https://${host}/api/v1`
}
function authHeaders(token: string): Record<string, string> {
return {
Authorization: `token ${token}`,
Accept: 'application/json',
}
}
async function ensureOk(res: Response): Promise<void> {
if (res.ok)
return
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, body || res.statusText)
}
export async function getCurrentUser(opts: { host: string, token: string }): Promise<GiteaUser> {
const res = await fetch(`${baseUrl(opts.host)}/user`, {
headers: authHeaders(opts.token),
})
await ensureOk(res)
return res.json() as Promise<GiteaUser>
}
/**
* Fetches `/repos/{owner}/{repo}/issues` filtered by either `assigned_by` or
* `created_by`, paginating until a short page is returned.
*/
export async function listIssuesByFilter(opts: {
host: string
token: string
owner: string
repo: string
/** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */
filter?: 'assigned_by' | 'created_by'
user?: string
}): Promise<GiteaIssue[]> {
const out: GiteaIssue[] = []
let page = 1
// Gitea returns a JSON array of issues; we accumulate until we see a short page.
while (true) {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`)
url.searchParams.set('type', 'issues')
url.searchParams.set('state', 'all')
if (opts.filter && opts.user)
url.searchParams.set(opts.filter, opts.user)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as GiteaIssue[]
if (!Array.isArray(batch) || batch.length === 0)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
/**
* Fetches the firehose `/repos/{owner}/{repo}/issues/comments` endpoint, which
* returns every comment in the repo across all issues. Paginated until a short
* page is returned.
*/
export async function listAllRepoComments(opts: {
host: string
token: string
owner: string
repo: string
}): Promise<GiteaComment[]> {
const out: GiteaComment[] = []
let page = 1
while (true) {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/comments`)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as GiteaComment[]
if (!Array.isArray(batch) || batch.length === 0)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
/**
* Fetches comments for a single issue via
* `/repos/{owner}/{repo}/issues/{index}/comments`. Paginated until a short
* page is returned. Used when we need the freshest view of one issue's
* state-JSON comment (e.g. mutating the last comment in place).
*/
export async function listIssueComments(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<GiteaComment[]> {
const out: GiteaComment[] = []
let page = 1
while (true) {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`,
)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as GiteaComment[]
if (!Array.isArray(batch) || batch.length === 0)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
export async function postIssueComment(opts: {
host: string
token: string
owner: string
repo: string
index: number
body: string
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`,
{
method: 'POST',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: opts.body }),
},
)
await ensureOk(res)
}
export interface GiteaPullRequest {
number: number
merged: boolean
state: string
merged_at?: string | null
html_url: string
/**
* PR description / body. Used by the webhook coordinator to reverse-lookup
* the underlying issue number (`Closes #N`) when a comment fires on a PR.
*/
body: string
/** head 分支最新提交 shaPR diff 的右侧 ref。 */
headSha: string
/** head 分支名,如 `feature/abcd`;完成列清理分支时作 state.branch 兜底。 */
headRef: string
/** base 分支 shamergeBase 取不到时的左侧 ref 兜底。 */
baseSha: string
/** base 与 head 的合并基:PR diff 的左侧 ref(三点 diff,与 Gitea 网页口径一致)。 */
mergeBase: string
}
/**
* Fetches a single pull request by index via
* `/repos/{owner}/{repo}/pulls/{index}`. Used by the kanban "done" drop
* handler to verify a PR is merged before persisting `column='done'`.
*/
export async function getPullRequest(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<GiteaPullRequest> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}`,
{ headers: authHeaders(opts.token) },
)
await ensureOk(res)
const data = await res.json() as {
number?: unknown
merged?: unknown
state?: unknown
merged_at?: unknown
html_url?: unknown
body?: unknown
head?: { sha?: unknown, ref?: unknown }
base?: { sha?: unknown }
merge_base?: unknown
}
return {
number: typeof data.number === 'number' ? data.number : Number(data.number),
merged: data.merged === true,
state: typeof data.state === 'string' ? data.state : '',
merged_at: typeof data.merged_at === 'string' ? data.merged_at : null,
html_url: typeof data.html_url === 'string' ? data.html_url : '',
body: typeof data.body === 'string' ? data.body : '',
headSha: typeof data.head?.sha === 'string' ? data.head.sha : '',
headRef: typeof data.head?.ref === 'string' ? data.head.ref : '',
baseSha: typeof data.base?.sha === 'string' ? data.base.sha : '',
mergeBase: typeof data.merge_base === 'string' ? data.merge_base : '',
}
}
export async function getIssue(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<GiteaIssue | null> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}`,
{ headers: authHeaders(opts.token) },
)
if (res.status === 404)
return null
await ensureOk(res)
return await res.json() as GiteaIssue
}
/**
* 覆盖式设置工单 assignee(实施即认领的多人协作语义:单一责任人,
* 重新实施会把责任转移给新实施者)。
*/
export async function updateIssueAssignees(opts: {
host: string
token: string
owner: string
repo: string
index: number
assignees: string[]
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}`,
{
method: 'PATCH',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ assignees: opts.assignees }),
},
)
await ensureOk(res)
}
export async function getDependencies(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<Array<{ number: number }>> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/dependencies`,
{ headers: authHeaders(opts.token) },
)
await ensureOk(res)
const data = await res.json() as Array<{ number?: unknown }>
if (!Array.isArray(data))
return []
return data
.map((it) => {
const n = typeof it?.number === 'number' ? it.number : Number(it?.number)
return Number.isFinite(n) ? { number: n } : null
})
.filter((it): it is { number: number } => it !== null)
}
export async function addDependency(opts: {
host: string
token: string
owner: string
repo: string
index: number
dependencyIndex: number
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/dependencies`,
{
method: 'POST',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ index: opts.dependencyIndex }),
},
)
await ensureOk(res)
}
/**
* Merge a pull request via gitea's `POST /repos/{owner}/{repo}/pulls/{index}/merge`.
* Defaults to the plain "merge" strategy. Throws GiteaApiError on non-2xx.
*/
export async function mergePullRequest(opts: {
host: string
token: string
owner: string
repo: string
index: number
strategy?: 'merge' | 'rebase' | 'rebase-merge' | 'squash'
}): Promise<void> {
const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/merge`
const body = {
Do: opts.strategy ?? 'merge',
delete_branch_after_merge: false,
force_merge: false,
}
const res = await fetch(url, {
method: 'POST',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
await ensureOk(res)
}
export async function closeIssue(opts: {
host: string
token: string
owner: string
repo: string
issueNumber: number
}): Promise<void> {
const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.issueNumber}`
const res = await fetch(url, {
method: 'PATCH',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ state: 'closed' }),
})
await ensureOk(res)
}
export async function deleteIssue(opts: {
host: string
token: string
owner: string
repo: string
issueNumber: number
}): Promise<void> {
const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.issueNumber}`
const res = await fetch(url, {
method: 'DELETE',
headers: authHeaders(opts.token),
})
await ensureOk(res)
}
export async function deleteBranch(opts: {
host: string
token: string
owner: string
repo: string
branch: string
}): Promise<void> {
const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/branches/${encodeURIComponent(opts.branch)}`
const res = await fetch(url, {
method: 'DELETE',
headers: authHeaders(opts.token),
})
await ensureOk(res)
}
export async function removeDependency(opts: {
host: string
token: string
owner: string
repo: string
index: number
dependencyIndex: number
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/dependencies`,
{
method: 'DELETE',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ index: opts.dependencyIndex }),
},
)
await ensureOk(res)
}
// no longer auto-invoked by the implement flow; kept for future manual ops
export async function createWebhook(opts: {
host: string
token: string
owner: string
repo: string
url: string
branchFilter: string
}): Promise<{ id: number }> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/hooks`,
{
method: 'POST',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'gitea',
active: true,
events: ['pull_request'],
branch_filter: opts.branchFilter,
config: {
url: opts.url,
content_type: 'json',
},
}),
},
)
if (!res.ok) {
const body = await res.text().catch(() => '')
logger.add({
level: 'error',
source: 'gitea',
message: `createWebhook 失败 status=${res.status}`,
details: (body || res.statusText).slice(0, 500),
})
throw new GiteaApiError(res.status, body || res.statusText)
}
const data = (await res.json()) as { id?: unknown }
const id = typeof data.id === 'number' ? data.id : Number(data.id)
if (!Number.isFinite(id)) {
logger.add({
level: 'error',
source: 'gitea',
message: 'createWebhook: 响应缺少 id',
})
throw new GiteaApiError(500, 'createWebhook: response missing id')
}
logger.add({
level: 'info',
source: 'gitea',
message: `已创建 hookId=${id} url=${opts.url} branch_filter=${opts.branchFilter}`,
})
return { id }
}
// no longer auto-invoked by the implement flow; kept for future manual ops
export async function deleteWebhook(opts: {
host: string
token: string
owner: string
repo: string
hookId: number
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/hooks/${opts.hookId}`,
{
method: 'DELETE',
headers: authHeaders(opts.token),
},
)
if (res.status === 404) {
logger.add({
level: 'warn',
source: 'gitea',
message: `deleteWebhook hookId=${opts.hookId} 已不存在 (404)`,
})
return
}
if (!res.ok) {
const body = await res.text().catch(() => '')
logger.add({
level: 'error',
source: 'gitea',
message: `deleteWebhook hookId=${opts.hookId} 失败 status=${res.status}`,
details: (body || res.statusText).slice(0, 500),
})
throw new GiteaApiError(res.status, body || res.statusText)
}
logger.add({
level: 'info',
source: 'gitea',
message: `已删除 hookId=${opts.hookId}`,
})
}
/** PR 改动里的一个文件:path/status 给文件树,additions/deletions 求和成统计条,previousFilename 用于改名时取左侧旧路径。 */
export interface PrFileDto {
path: string
status: string
additions: number
deletions: number
previousFilename?: string
}
/**
* 列出某 PR 的全部改动文件(三点 diffmerge_base → head,与 Gitea 网页 diff 口径一致)。
* 分页拉到短页为止;改名文件带 previousFilename(旧路径,取左侧内容用)。
*/
export async function listPullRequestFiles(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<PrFileDto[]> {
const out: PrFileDto[] = []
let page = 1
while (true) {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/files`,
)
url.searchParams.set('limit', String(PAGE_SIZE))
url.searchParams.set('page', String(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as Array<{
filename?: unknown
previous_filename?: unknown
status?: unknown
additions?: unknown
deletions?: unknown
}>
if (!Array.isArray(batch) || batch.length === 0)
break
for (const f of batch) {
const prev = typeof f.previous_filename === 'string' && f.previous_filename.length > 0
? f.previous_filename
: undefined
out.push({
path: typeof f.filename === 'string' ? f.filename : '',
status: typeof f.status === 'string' ? f.status : '',
additions: typeof f.additions === 'number' ? f.additions : 0,
deletions: typeof f.deletions === 'number' ? f.deletions : 0,
previousFilename: prev,
})
}
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
/**
* 取某 ref 下文件的原始内容,用作 diff 的一侧。
*
* 文件在该 ref 不存在(404)是预期情况:新增文件在父 ref 缺席、删除文件在子
* ref 缺席。这类情况返回空串,让 diff 自然呈现新增/删除,而非报错。其它非 2xx
* 同样兜底返回空串——diff 视图容不下错误对话框。
*/
export async function getRawFile(opts: {
host: string
token: string
owner: string
repo: string
filepath: string
ref: string
}): Promise<string> {
// encodeURI 保留路径分隔符 `/`,只转义路径段里的特殊字符。
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/raw/${encodeURI(opts.filepath)}`,
)
url.searchParams.set('ref', opts.ref)
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
if (!res.ok)
return ''
return res.text()
}