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) +}