Files
superwork/vscode/src/forge/github/provider.test.ts
T

395 lines
16 KiB
TypeScript

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('工单已被删除(410)也返回 null', async () => {
stubFetch(new Response('{"message":"This issue was deleted"}', { status: 410 }))
expect(await provider().getIssue(5)).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'] })
})
})