From bffdcb4d42a964f36304cced871eb3b5c9e4e52a Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 26 Aug 2026 16:02:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20Gitea=20API=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20assignees=20=E5=88=97=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E9=99=84=E4=BB=B6=E8=AF=BB=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph --- vscode/src/gitea/api.ts | 111 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index c1377ee..bdde6b4 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -9,6 +9,8 @@ * distinguish 401 (token invalid) from other failure modes. */ +import { Buffer } from 'node:buffer' + import { logger } from '../logging/logger' const PAGE_SIZE = 50 @@ -641,3 +643,112 @@ export async function getRawFile(opts: { 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 { + 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 { + 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 { + 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 { + 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/),Gitea 的 web + * 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML, + * 所以除 ok 外还要拒掉 HTML 响应,别把登录页当 tgz 存下来。 + */ +export async function downloadAttachment(opts: { token: string, url: string }): Promise { + 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 { + 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) +}