From 7351b6247fab469c3ccfe1569aea014bf6587654 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 19 Oct 2026 08:18:27 +1100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20GitHub=20provider=EF=BC=8C=E5=87=AD=E6=8D=AE=E5=80=9F=20gh?= =?UTF-8?q?=20=E7=99=BB=E5=BD=95=EF=BC=8C=E5=88=A0=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E8=B5=B0=20GraphQL=E3=80=81=E4=BE=9D=E8=B5=96=E6=8C=89?= =?UTF-8?q?=E5=86=85=E9=83=A8=20id=20=E6=8D=A2=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/cc/claudeBin.ts | 20 +- vscode/src/forge/context.test.ts | 28 +- vscode/src/forge/context.ts | 39 +-- vscode/src/forge/gitea/provider.ts | 9 +- vscode/src/forge/github/ghAuth.test.ts | 42 +++ vscode/src/forge/github/ghAuth.ts | 26 ++ vscode/src/forge/github/provider.test.ts | 389 ++++++++++++++++++++ vscode/src/forge/github/provider.ts | 429 +++++++++++++++++++++++ vscode/src/forge/linkHeader.test.ts | 23 ++ vscode/src/forge/linkHeader.ts | 18 + vscode/src/forge/types.ts | 11 + vscode/src/shared/executable.ts | 23 ++ 12 files changed, 1010 insertions(+), 47 deletions(-) create mode 100644 vscode/src/forge/github/ghAuth.test.ts create mode 100644 vscode/src/forge/github/ghAuth.ts create mode 100644 vscode/src/forge/github/provider.test.ts create mode 100644 vscode/src/forge/github/provider.ts create mode 100644 vscode/src/forge/linkHeader.test.ts create mode 100644 vscode/src/forge/linkHeader.ts create mode 100644 vscode/src/shared/executable.ts diff --git a/vscode/src/cc/claudeBin.ts b/vscode/src/cc/claudeBin.ts index 0edd143..c9c37d3 100644 --- a/vscode/src/cc/claudeBin.ts +++ b/vscode/src/cc/claudeBin.ts @@ -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) } diff --git a/vscode/src/forge/context.test.ts b/vscode/src/forge/context.test.ts index a912ec6..8942841 100644 --- a/vscode/src/forge/context.test.ts +++ b/vscode/src/forge/context.test.ts @@ -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(), 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() }) diff --git a/vscode/src/forge/context.ts b/vscode/src/forge/context.ts index 4c46eb5..567eff5 100644 --- a/vscode/src/forge/context.ts +++ b/vscode/src/forge/context.ts @@ -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 { 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 }) } } } /** 解析当前工作区(第一个工作区文件夹)的代码托管仓库。 */ diff --git a/vscode/src/forge/gitea/provider.ts b/vscode/src/forge/gitea/provider.ts index 0f2a74d..5b5ed11 100644 --- a/vscode/src/forge/gitea/provider.ts +++ b/vscode/src/forge/gitea/provider.ts @@ -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 } } diff --git a/vscode/src/forge/github/ghAuth.test.ts b/vscode/src/forge/github/ghAuth.test.ts new file mode 100644 index 0000000..05da22c --- /dev/null +++ b/vscode/src/forge/github/ghAuth.test.ts @@ -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] + 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() + }) +}) diff --git a/vscode/src/forge/github/ghAuth.ts b/vscode/src/forge/github/ghAuth.ts new file mode 100644 index 0000000..e3f9c96 --- /dev/null +++ b/vscode/src/forge/github/ghAuth.ts @@ -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 { + 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) + }) + }) +} diff --git a/vscode/src/forge/github/provider.test.ts b/vscode/src/forge/github/provider.test.ts new file mode 100644 index 0000000..2811121 --- /dev/null +++ b/vscode/src/forge/github/provider.test.ts @@ -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 = {}): Record { + 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 { + const fetch = vi.fn() + for (const res of responses) + fetch.mockResolvedValueOnce(res) + vi.stubGlobal('fetch', fetch) + return fetch +} + +function calledUrl(fetch: ReturnType, i: number): URL { + return new URL(fetch.mock.calls[i][0] as string) +} + +function calledInit(fetch: ReturnType, i: number): RequestInit & { headers: Record } { + return fetch.mock.calls[i][1] as RequestInit & { headers: Record } +} + +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 } + 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'] }) + }) +}) diff --git a/vscode/src/forge/github/provider.ts b/vscode/src/forge/github/provider.ts new file mode 100644 index 0000000..96fbea2 --- /dev/null +++ b/vscode/src/forge/github/provider.ts @@ -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 { + 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) + } + + /** 这个编号是 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) + 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}` + } +} diff --git a/vscode/src/forge/linkHeader.test.ts b/vscode/src/forge/linkHeader.test.ts new file mode 100644 index 0000000..4f71700 --- /dev/null +++ b/vscode/src/forge/linkHeader.test.ts @@ -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('; rel="prev", ; rel="next"'))) + .toBe('https://a/x?labels=a,b&page=3') + }) + + it('rel 有多个值或不带引号也认', () => { + expect(nextPageLink(res('; rel="last next"'))).toBe('https://a/2') + expect(nextPageLink(res('; rel=next'))).toBe('https://a/2') + }) + + it('没有 Link 头或没有 next 返回 undefined', () => { + expect(nextPageLink(res())).toBeUndefined() + expect(nextPageLink(res('; rel="first", ; rel="nextish"'))).toBeUndefined() + }) +}) diff --git a/vscode/src/forge/linkHeader.ts b/vscode/src/forge/linkHeader.ts new file mode 100644 index 0000000..6ead794 --- /dev/null +++ b/vscode/src/forge/linkHeader.ts @@ -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 +} diff --git a/vscode/src/forge/types.ts b/vscode/src/forge/types.ts index ef83d46..f12d04c 100644 --- a/vscode/src/forge/types.ts +++ b/vscode/src/forge/types.ts @@ -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 = { + gitea: 'Gitea', + github: 'GitHub', +} + /** 当前登录用户或可指派的用户。 */ export interface ForgeUser { login: string diff --git a/vscode/src/shared/executable.ts b/vscode/src/shared/executable.ts new file mode 100644 index 0000000..4b3262c --- /dev/null +++ b/vscode/src/shared/executable.ts @@ -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 +}