✨ 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

+3 -17
View File
@@ -1,7 +1,7 @@
import { existsSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import process from 'node:process'
import { findExecutable } from '../shared/executable'
/**
* 解析 claude 可执行文件绝对路径;找不到返回 null。
@@ -14,24 +14,10 @@ export function resolveClaudeBin(
homeDir?: string,
): string | null {
const home = homeDir ?? env.HOME ?? os.homedir()
for (const dir of (env.PATH ?? '').split(path.delimiter)) {
if (!dir)
continue
const candidate = path.join(dir, 'claude')
if (existsSync(candidate))
return candidate
}
const fallbacks = [
return findExecutable('claude', [
path.join(home, '.local', 'bin', 'claude'),
'/usr/local/bin/claude',
'/usr/bin/claude',
path.join(home, '.claude', 'local', 'claude'),
]
for (const candidate of fallbacks) {
if (existsSync(candidate))
return candidate
}
return null
], env)
}
+25 -3
View File
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
folders: [{ uri: { fsPath: '/repo' } }] as Array<{ uri: { fsPath: string } }> | undefined,
detectRepo: vi.fn(),
getToken: vi.fn(),
readGhToken: vi.fn(),
}))
vi.mock('vscode', () => ({
@@ -15,12 +16,13 @@ vi.mock('vscode', () => ({
}))
vi.mock('../git/remote', () => ({ detectRepo: mocks.detectRepo }))
vi.mock('../auth/secrets', () => ({ getToken: mocks.getToken }))
vi.mock('./github/ghAuth', async importOriginal => ({ ...await importOriginal<object>(), readGhToken: mocks.readGhToken }))
const ctx = {} as never
describe('forgeKindForHost', () => {
it('只有 github.com(不分大小写)是 GitHub,其余都是 Gitea', async () => {
const { forgeKindForHost } = await import('./context.js')
const { forgeKindForHost } = await import('./types.js')
expect(forgeKindForHost('github.com')).toBe('github')
expect(forgeKindForHost('GitHub.com')).toBe('github')
expect(forgeKindForHost('github.example.com')).toBe('gitea')
@@ -33,6 +35,7 @@ describe('resolveForge', () => {
mocks.folders = [{ uri: { fsPath: '/repo' } }]
mocks.detectRepo.mockReset()
mocks.getToken.mockReset()
mocks.readGhToken.mockReset()
})
it('gitea 仓库带上 token 造出 provider', async () => {
@@ -50,11 +53,30 @@ describe('resolveForge', () => {
expect(mocks.getToken).toHaveBeenCalledWith(ctx, 'gitea.x.cn')
})
it('gitHub 仓库暂未支持,不去取 token', async () => {
it('gitHub 仓库用 gh 的登录凭据造出 provider,不读插件里存的 token', async () => {
const { resolveForge } = await import('./context.js')
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
mocks.readGhToken.mockResolvedValue('gho_x')
expect(await resolveForge(ctx)).toEqual({ ok: false, reason: 'unsupported', kind: 'github', host: 'github.com', message: '暂未支持 GitHub 仓库' })
const resolved = await resolveForge(ctx)
expect(resolved.ok).toBe(true)
if (resolved.ok) {
expect(resolved.forge).toMatchObject({ kind: 'github', host: 'github.com', owner: 'o', repo: 'r' })
expect(resolved.forge.provider.kind).toBe('github')
}
expect(mocks.getToken).not.toHaveBeenCalled()
})
it('gh 没登录时是 no-credentials,提示去终端运行 gh auth login', async () => {
const { resolveForge } = await import('./context.js')
mocks.detectRepo.mockResolvedValue({ host: 'github.com', owner: 'o', repo: 'r' })
mocks.readGhToken.mockResolvedValue(undefined)
const resolved = await resolveForge(ctx)
expect(resolved).toMatchObject({ ok: false, reason: 'no-credentials', kind: 'github', host: 'github.com' })
expect(resolved.ok ? '' : resolved.message).toContain('gh auth login')
expect(mocks.getToken).not.toHaveBeenCalled()
})
+18 -21
View File
@@ -11,6 +11,9 @@ import { workspace } from 'vscode'
import { getToken } from '../auth/secrets'
import { detectRepo } from '../git/remote'
import { GiteaProvider } from './gitea/provider'
import { GH_LOGIN_HINT, readGhToken } from './github/ghAuth'
import { GitHubProvider } from './github/provider'
import { forgeKindForHost } from './types'
/** 一个工作区对应的代码托管仓库,和访问它用的 [ForgeProvider]。 */
export interface ForgeContext {
@@ -24,37 +27,31 @@ export interface ForgeContext {
/**
* 解析结果;失败时 `message` 是可以直接给人看的原因,`reason` 给需要分情况处理的调用方:
* `no-workspace` 没打开工作区,`no-remote` 没有能认出的 origin,`no-credentials` 没有凭据,`unsupported` 这一家还不支持。
* `no-workspace` 没打开工作区,`no-remote` 没有能认出的 origin,`no-credentials` 没有凭据。
*/
export type ForgeResolution
= | { ok: true, forge: ForgeContext }
| { ok: false, reason: 'no-workspace' | 'no-remote', message: string }
| { ok: false, reason: 'no-credentials' | 'unsupported', message: string, kind: ForgeKind, host: string }
| { ok: false, reason: 'no-credentials', message: string, kind: ForgeKind, host: string }
/** host 小写后是 `github.com` 的是 GitHub,其余都当 Gitea;不认 GitHub Enterprise。 */
export function forgeKindForHost(host: string): ForgeKind {
return host.toLowerCase() === 'github.com' ? 'github' : 'gitea'
}
/** 给指定的仓库造 [ForgeContext];webhook 记下的仓库不一定是当前工作区的 origin,所以单独开放。 */
/**
* 给指定的仓库造 [ForgeContext];webhook 记下的仓库不一定是当前工作区的 origin,所以单独开放。
*
* GitHub 的凭据现取 gh 的登录,Gitea 的凭据是用户在设置里填、存在 SecretStorage 里的 token。
*/
export async function openForge(ctx: ExtensionContext, workspaceRoot: string, remote: Remote): Promise<ForgeResolution> {
const kind = forgeKindForHost(remote.host)
if (kind === 'github')
return { ok: false, reason: 'unsupported', kind, host: remote.host, message: '暂未支持 GitHub 仓库' }
const repo = { workspaceRoot, kind, host: remote.host, owner: remote.owner, repo: remote.repo }
if (kind === 'github') {
const token = await readGhToken()
if (!token)
return { ok: false, reason: 'no-credentials', kind, host: remote.host, message: GH_LOGIN_HINT }
return { ok: true, forge: { ...repo, provider: new GitHubProvider({ owner: remote.owner, repo: remote.repo, token }) } }
}
const token = await getToken(ctx, remote.host)
if (!token)
return { ok: false, reason: 'no-credentials', kind, host: remote.host, message: `请先配置 ${remote.host} 的访问令牌` }
return {
ok: true,
forge: {
workspaceRoot,
kind,
host: remote.host,
owner: remote.owner,
repo: remote.repo,
provider: new GiteaProvider({ ...remote, token }),
},
}
return { ok: true, forge: { ...repo, provider: new GiteaProvider({ ...remote, token }) } }
}
/** 解析当前工作区(第一个工作区文件夹)的代码托管仓库。 */
+3 -6
View File
@@ -19,6 +19,7 @@ import type {
PrFileStatus,
} from '../types'
import { fetchImageDataUri } from '../avatars'
import { nextPageLink } from '../linkHeader'
import { ForgeApiError } from '../types'
/** 每页要 50 条;服务端上限(`MAX_RESPONSE_ITEMS`)可能更小,翻页只看 Link 头,不看这一页满没满。 */
@@ -97,11 +98,6 @@ function toComment(raw: RawComment): ForgeComment {
}
}
/** Link 头里有没有 rel="next":Gitea 只在还有下一页时给它。 */
function hasNextPage(res: Response): boolean {
return /rel="next"/.test(res.headers.get('link') ?? '')
}
function str(v: unknown): string {
return typeof v === 'string' ? v : ''
}
@@ -168,7 +164,8 @@ export class GiteaProvider implements ForgeProvider {
if (!Array.isArray(batch) || batch.length === 0)
return out
out.push(...batch)
if (!hasNextPage(res))
// Gitea 只在还有下一页时给 rel="next";页码自己数,不跟它给的地址,那个地址按 ROOT_URL 拼,可能不是本机访问用的 host。
if (!nextPageLink(res))
return out
}
}
+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type ExecCallback = (err: Error | null, stdout: string, stderr: string) => void
const mocks = vi.hoisted(() => ({
execFile: vi.fn(),
}))
vi.mock('node:child_process', () => ({ execFile: mocks.execFile }))
/** 让下一次 execFile 以给定结果回调。 */
function execResult(err: Error | null, stdout = ''): void {
mocks.execFile.mockImplementationOnce((_file: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
cb(err, stdout, '')
})
}
describe('readGhToken', () => {
beforeEach(() => mocks.execFile.mockReset())
it('不走 shell 执行 gh auth token --hostname github.com,去掉首尾空白', async () => {
const { readGhToken } = await import('./ghAuth.js')
execResult(null, ' gho_abc\n')
expect(await readGhToken()).toBe('gho_abc')
const [file, args, opts] = mocks.execFile.mock.calls[0] as [string, string[], Record<string, unknown>]
expect(file).toMatch(/(?:^|\/)gh$/)
expect(args).toEqual(['auth', 'token', '--hostname', 'github.com'])
expect(opts.shell).toBeUndefined()
})
it.each([
['gh 不存在', Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }), ''],
['退出码非 0(没登录)', Object.assign(new Error('exit 1'), { code: 1 }), ''],
['输出为空', null, ' \n'],
])('%s → undefined', async (_name, err, stdout) => {
const { readGhToken } = await import('./ghAuth.js')
execResult(err, stdout)
expect(await readGhToken()).toBeUndefined()
})
})
+26
View File
@@ -0,0 +1,26 @@
/**
* GitHub 的凭据直接借 GitHub CLI(`gh`)的登录:每次要用时现取 `gh auth token`,插件自己不存 GitHub token。
*/
import { execFile } from 'node:child_process'
import { findExecutable } from '../../shared/executable'
/** 没取到 gh 登录凭据时给人看的提示。 */
export const GH_LOGIN_HINT = '未登录 GitHub CLI,请在终端运行 `gh auth login` 登录后重试'
/** gh 登录已失效(GitHub 回 401)时给人看的提示。 */
export const GH_RELOGIN_HINT = 'GitHub CLI 的登录已失效,请在终端运行 `gh auth login` 重新登录后重试'
/** PATH 里找不到 gh 时再试的安装位置:Homebrew(Apple 芯片、Intel)和系统包管理器。 */
const GH_FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh']
/** 取 gh 在 github.com 上登录的 token;gh 没装、没登录、执行失败或输出为空都返回 `undefined`。 */
export function readGhToken(): Promise<string | undefined> {
const gh = findExecutable('gh', GH_FALLBACKS) ?? 'gh'
return new Promise((resolve) => {
execFile(gh, ['auth', 'token', '--hostname', 'github.com'], { timeout: 10_000, encoding: 'utf8' }, (err, stdout) => {
const token = err ? '' : String(stdout).trim()
resolve(token || undefined)
})
})
}
+389
View File
@@ -0,0 +1,389 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ForgeApiError } from '../types'
import { GitHubProvider } from './provider'
afterEach(() => vi.unstubAllGlobals())
const API = 'https://api.github.com'
const BASE = `${API}/repos/owner/repo`
function provider(): GitHubProvider {
return new GitHubProvider({ owner: 'owner', repo: 'repo', token: 'gh-token' })
}
function json(data: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(data), init)
}
function nextLink(url: string): ResponseInit {
return { headers: { Link: `<${url}>; rel="next", <${url}>; rel="last"` } }
}
function rawIssue(n: number, extra: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 1000 + n,
node_id: `I_node${n}`,
number: n,
title: `工单 ${n}`,
state: 'open',
comments: 2,
html_url: `https://github.com/owner/repo/issues/${n}`,
repository_url: `${BASE}`,
closed_at: null,
user: { login: 'alice', avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' },
assignees: [{ login: 'bob' }],
...extra,
}
}
function stubFetch(...responses: Response[]): ReturnType<typeof vi.fn> {
const fetch = vi.fn()
for (const res of responses)
fetch.mockResolvedValueOnce(res)
vi.stubGlobal('fetch', fetch)
return fetch
}
function calledUrl(fetch: ReturnType<typeof vi.fn>, i: number): URL {
return new URL(fetch.mock.calls[i][0] as string)
}
function calledInit(fetch: ReturnType<typeof vi.fn>, i: number): RequestInit & { headers: Record<string, string> } {
return fetch.mock.calls[i][1] as RequestInit & { headers: Record<string, string> }
}
describe('请求头', () => {
it('对 api.github.com 带 Bearer token、JSON 媒体类型和固定的接口版本', async () => {
const fetch = stubFetch(json({ login: 'cruldra', email: null }))
expect(await provider().getCurrentUser()).toEqual({ login: 'cruldra' })
expect(calledUrl(fetch, 0).toString()).toBe(`${API}/user`)
expect(calledInit(fetch, 0).headers).toMatchObject({
'Authorization': 'Bearer gh-token',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
})
})
it('有邮箱时带上邮箱', async () => {
stubFetch(json({ login: 'cruldra', email: 'c@x.cn' }))
expect(await provider().getCurrentUser()).toEqual({ login: 'cruldra', email: 'c@x.cn' })
})
})
describe('翻页', () => {
it('每页 100 条,跟着 Link 的 rel="next" 原样请求下一页', async () => {
const next = `${BASE}/issues?state=all&per_page=100&page=2`
const fetch = stubFetch(json([rawIssue(1)], nextLink(next)), json([rawIssue(2)]))
const issues = await provider().listIssues()
expect(issues.map(i => i.number)).toEqual([1, 2])
expect(calledUrl(fetch, 0).searchParams.get('per_page')).toBe('100')
expect(calledUrl(fetch, 0).searchParams.get('state')).toBe('all')
expect(calledUrl(fetch, 1).toString()).toBe(next)
})
it('下一页指向别的 host 时拒绝跟随,token 不发出去', async () => {
const fetch = stubFetch(json([rawIssue(1)], nextLink('https://evil.example/steal?page=2')))
await expect(provider().listIssues()).rejects.toThrow(/evil\.example/)
expect(fetch).toHaveBeenCalledOnce()
})
it('可指派用户也翻页', async () => {
const fetch = stubFetch(
json([{ login: 'a' }], nextLink(`${BASE}/assignees?per_page=100&page=2`)),
json([{ login: 'b' }]),
)
expect(await provider().listAssignees()).toEqual([{ login: 'a' }, { login: 'b' }])
expect(calledUrl(fetch, 0).pathname).toBe('/repos/owner/repo/assignees')
expect(fetch).toHaveBeenCalledTimes(2)
})
it('评论也翻页,转成统一的评论', async () => {
const fetch = stubFetch(
json([{ id: 1, body: 'a', created_at: 't1', user: { login: 'x' } }], nextLink(`${BASE}/issues/7/comments?per_page=100&page=2`)),
json([{ id: 2, body: null, created_at: 't2', user: null }]),
)
expect(await provider().listIssueComments(7)).toEqual([
{ id: 1, body: 'a', createdAt: 't1', author: { login: 'x' } },
{ id: 2, body: '', createdAt: 't2' },
])
expect(calledUrl(fetch, 0).pathname).toBe('/repos/owner/repo/issues/7/comments')
expect(fetch).toHaveBeenCalledTimes(2)
})
})
describe('工单', () => {
it('过滤掉 PR,按人过滤用 assignee / creator,转成统一的工单', async () => {
const fetch = stubFetch(
json([rawIssue(7, { closed_at: '2026-01-01T00:00:00Z', state: 'closed' }), rawIssue(8, { pull_request: { url: 'x' } })]),
json([]),
)
const p = provider()
expect(await p.listIssues({ assignee: 'bob' })).toEqual([{
number: 7,
title: '工单 7',
state: 'closed',
htmlUrl: 'https://github.com/owner/repo/issues/7',
commentCount: 2,
closedAt: '2026-01-01T00:00:00Z',
author: { login: 'alice', avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' },
assignees: [{ login: 'bob' }],
}])
await p.listIssues({ creator: 'alice' })
expect(calledUrl(fetch, 0).searchParams.get('assignee')).toBe('bob')
expect(calledUrl(fetch, 0).searchParams.has('creator')).toBe(false)
expect(calledUrl(fetch, 1).searchParams.get('creator')).toBe('alice')
})
it('工单不存在(404)返回 null,编号其实是 PR 也返回 null', async () => {
stubFetch(new Response('{"message":"Not Found"}', { status: 404 }), json(rawIssue(9, { pull_request: {} })))
const p = provider()
expect(await p.getIssue(404)).toBeNull()
expect(await p.getIssue(9)).toBeNull()
})
it('其它失败照常抛 ForgeApiError', async () => {
stubFetch(new Response('boom', { status: 500 }))
await expect(provider().getIssue(1)).rejects.toBeInstanceOf(ForgeApiError)
})
it('关工单、改指派人、发评论都走 REST', async () => {
const fetch = stubFetch(json({}), json({}), json({}, { status: 201 }))
const p = provider()
await p.closeIssue(3)
await p.setIssueAssignees(3, ['a', 'b'])
await p.postIssueComment(3, 'hi')
expect(fetch.mock.calls.map(c => [c[0], (c[1] as RequestInit).method, (c[1] as RequestInit).body])).toEqual([
[`${BASE}/issues/3`, 'PATCH', JSON.stringify({ state: 'closed' })],
[`${BASE}/issues/3`, 'PATCH', JSON.stringify({ assignees: ['a', 'b'] })],
[`${BASE}/issues/3/comments`, 'POST', JSON.stringify({ body: 'hi' })],
])
expect(calledInit(fetch, 0).headers['Content-Type']).toBe('application/json')
})
})
describe('删除工单', () => {
it('先取工单的 node_id,再调 GraphQL 的 deleteIssue', async () => {
const fetch = stubFetch(json(rawIssue(5)), json({ data: { deleteIssue: { clientMutationId: null } } }))
await provider().deleteIssue(5)
expect(calledUrl(fetch, 1).toString()).toBe(`${API}/graphql`)
expect(calledInit(fetch, 1).method).toBe('POST')
const body = JSON.parse(calledInit(fetch, 1).body as string) as { query: string, variables: Record<string, unknown> }
expect(body.query).toMatch(/deleteIssue\(input: \{ issueId: \$id \}\)/)
expect(body.variables).toEqual({ id: 'I_node5' })
})
it('没有管理员权限时抛出中性的 403 错误', async () => {
stubFetch(json(rawIssue(5)), json({ errors: [{ type: 'FORBIDDEN', message: 'cruldra does not have the correct permissions to execute `DeleteIssue`' }] }))
const err = await provider().deleteIssue(5).then(() => undefined, (e: unknown) => e)
expect(err).toBeInstanceOf(ForgeApiError)
expect(err).toMatchObject({ status: 403, message: '需要仓库管理员权限才能删除工单' })
})
it('其它 GraphQL 错误把原文带出来', async () => {
stubFetch(json(rawIssue(5)), json({ errors: [{ type: 'NOT_FOUND', message: 'Could not resolve' }] }))
await expect(provider().deleteIssue(5)).rejects.toThrow('Could not resolve')
})
})
describe('依赖', () => {
it('列出挡住本工单的工单编号,翻页,只认本仓库的', async () => {
const fetch = stubFetch(
json([rawIssue(3)], nextLink(`${BASE}/issues/9/dependencies/blocked_by?per_page=100&page=2`)),
json([rawIssue(5), rawIssue(6, { repository_url: `${API}/repos/other/repo` })]),
)
expect(await provider().listDependencies(9)).toEqual([3, 5])
expect(calledUrl(fetch, 0).pathname).toBe('/repos/owner/repo/issues/9/dependencies/blocked_by')
expect(fetch).toHaveBeenCalledTimes(2)
})
it('加依赖:把被依赖工单的编号换成它的内部 id 放进 issue_id', async () => {
const fetch = stubFetch(json(rawIssue(4)), json(rawIssue(9), { status: 201 }))
await provider().addDependency(9, 4)
expect(calledUrl(fetch, 0).pathname).toBe('/repos/owner/repo/issues/4')
expect(fetch.mock.calls[1][0]).toBe(`${BASE}/issues/9/dependencies/blocked_by`)
expect(calledInit(fetch, 1).method).toBe('POST')
expect(calledInit(fetch, 1).body).toBe(JSON.stringify({ issue_id: 1004 }))
})
it('删依赖:内部 id 放在路径里', async () => {
const fetch = stubFetch(json(rawIssue(4)), json(rawIssue(9)))
await provider().removeDependency(9, 4)
expect(fetch.mock.calls[1][0]).toBe(`${BASE}/issues/9/dependencies/blocked_by/1004`)
expect(calledInit(fetch, 1).method).toBe('DELETE')
})
})
describe('pR', () => {
const rawPr = {
number: 4,
state: 'closed',
merged: true,
merged_at: '2026-02-02T00:00:00Z',
html_url: 'https://github.com/owner/repo/pull/4',
body: null,
head: { sha: 'h1', ref: 'feature/x' },
base: { sha: 'b1', ref: 'master' },
}
it('转成统一的 PR', async () => {
stubFetch(json(rawPr))
expect(await provider().getPullRequest(4)).toEqual({
number: 4,
state: 'closed',
merged: true,
mergedAt: '2026-02-02T00:00:00Z',
htmlUrl: 'https://github.com/owner/repo/pull/4',
body: '',
headSha: 'h1',
headRef: 'feature/x',
baseSha: 'b1',
baseRef: 'master',
})
})
it('diff 左侧取 compare 接口给的合并基', async () => {
const fetch = stubFetch(json(rawPr), json({ merge_base_commit: { sha: 'm1' } }))
const p = provider()
expect(await p.pullRequestDiffBase(await p.getPullRequest(4))).toBe('m1')
expect(calledUrl(fetch, 1).pathname).toBe('/repos/owner/repo/compare/b1...h1')
})
it('compare 失败时退回目标分支提交', async () => {
stubFetch(json(rawPr), new Response('nope', { status: 404 }))
const p = provider()
expect(await p.pullRequestDiffBase(await p.getPullRequest(4))).toBe('b1')
})
it('合并用 PUT,方法放在 merge_method 里,不删分支', async () => {
const fetch = stubFetch(json({ merged: true, sha: 'x' }))
expect(await provider().mergePullRequest(4, 'squash')).toEqual({ merged: true })
expect(fetch).toHaveBeenCalledOnce()
expect(fetch.mock.calls[0][0]).toBe(`${BASE}/pulls/4/merge`)
expect(calledInit(fetch, 0).method).toBe('PUT')
expect(calledInit(fetch, 0).body).toBe(JSON.stringify({ merge_method: 'squash' }))
})
it.each([
[405, 'Pull Request is not mergeable', 'conflict'],
[405, 'Merge commits are not allowed on this repository.', 'rejected'],
[409, 'Head branch was modified. Review and try the merge again.', 'head-changed'],
[403, 'Resource not accessible', 'rejected'],
])('合并被拒(%i %s)→ %s', async (status, message, reason) => {
stubFetch(json({ message }, { status }))
expect(await provider().mergePullRequest(4, 'merge')).toEqual({ merged: false, reason, message })
})
it('关 PR 走 pulls 接口', async () => {
const fetch = stubFetch(json({}))
await provider().closePullRequest(4)
expect(fetch.mock.calls[0][0]).toBe(`${BASE}/pulls/4`)
expect(calledInit(fetch, 0).method).toBe('PATCH')
expect(calledInit(fetch, 0).body).toBe(JSON.stringify({ state: 'closed' }))
})
it('改动文件翻页,状态统一成五种', async () => {
const fetch = stubFetch(
json([
{ filename: 'a.ts', status: 'modified', additions: 1, deletions: 2 },
{ filename: 'b.ts', status: 'removed', additions: 0, deletions: 3 },
{ filename: 'c.ts', status: 'changed' },
], nextLink(`${BASE}/pulls/4/files?per_page=100&page=2`)),
json([
{ filename: 'd.ts', status: 'added', additions: 4 },
{ filename: 'e.ts', previous_filename: 'old.ts', status: 'renamed' },
{ filename: 'f.ts', previous_filename: 'src.ts', status: 'copied' },
{ filename: 'g.ts', status: 'unchanged' },
]),
)
expect(await provider().listPullRequestFiles(4)).toEqual([
{ path: 'a.ts', status: 'modified', additions: 1, deletions: 2 },
{ path: 'b.ts', status: 'deleted', additions: 0, deletions: 3 },
{ path: 'c.ts', status: 'modified', additions: 0, deletions: 0 },
{ path: 'd.ts', status: 'added', additions: 4, deletions: 0 },
{ path: 'e.ts', status: 'renamed', additions: 0, deletions: 0, previousFilename: 'old.ts' },
{ path: 'f.ts', status: 'copied', additions: 0, deletions: 0, previousFilename: 'src.ts' },
{ path: 'g.ts', status: 'modified', additions: 0, deletions: 0 },
])
expect(calledUrl(fetch, 0).searchParams.get('per_page')).toBe('100')
expect(fetch).toHaveBeenCalledTimes(2)
})
})
describe('文件与分支', () => {
it('取原文用 contents 接口的 raw 媒体类型,路径逐段编码保留 /', async () => {
const fetch = stubFetch(new Response('原文'))
expect(await provider().getRawFile('src/a b/#x.ts', 'feature/x')).toBe('原文')
const url = calledUrl(fetch, 0)
expect(url.pathname).toBe('/repos/owner/repo/contents/src/a%20b/%23x.ts')
expect(url.searchParams.get('ref')).toBe('feature/x')
expect(calledInit(fetch, 0).headers.Accept).toBe('application/vnd.github.raw+json')
})
it('取不到原文返回空串', async () => {
stubFetch(new Response('', { status: 404 }))
expect(await provider().getRawFile('a.ts', 'x')).toBe('')
})
it('删分支走 git/refs,分支名逐段编码保留 /', async () => {
const fetch = stubFetch(new Response(null, { status: 204 }))
await provider().deleteBranch('feature/a#b')
expect(fetch.mock.calls[0][0]).toBe(`${BASE}/git/refs/heads/feature/a%23b`)
expect(calledInit(fetch, 0).method).toBe('DELETE')
})
})
describe('头像', () => {
it('只对 api.github.com 带 token,头像域名不带', async () => {
const png = (): Response => new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } })
const fetch = stubFetch(png(), png())
const p = provider()
expect(await p.avatarDataUri('https://avatars.githubusercontent.com/u/1?v=4')).toMatch(/^data:image\/png;base64,/)
await p.avatarDataUri('https://api.github.com/some/avatar')
expect(fetch.mock.calls[0][1]).toEqual({ headers: {} })
expect(fetch.mock.calls[1][1]).toEqual({ headers: { Authorization: 'Bearer gh-token' } })
})
it('头像域名上的图要 64 像素的小图,原图可能有几百 KB', async () => {
const fetch = stubFetch(new Response(new Uint8Array([1]), { headers: { 'content-type': 'image/png' } }))
await provider().avatarDataUri('https://avatars.githubusercontent.com/u/1?v=4')
const url = new URL(fetch.mock.calls[0][0] as string)
expect(url.searchParams.get('v')).toBe('4')
expect(url.searchParams.get('s')).toBe('64')
})
})
describe('网页地址与能力', () => {
it('工单和 PR 的网页地址', () => {
const p = provider()
expect(p.issueWebUrl(3)).toBe('https://github.com/owner/repo/issues/3')
expect(p.pullRequestWebUrl(4)).toBe('https://github.com/owner/repo/pull/4')
})
it('能删工单、有依赖,合并方式只有 merge / squash / rebase', () => {
expect(provider().capabilities).toEqual({ deleteIssue: true, dependencies: true, mergeMethods: ['merge', 'squash', 'rebase'] })
})
})
+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}`
}
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { nextPageLink } from './linkHeader'
function res(link?: string): Response {
return new Response(null, link ? { headers: { Link: link } } : {})
}
describe('nextPageLink', () => {
it('取 rel="next" 的地址,地址里带逗号也不切断', () => {
expect(nextPageLink(res('<https://a/x?labels=a,b&page=1>; rel="prev", <https://a/x?labels=a,b&page=3>; rel="next"')))
.toBe('https://a/x?labels=a,b&page=3')
})
it('rel 有多个值或不带引号也认', () => {
expect(nextPageLink(res('<https://a/2>; rel="last next"'))).toBe('https://a/2')
expect(nextPageLink(res('<https://a/2>; rel=next'))).toBe('https://a/2')
})
it('没有 Link 头或没有 next 返回 undefined', () => {
expect(nextPageLink(res())).toBeUndefined()
expect(nextPageLink(res('<https://a/1>; rel="first", <https://a/9>; rel="nextish"'))).toBeUndefined()
})
})
+18
View File
@@ -0,0 +1,18 @@
/**
* 读 REST 接口分页用的 `Link` 响应头(RFC 8288),Gitea 和 GitHub 都用它告诉调用方还有没有下一页。
*/
/** `Link` 头里 rel 含 `next` 那一项的地址,原样返回;没有下一页返回 `undefined`。 */
export function nextPageLink(res: Response): string | undefined {
const header = res.headers.get('link')
if (!header)
return undefined
// 地址包在尖括号里,可能带逗号,所以从 `<` 开始整段匹配,参数到下一个逗号为止,不先按逗号切。
for (const [, url, params] of header.matchAll(/<([^>]*)>([^,<]*)/g)) {
const rel = /\brel\s*=\s*(?:"([^"]*)"|([^\s;]+))/i.exec(params)
const values = (rel?.[1] ?? rel?.[2] ?? '').toLowerCase().split(/\s+/)
if (values.includes('next'))
return url
}
return undefined
}
+11
View File
@@ -6,6 +6,17 @@
/** 哪一家代码托管:host 是 `github.com` 的是 GitHub,其余都按 Gitea 处理。 */
export type ForgeKind = 'gitea' | 'github'
/** host 小写后是 `github.com` 的是 GitHub,其余都当 Gitea;不认 GitHub Enterprise。 */
export function forgeKindForHost(host: string): ForgeKind {
return host.toLowerCase() === 'github.com' ? 'github' : 'gitea'
}
/** 界面上称呼这一家用的名字,如「在 GitHub 打开」。 */
export const FORGE_NAMES: Record<ForgeKind, string> = {
gitea: 'Gitea',
github: 'GitHub',
}
/** 当前登录用户或可指派的用户。 */
export interface ForgeUser {
login: string
+23
View File
@@ -0,0 +1,23 @@
import { existsSync } from 'node:fs'
import * as path from 'node:path'
import process from 'node:process'
/**
* 找命令行工具的绝对路径:先找 `env.PATH` 各目录下的 `name`,再按顺序试 `fallbacks`;都没有返回 `null`。
*
* 从程序坞启动的 VS Code 拿到的 PATH 常常不全,只靠 PATH 会把装了的工具当成没装。
*/
export function findExecutable(name: string, fallbacks: readonly string[], env: NodeJS.ProcessEnv = process.env): string | null {
for (const dir of (env.PATH ?? '').split(path.delimiter)) {
if (!dir)
continue
const candidate = path.join(dir, name)
if (existsSync(candidate))
return candidate
}
for (const candidate of fallbacks) {
if (existsSync(candidate))
return candidate
}
return null
}