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

564 lines
16 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'
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
}): 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
}
/**
* 单个工单的评论接口一次返回全部评论,只支持 since / before 时间筛选。
*/
export async function listIssueComments(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<GiteaComment[]> {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`,
)
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 : []
}
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 分支名(PR 要合进去的目标分支),如 `master`。 */
baseRef: 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, ref?: 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 : '',
baseRef: typeof data.base?.ref === 'string' ? data.base.ref : '',
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 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()
}
/** 可被指派的用户(仓库协作者 + 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[]
}