/** * 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 ` 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 { return { Authorization: `token ${token}`, Accept: 'application/json', } } async function ensureOk(res: Response): Promise { 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 { const res = await fetch(`${baseUrl(opts.host)}/user`, { headers: authHeaders(opts.token), }) await ensureOk(res) return res.json() as Promise } /** * 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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> { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 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() }