/** * GitHub(github.com)上的 [ForgeProvider]:用 Node 自带的 `fetch` 调 REST 接口,删除工单这一项 REST 没有,走 GraphQL。 * * 请求只发往 `api.github.com`,带 `gh auth token` 取来的 token;GitHub 的原始 JSON 只在这个文件里出现。 */ import type { ForgeCapabilities, ForgeComment, ForgeIssue, ForgePrFile, ForgeProvider, ForgePullRequest, ForgeUser, ForgeUserRef, IssueFilter, MergeMethod, MergeOutcome, PrFileStatus, } from '../types' import { fetchImageDataUri } from '../avatars' import { nextPageLink } from '../linkHeader' import { ForgeApiError } from '../types' const API = 'https://api.github.com' const API_HOST = 'api.github.com' /** 固定接口版本,GitHub 以后改了默认版本也不影响这里解析的字段。 */ const API_VERSION = '2022-11-28' /** GitHub 列表接口每页上限。 */ const PAGE_SIZE = 100 const AVATAR_HOST = 'avatars.githubusercontent.com' /** 卡片上的头像只有十几像素,要 64 像素的图在高分屏上也够清楚。 */ const AVATAR_SIZE = 64 interface RawUserRef { login?: string avatar_url?: string } interface RawIssue { /** 内部 id,依赖接口按它认工单,不认编号。 */ id: number /** GraphQL 用的全局 id。 */ node_id: string number: number title: string state: 'open' | 'closed' comments: number html_url: string repository_url?: string closed_at?: string | null user?: RawUserRef | null assignees?: RawUserRef[] | null /** 有这个键的是 PR:GitHub 的工单接口把 PR 也当工单返回。 */ pull_request?: unknown } interface RawComment { id: number body?: string | null created_at?: string user?: RawUserRef | null } interface RawPullRequest { number: number state: 'open' | 'closed' merged?: boolean merged_at?: string | null html_url: string body?: string | null head: { sha: string, ref: string } base: { sha: string, ref: string } } interface RawPrFile { filename: string previous_filename?: string status?: string additions?: number deletions?: number } /** GitHub 没有 Gitea 那种「变基后创建合并提交」。 */ const CAPABILITIES: ForgeCapabilities = { deleteIssue: true, dependencies: true, mergeMethods: ['merge', 'squash', 'rebase'], } /** GitHub 的文件改动状态有 added、removed、modified、renamed、copied、changed、unchanged;changed 与 unchanged 都算修改。 */ function fileStatus(status: string | undefined): PrFileStatus { switch (status) { case 'added': case 'renamed': case 'copied': return status case 'removed': return 'deleted' default: return 'modified' } } function userRef(raw: RawUserRef): ForgeUserRef { return { login: raw.login ?? '', ...(raw.avatar_url ? { avatarUrl: raw.avatar_url } : {}), } } function toIssue(raw: RawIssue): ForgeIssue { return { number: raw.number, title: raw.title, state: raw.state, htmlUrl: raw.html_url, commentCount: raw.comments, ...(raw.closed_at ? { closedAt: raw.closed_at } : {}), ...(raw.user ? { author: userRef(raw.user) } : {}), assignees: (raw.assignees ?? []).map(userRef), } } function toComment(raw: RawComment): ForgeComment { return { id: raw.id, body: raw.body ?? '', createdAt: raw.created_at ?? '', ...(raw.user ? { author: userRef(raw.user) } : {}), } } function toUser(raw: { login: string, email?: string | null }): ForgeUser { return { login: raw.login, ...(raw.email ? { email: raw.email } : {}) } } /** 文件路径、分支名逐段转义后再拼进地址,保留其中的 `/`。 */ function encodePath(p: string): string { return p.split('/').map(encodeURIComponent).join('/') } /** GitHub 的错误响应是 `{ "message": … }`,取出 message;不是这个格式就用原文。 */ function errorText(body: string): string { try { const message = (JSON.parse(body) as { message?: unknown }).message if (typeof message === 'string' && message) return message } catch {} return body } export class GitHubProvider implements ForgeProvider { readonly kind = 'github' as const readonly capabilities = CAPABILITIES private readonly owner: string private readonly repo: string private readonly token: string constructor(opts: { owner: string, repo: string, token: string }) { this.owner = opts.owner this.repo = opts.repo this.token = opts.token } private get repoUrl(): string { return `${API}/repos/${this.owner}/${this.repo}` } private headers(opts: { json?: boolean, accept?: string } = {}): Record { return { 'Authorization': `Bearer ${this.token}`, 'Accept': opts.accept ?? 'application/vnd.github+json', 'X-GitHub-Api-Version': API_VERSION, ...(opts.json ? { 'Content-Type': 'application/json' } : {}), } } private async send(url: string, init: { method?: string, body?: unknown } = {}): Promise { const hasBody = init.body !== undefined const res = await fetch(url, { ...(init.method ? { method: init.method } : {}), headers: this.headers({ json: hasBody }), ...(hasBody ? { body: JSON.stringify(init.body) } : {}), }) if (!res.ok) { const text = await res.text().catch(() => '') throw new ForgeApiError(res.status, errorText(text) || res.statusText) } return res } private async getJson(url: string): Promise { return await (await this.send(url)).json() as T } /** * 一页 100 条,照 Link 头给的 rel="next" 地址一页页往下拉。 * * @throws [Error] 下一页地址不在 `api.github.com` 上;跟过去会把 token 发给别的服务器 */ private async paginate(path: string, params: Record = {}): Promise { let url = new URL(`${this.repoUrl}${path}`) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) url.searchParams.set('per_page', String(PAGE_SIZE)) const out: T[] = [] for (;;) { const res = await this.send(url.toString()) const batch = await res.json() as T[] if (!Array.isArray(batch)) return out out.push(...batch) const next = nextPageLink(res) if (!next) return out const nextUrl = new URL(next, url) if (nextUrl.protocol !== 'https:' || nextUrl.host !== API_HOST) throw new Error(`翻页地址指向 ${nextUrl.host},不是 ${API_HOST},为免泄露凭据已停止`) url = nextUrl } } private async rawIssue(issueNumber: number): Promise { return this.getJson(`${this.repoUrl}/issues/${issueNumber}`) } /** 只认挂在本仓库下的工单;跨仓库的依赖编号在本仓库里指的是别的工单。 */ private inThisRepo(raw: RawIssue): boolean { return !raw.repository_url || raw.repository_url.toLowerCase() === this.repoUrl.toLowerCase() } /** 公开邮箱没设时 GitHub 给 `null`,这时不带邮箱。 */ async getCurrentUser(): Promise { return toUser(await this.getJson<{ login: string, email?: string | null }>(`${API}/user`)) } async listAssignees(): Promise { return (await this.paginate<{ login: string }>('/assignees')).map(toUser) } async listIssues(filter: IssueFilter = {}): Promise { const params: Record = { state: 'all' } if (filter.assignee) params.assignee = filter.assignee if (filter.creator) params.creator = filter.creator const raws = await this.paginate('/issues', params) return raws.filter(raw => raw.pull_request === undefined).map(toIssue) } /** 工单不存在(404)、已被删除(GitHub 回 410)或这个编号其实是 PR 时都返回 `null`。 */ async getIssue(issueNumber: number): Promise { try { const raw = await this.rawIssue(issueNumber) return raw.pull_request === undefined ? toIssue(raw) : null } catch (err) { if (err instanceof ForgeApiError && (err.status === 404 || err.status === 410)) return null throw err } } async setIssueAssignees(issueNumber: number, logins: string[]): Promise { await this.send(`${this.repoUrl}/issues/${issueNumber}`, { method: 'PATCH', body: { assignees: logins } }) } async closeIssue(issueNumber: number): Promise { await this.send(`${this.repoUrl}/issues/${issueNumber}`, { method: 'PATCH', body: { state: 'closed' } }) } /** * REST 没有删除工单的接口,用 GraphQL 的 `deleteIssue`,它要工单的 `node_id`;只有仓库管理员能删。 * * @throws [ForgeApiError] 没有权限时 `status` 是 403;GraphQL 返回其它错误时是 422 */ async deleteIssue(issueNumber: number): Promise { const { node_id } = await this.rawIssue(issueNumber) const res = await this.send(`${API}/graphql`, { method: 'POST', body: { query: 'mutation($id: ID!) { deleteIssue(input: { issueId: $id }) { clientMutationId } }', variables: { id: node_id }, }, }) // GraphQL 出错时 HTTP 状态照样是 200,错误放在 errors 里。 const { errors } = await res.json() as { errors?: Array<{ type?: string, message?: string }> } if (!errors?.length) return if (errors.some(e => e.type === 'FORBIDDEN')) throw new ForgeApiError(403, '需要仓库管理员权限才能删除工单') throw new ForgeApiError(422, errors.map(e => e.message ?? e.type ?? '').join(';')) } async listIssueComments(issueNumber: number): Promise { return (await this.paginate(`/issues/${issueNumber}/comments`)).map(toComment) } async postIssueComment(issueNumber: number, body: string): Promise { await this.send(`${this.repoUrl}/issues/${issueNumber}/comments`, { method: 'POST', body: { body } }) } async listDependencies(issueNumber: number): Promise { const blockers = await this.paginate(`/issues/${issueNumber}/dependencies/blocked_by`) return blockers.filter(raw => this.inThisRepo(raw)).map(raw => raw.number) } /** 依赖接口按被依赖工单的内部 `id` 认人,先按编号查出 id。 */ async addDependency(issueNumber: number, dependsOn: number): Promise { const { id } = await this.rawIssue(dependsOn) await this.send(`${this.repoUrl}/issues/${issueNumber}/dependencies/blocked_by`, { method: 'POST', body: { issue_id: id } }) } async removeDependency(issueNumber: number, dependsOn: number): Promise { const { id } = await this.rawIssue(dependsOn) await this.send(`${this.repoUrl}/issues/${issueNumber}/dependencies/blocked_by/${id}`, { method: 'DELETE' }) } async getPullRequest(prNumber: number): Promise { const raw = await this.getJson(`${this.repoUrl}/pulls/${prNumber}`) return { number: raw.number, state: raw.state === 'open' ? 'open' : 'closed', merged: raw.merged === true, ...(raw.merged_at ? { mergedAt: raw.merged_at } : {}), htmlUrl: raw.html_url, body: raw.body ?? '', headSha: raw.head.sha, headRef: raw.head.ref, baseSha: raw.base.sha, baseRef: raw.base.ref, } } /** PR 详情里没有合并基,要另调 compare 接口;它还会带回提交列表,只要 1 条,少传点数据。 */ async pullRequestDiffBase(pr: ForgePullRequest): Promise { try { const url = new URL(`${this.repoUrl}/compare/${pr.baseSha}...${pr.headSha}`) url.searchParams.set('per_page', '1') const data = await this.getJson<{ merge_base_commit?: { sha?: string } }>(url.toString()) return data.merge_base_commit?.sha || pr.baseSha } catch { return pr.baseSha } } /** * GitHub 不能合并时回 405:说「not mergeable」的是有冲突,其余(合并方式被仓库禁用、必需的检查没过等)算被拒; * 回 409 是合并期间 head 又变了。 */ async mergePullRequest(prNumber: number, method: MergeMethod): Promise { if (!CAPABILITIES.mergeMethods.includes(method)) return { merged: false, reason: 'rejected', message: `GitHub 不支持 ${method} 合并方式` } try { await this.send(`${this.repoUrl}/pulls/${prNumber}/merge`, { method: 'PUT', body: { merge_method: method } }) return { merged: true } } catch (err) { if (!(err instanceof ForgeApiError)) throw err if (err.status === 405) return { merged: false, reason: /not mergeable|conflict/i.test(err.message) ? 'conflict' : 'rejected', message: err.message } return { merged: false, reason: err.status === 409 ? 'head-changed' : 'rejected', message: err.message } } } async closePullRequest(prNumber: number): Promise { await this.send(`${this.repoUrl}/pulls/${prNumber}`, { method: 'PATCH', body: { state: 'closed' } }) } async listPullRequestFiles(prNumber: number): Promise { const files = await this.paginate(`/pulls/${prNumber}/files`) return files.map(f => ({ path: f.filename, status: fileStatus(f.status), additions: f.additions ?? 0, deletions: f.deletions ?? 0, ...(f.previous_filename ? { previousFilename: f.previous_filename } : {}), })) } /** 文件在该 ref 上不存在(404)是新增、删除文件的正常情况,返回空串让 diff 显示成整份新增或删除;其它失败也返回空串。 */ async getRawFile(path: string, ref: string): Promise { const url = new URL(`${this.repoUrl}/contents/${encodePath(path)}`) url.searchParams.set('ref', ref) try { const res = await fetch(url.toString(), { headers: this.headers({ accept: 'application/vnd.github.raw+json' }) }) return res.ok ? await res.text() : '' } catch { return '' } } async deleteBranch(branch: string): Promise { await this.send(`${this.repoUrl}/git/refs/heads/${encodePath(branch)}`, { method: 'DELETE' }) } /** * 头像在 `avatars.githubusercontent.com` 这类公开域名上,不带 token;只有 `api.github.com` 上的地址才带。 * * 头像域名上的图按 [AVATAR_SIZE] 要小图:用户上传的原图可能超过 [头像大小上限](vscode/src/forge/avatars.ts#MAX_BYTES),抓回来也会被丢掉。 */ avatarDataUri(url: string): Promise { let parsed: URL try { parsed = new URL(url) } catch { return Promise.resolve(undefined) } const host = parsed.host.toLowerCase() if (host === AVATAR_HOST) parsed.searchParams.set('s', String(AVATAR_SIZE)) return fetchImageDataUri(parsed.toString(), host === API_HOST ? { Authorization: `Bearer ${this.token}` } : {}) } issueWebUrl(issueNumber: number): string { return `https://github.com/${this.owner}/${this.repo}/issues/${issueNumber}` } pullRequestWebUrl(prNumber: number): string { return `https://github.com/${this.owner}/${this.repo}/pull/${prNumber}` } }