✨ feat(vscode): 新增 GitHub provider,凭据借 gh 登录,删工单走 GraphQL、依赖按内部 id 换算

This commit is contained in:
cruldra committed 2026-10-19 08:18:27 +11:00
1 parent 451bc91a6b
commit 7351b6247f
12 files changed
+1010 -47

No files matched your search

+429
View File
@@ -0,0 +1,429 @@
/**
* 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<string, string> {
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<Response> {
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<T>(url: string): Promise<T> {
return await (await this.send(url)).json() as T
}
/**
* 一页 100 条,照 Link 头给的 rel="next" 地址一页页往下拉。
*
* @throws [Error] 下一页地址不在 `api.github.com` 上;跟过去会把 token 发给别的服务器
*/
private async paginate<T>(path: string, params: Record<string, string> = {}): Promise<T[]> {
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<RawIssue> {
return this.getJson<RawIssue>(`${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<ForgeUser> {
return toUser(await this.getJson<{ login: string, email?: string | null }>(`${API}/user`))
}
async listAssignees(): Promise<ForgeUser[]> {
return (await this.paginate<{ login: string }>('/assignees')).map(toUser)
}
async listIssues(filter: IssueFilter = {}): Promise<ForgeIssue[]> {
const params: Record<string, string> = { state: 'all' }
if (filter.assignee)
params.assignee = filter.assignee
if (filter.creator)
params.creator = filter.creator
const raws = await this.paginate<RawIssue>('/issues', params)
return raws.filter(raw => raw.pull_request === undefined).map(toIssue)
}
/** 这个编号是 PR 时也返回 `null`。 */
async getIssue(issueNumber: number): Promise<ForgeIssue | null> {
try {
const raw = await this.rawIssue(issueNumber)
return raw.pull_request === undefined ? toIssue(raw) : null
}
catch (err) {
if (err instanceof ForgeApiError && err.status === 404)
return null
throw err
}
}
async setIssueAssignees(issueNumber: number, logins: string[]): Promise<void> {
await this.send(`${this.repoUrl}/issues/${issueNumber}`, { method: 'PATCH', body: { assignees: logins } })
}
async closeIssue(issueNumber: number): Promise<void> {
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<void> {
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<ForgeComment[]> {
return (await this.paginate<RawComment>(`/issues/${issueNumber}/comments`)).map(toComment)
}
async postIssueComment(issueNumber: number, body: string): Promise<void> {
await this.send(`${this.repoUrl}/issues/${issueNumber}/comments`, { method: 'POST', body: { body } })
}
async listDependencies(issueNumber: number): Promise<number[]> {
const blockers = await this.paginate<RawIssue>(`/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<void> {
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<void> {
const { id } = await this.rawIssue(dependsOn)
await this.send(`${this.repoUrl}/issues/${issueNumber}/dependencies/blocked_by/${id}`, { method: 'DELETE' })
}
async getPullRequest(prNumber: number): Promise<ForgePullRequest> {
const raw = await this.getJson<RawPullRequest>(`${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<string> {
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<MergeOutcome> {
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<void> {
await this.send(`${this.repoUrl}/pulls/${prNumber}`, { method: 'PATCH', body: { state: 'closed' } })
}
async listPullRequestFiles(prNumber: number): Promise<ForgePrFile[]> {
const files = await this.paginate<RawPrFile>(`/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<string> {
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<void> {
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<string | undefined> {
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}`
}
}