✨ feat(vscode): 新增 GitHub provider,凭据借 gh 登录,删工单走 GraphQL、依赖按内部 id 换算
This commit is contained in:
1 parent
451bc91a6b
commit
7351b6247f
12 files changed
+1010
-47
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 }) } }
|
||||
}
|
||||
|
||||
/** 解析当前工作区(第一个工作区文件夹)的代码托管仓库。 */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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'] })
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user