395 lines
16 KiB
TypeScript
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'] })
|
|
})
|
|
})
|