Files
superwork/vscode/src/gitea/api.ts
T

853 lines
24 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 { Buffer } from 'node:buffer'
import { logger } from '../logging/logger'
export 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
/** 工单关闭时间(ISO 8601);未关闭为 null。完成列缺 PR 合并时间时用它排序。 */
closed_at?: string | null
body: string
html_url: string
user: { login: string, avatar_url?: string } | null
assignees: Array<{ login: string, avatar_url?: 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
user?: {
id: number
login: string
full_name?: string
avatar_url?: 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
/** 逗号分隔的 label 名。label 必须已存在,否则 Gitea 会丢掉过滤条件。 */
labels?: 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)
if (opts.labels)
url.searchParams.set('labels', opts.labels)
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
}
export interface GiteaLabel {
id: number
name: string
color: string
}
export async function listRepoLabels(opts: {
host: string
token: string
owner: string
repo: string
}): Promise<GiteaLabel[]> {
const out: GiteaLabel[] = []
let page = 1
while (true) {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`)
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 GiteaLabel[]
if (!Array.isArray(batch) || batch.length === 0)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
export async function createRepoLabel(opts: {
host: string
token: string
owner: string
repo: string
name: string
color: string
description?: string
}): Promise<GiteaLabel> {
const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`, {
method: 'POST',
headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: opts.name, color: opts.color, description: opts.description ?? '' }),
})
await ensureOk(res)
return (await res.json()) as GiteaLabel
}
export async function createIssue(opts: {
host: string
token: string
owner: string
repo: string
title: string
body: string
labels?: number[]
}): Promise<GiteaIssue> {
const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`, {
method: 'POST',
headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' },
body: JSON.stringify({
title: opts.title,
body: opts.body,
...(opts.labels && opts.labels.length > 0 ? { labels: opts.labels } : {}),
}),
})
await ensureOk(res)
return (await res.json()) as GiteaIssue
}
/**
* 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
}
/**
* 单页拉取某工单评论。Gitea 默认旧→新,末页才是最新 state JSON 所在。
*/
export async function listIssueCommentsPage(opts: {
host: string
token: string
owner: string
repo: string
index: number
page: number
}): Promise<GiteaComment[]> {
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(opts.page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as GiteaComment[]
return Array.isArray(batch) ? batch : []
}
/**
* 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 batch = await listIssueCommentsPage({ ...opts, page })
if (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 分支最新提交 sha:PR diff 的右侧 ref。 */
headSha: string
/** head 分支名,如 `feature/abcd`;完成列清理分支时作 state.branch 兜底。 */
headRef: string
/** base 分支 sha:mergeBase 取不到时的左侧 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 的全部改动文件(三点 diff,merge_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()
}
export interface GiteaAttachment {
id: number
name: string
size: number
uuid: string
browser_download_url: string
created_at: string
}
/** 可被指派的用户(仓库协作者 + owner),移交选人用。 */
export async function listRepoAssignees(opts: {
host: string
token: string
owner: string
repo: string
}): Promise<GiteaUser[]> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/assignees`,
{ headers: authHeaders(opts.token) },
)
await ensureOk(res)
return (await res.json()) as GiteaUser[]
}
export async function listIssueAttachments(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<GiteaAttachment[]> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`,
{ headers: authHeaders(opts.token) },
)
await ensureOk(res)
return (await res.json()) as GiteaAttachment[]
}
export async function uploadIssueAttachment(opts: {
host: string
token: string
owner: string
repo: string
index: number
name: string
data: Buffer
}): Promise<GiteaAttachment> {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`)
url.searchParams.set('name', opts.name)
const form = new FormData()
// Buffer 的底层 ArrayBufferLike 包含 SharedArrayBuffer,BlobPart 只认 ArrayBuffer;
// 用 Uint8Array 包一层拷贝到普通 ArrayBuffer 上,绕开这条 TS 类型不兼容。
form.append('attachment', new Blob([new Uint8Array(opts.data)], { type: 'application/gzip' }), opts.name)
// 不能带 Content-Type:multipart boundary 由 fetch 按 FormData 自动生成。
const res = await fetch(url.toString(), {
method: 'POST',
headers: { Authorization: `token ${opts.token}`, Accept: 'application/json' },
body: form,
})
await ensureOk(res)
return (await res.json()) as GiteaAttachment
}
export async function getIssueAttachment(opts: {
host: string
token: string
owner: string
repo: string
index: number
attachmentId: number
}): Promise<GiteaAttachment> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`,
{ headers: authHeaders(opts.token) },
)
await ensureOk(res)
return (await res.json()) as GiteaAttachment
}
/**
* `browser_download_url` 走的是 web 路由(/attachments/<uuid>),Gitea 的 web
* 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML,
* 所以除 ok 外还要拒掉 HTML 响应,别把登录页当 tgz 存下来。
*/
export async function downloadAttachment(opts: { token: string, url: string }): Promise<Buffer> {
const res = await fetch(opts.url, { headers: { Authorization: `token ${opts.token}` }, redirect: 'follow' })
await ensureOk(res)
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('text/html'))
throw new GiteaApiError(res.status, `附件下载被重定向到页面(鉴权失败?):${opts.url}`)
return Buffer.from(await res.arrayBuffer())
}
export async function deleteIssueAttachment(opts: {
host: string
token: string
owner: string
repo: string
index: number
attachmentId: number
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`,
{ method: 'DELETE', headers: authHeaders(opts.token) },
)
await ensureOk(res)
}