Files
deepseek-harness/packages/experimental/forge-github/tests/provider.spec.ts
T
Yichen Jiang 9b456b4e93 perf(forge): compare from the recorded base while confirming the head
Status snapshots carry the base commit the host recorded with the head
(baseSha). ForgeService.diff passes it to the provider when the latest
status names the requested head, and forge-github starts the compare read
from it while its GraphQL read confirms the head; a stale head or another
base cancels and joins the early read.
2026-11-07 16:22:24 +08:00

709 lines
43 KiB
TypeScript

import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
import { describe, expect, it, vi } from 'vitest'
import { GhOutputLimitError, GhTimeoutError, GhUnavailableError } from '../src/gh.ts'
import type { GhOutput, GhRunner } from '../src/gh.ts'
import { PULL_REQUEST_COMMITS_QUERY, THREADS_QUERY } from '../src/graphql.ts'
import { GitHubProvider } from '../src/provider.ts'
import type { GitHubLimits } from '../src/provider.ts'
import { checkRun, demo, pull, pullRequestNode, threadPage } from './fixtures.ts'
const enterprise = { forge: 'github', host: 'ghe.corp.example', owner: 'team', name: 'svc' }
const signal = new AbortController().signal
function fakeGh(handler: (args: readonly string[]) => GhOutput | Promise<GhOutput>): { run: GhRunner; calls: string[][] } {
const calls: string[][] = []
return {
calls,
run: async (args) => {
calls.push([...args])
return await handler(args)
},
}
}
function output(stdout: unknown, exitCode = 0, stderr = ''): GhOutput {
return { exitCode, stdout: typeof stdout === 'string' ? stdout : JSON.stringify(stdout), stderr }
}
const limits: GitHubLimits = { maxOutputBytes: 8_388_608, maxReviewThreads: 1_000, maxCommits: 250 }
function provider(run: GhRunner): GitHubProvider {
return new GitHubProvider(run, ['github.com', 'GHE.corp.example'], limits)
}
describe('GitHub provider addressing', () => {
const subject = provider(fakeGh(() => output('')).run)
it('serves remotes and pull request URLs on configured hosts only', () => {
expect(subject.repositoryFromRemote('[email protected]:octo/demo.git')).toEqual(demo)
expect(subject.repositoryFromRemote('https://ghe.corp.example/team/svc')).toEqual(enterprise)
expect(subject.repositoryFromRemote('https://gitlab.com/octo/demo')).toBeUndefined()
expect(subject.repositoryFromRemote('/local/repo')).toBeUndefined()
expect(subject.changeRequestFromUrl('https://github.com/octo/demo/pull/4')).toEqual(pull(4))
expect(subject.changeRequestFromUrl('https://gitlab.com/octo/demo/pull/4')).toBeUndefined()
expect(subject.changeRequestFromUrl('https://github.com/octo/demo/issues/4')).toBeUndefined()
})
it('builds canonical pull request URLs', () => {
expect(subject.changeRequestUrl(pull(4, enterprise))).toBe('https://ghe.corp.example/team/svc/pull/4')
})
it('uses a configured HTTP port for URL mapping and GitHub queries', async () => {
const gh = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1) } } }))
const subject = new GitHubProvider(gh.run, ['ghe.example.com:8443'], limits)
const repository = subject.repositoryFromRemote('https://ghe.example.com:8443/team/repo.git')!
const ref = { repository, number: 1 }
expect(subject.changeRequestFromUrl(subject.changeRequestUrl(ref))).toEqual(ref)
await subject.fetch([ref], signal)
expect(gh.calls[0]?.[3]).toBe('ghe.example.com:8443')
})
})
describe('GitHub provider fetch', () => {
const firstPage = () => pullRequestNode(1, { commits: { nodes: [{ commit: { statusCheckRollup: {
id: 'fixed-rollup', state: 'SUCCESS', contexts: { pageInfo: { hasNextPage: true, endCursor: 'page-1' },
nodes: Array.from({ length: 100 }, (_, index) => ({ ...checkRun('lint', 'COMPLETED', 'FAILURE'), databaseId: index + 1 })) },
} } }] } })
it('publishes the first page and other PRs without requesting check continuations', async () => {
const gh = fakeGh(() => output({ data: { r0: { p0: firstPage(), p1: pullRequestNode(2) } } }))
const results = await provider(gh.run).fetch([pull(1), pull(2)], signal)
expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ checksTruncated: true, checksRollup: 'success', checks: [{ state: 'failure' }] })
expect(results.get(changeRequestKey(pull(2)))).toMatchObject({ title: 'Change 2', checksTruncated: false })
expect(gh.calls).toHaveLength(1)
})
it('counts review threads past the first page while polling, bounded by maxReviewThreads', async () => {
const many = pullRequestNode(1, { reviewThreads: threadPage(Array.from({ length: 100 }, () => true), 'threads-1') })
const gh = fakeGh(args => args.includes(`query=${THREADS_QUERY}`)
? output({ data: { node: { reviewThreads: threadPage([false, false], 'threads-2') } } })
: output({ data: { r0: { p0: many, p1: pullRequestNode(2) } } }))
const results = await new GitHubProvider(gh.run, ['github.com'], { ...limits, maxReviewThreads: 102 }).fetch([pull(1), pull(2)], signal)
expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ unresolvedThreads: 2, threadsTruncated: true })
expect(results.get(changeRequestKey(pull(2)))).toMatchObject({ unresolvedThreads: 2, threadsTruncated: false })
expect(gh.calls).toHaveLength(2)
expect(gh.calls[1]).toEqual(['api', 'graphql', '--hostname', 'github.com',
'-f', `query=${THREADS_QUERY}`, '-f', 'id=pull-1', '-f', 'cursor=threads-1', '-F', 'first=2'])
})
it.each([
[new GhTimeoutError('timed out'), 'unavailable'],
[new GhOutputLimitError('too much output'), 'too-large'],
[new GhUnavailableError('gh missing'), 'unavailable'],
] as const)('fails only the pull request whose review thread page cannot be read', async (error, code) => {
const many = pullRequestNode(1, { reviewThreads: threadPage([true], 'threads-1') })
const gh = fakeGh((args) => {
if (args.includes(`query=${THREADS_QUERY}`)) throw error
return output({ data: { r0: { p0: many, p1: pullRequestNode(2) } } })
})
const results = await provider(gh.run).fetch([pull(1), pull(2)], signal)
expect(results.get(changeRequestKey(pull(1)))).toEqual({ code, message: error.message })
expect(results.get(changeRequestKey(pull(2)))).toMatchObject({ title: 'Change 2' })
})
it('reports a review thread page that GitHub refuses', async () => {
const gh = fakeGh(args => args.includes(`query=${THREADS_QUERY}`)
? output({ message: 'API rate limit exceeded' }, 1, 'gh: API rate limit exceeded (HTTP 403)')
: output({ data: { r0: { p0: pullRequestNode(1, { reviewThreads: threadPage([true], 'threads-1') }) } } }))
await expect(provider(gh.run).fetchChecks(pull(1), signal))
.resolves.toEqual({ code: 'unavailable', message: 'gh: API rate limit exceeded (HTTP 403)' })
})
it.each([
[new GhTimeoutError('timed out'), 'unavailable'],
[new GhOutputLimitError('too much output'), 'too-large'],
[new GhUnavailableError('gh missing'), 'unavailable'],
] as const)('reports an initial check-detail process failure', async (error, code) => {
await expect(provider(fakeGh(() => { throw error }).run).fetchChecks(pull(1), signal))
.resolves.toEqual({ code, message: error.message })
})
it('refuses unconfigured hosts before starting a detail read', async () => {
const gh = fakeGh(() => output(''))
await expect(provider(gh.run).fetchChecks(pull(1, { ...demo, host: 'unconfigured.example' }), signal))
.resolves.toMatchObject({ code: 'unavailable' })
expect(gh.calls).toEqual([])
})
it('bounds the complete detail read by combined UTF-8 JSON bytes', async () => {
const first = output({ data: { r0: { p0: firstPage() } } })
const last = output({ data: { node: { contexts: { pageInfo: { hasNextPage: false, endCursor: null },
nodes: [checkRun('检查', 'COMPLETED', 'SUCCESS')] } } } })
const total = Buffer.byteLength(first.stdout) + Buffer.byteLength(last.stdout)
for (const limit of [total, total - 1, Buffer.byteLength(first.stdout) - 1]) {
const gh = fakeGh(args => args.includes('id=fixed-rollup') ? last : first)
const result = await new GitHubProvider(gh.run, ['github.com'], { ...limits, maxOutputBytes: limit }).fetchChecks(pull(1), signal)
if (limit === total) expect(result).toMatchObject({ checksTruncated: false })
else expect(result).toMatchObject({ code: 'too-large' })
expect(gh.calls).toHaveLength(limit < Buffer.byteLength(first.stdout) ? 1 : 2)
}
})
it('counts review thread pages toward the byte budget of a complete detail read, but not of polling', async () => {
const first = output({ data: { r0: { p0: pullRequestNode(1, { reviewThreads: threadPage([true], 'threads-1') }) } } })
const page = output({ data: { node: { reviewThreads: threadPage([false, false]) } } })
const total = Buffer.byteLength(first.stdout) + Buffer.byteLength(page.stdout)
for (const limit of [total, total - 1]) {
const gh = fakeGh(args => args.includes(`query=${THREADS_QUERY}`) ? page : first)
const subject = new GitHubProvider(gh.run, ['github.com'], { ...limits, maxOutputBytes: limit })
const result = await subject.fetchChecks(pull(1), signal)
if (limit === total) expect(result).toMatchObject({ unresolvedThreads: 2, threadsTruncated: false })
else expect(result).toEqual({ code: 'too-large', message: 'GitHub check details exceed the output size limit' })
expect((await subject.fetch([pull(1)], signal)).get(changeRequestKey(pull(1)))).toMatchObject({ unresolvedThreads: 2 })
}
})
it('reads past 100 historical runs from the pinned rollup and counts only the latest run', async () => {
const gh = fakeGh(args => output(args.includes('id=fixed-rollup')
? { data: { node: { contexts: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [
{ ...checkRun('lint', 'COMPLETED', 'SUCCESS'), databaseId: 101 }, checkRun('test', 'COMPLETED', 'SUCCESS'),
] } } } }
: { data: { r0: { p0: firstPage() } } }))
const result = await provider(gh.run).fetchChecks(pull(1), signal)
expect(result).toMatchObject({ checks: [{ name: 'lint', state: 'success' }, { name: 'test', state: 'success' }], checksTruncated: false })
expect(gh.calls).toHaveLength(2)
expect(gh.calls[1]).toContain('cursor=page-1')
expect(gh.calls[1]?.[5]).toContain('node(id: $id)')
})
it.each([
[output({ message: 'API rate limit exceeded' }, 1), 'API rate limit exceeded'],
[output({ errors: [{ message: 'Check page failed' }] }, 1), 'Check page failed'],
[output({ data: { node: { contexts: { pageInfo: { hasNextPage: true, endCursor: 'page-1' }, nodes: [] } } } }), 'GitHub check pagination did not advance'],
[output({ data: { node: { contexts: { pageInfo: { hasNextPage: true, endCursor: null }, nodes: [] } } } }), 'GitHub check pagination did not advance'],
])('rejects an incomplete check read without losing another pull request', async (page, message) => {
const gh = fakeGh(args => args.includes('id=fixed-rollup') ? page : output({ data: { r0: { p0: firstPage(), p1: pullRequestNode(2) } } }))
const subject = provider(gh.run)
const results = await subject.fetch([pull(1), pull(2)], signal)
const details = await subject.fetchChecks(pull(1), signal)
expect(details).toEqual({ code: 'unavailable', message })
expect(results.get(changeRequestKey(pull(2)))).toMatchObject({ title: 'Change 2' })
expect(gh.calls).toHaveLength(3)
})
it('stops before requesting another check page when cancelled', async () => {
const controller = new AbortController()
const gh = fakeGh(() => { controller.abort(new Error('cancelled')); return output({ data: { r0: { p0: firstPage() } } }) })
await expect(provider(gh.run).fetchChecks(pull(1), controller.signal)).rejects.toThrow('cancelled')
expect(gh.calls).toHaveLength(1)
})
it.each([
[new GhTimeoutError('timed out'), 'unavailable'],
[new GhOutputLimitError('too much output'), 'too-large'],
[new GhUnavailableError('gh missing'), 'unavailable'],
] as const)(
'contains a failed continuation process to its pull request', async (error, code) => {
const gh = fakeGh((args) => {
if (args.includes('id=fixed-rollup')) throw error
return output({ data: { r0: { p0: firstPage(), p1: pullRequestNode(2) } } })
})
const subject = provider(gh.run)
const results = await subject.fetch([pull(1), pull(2)], signal)
const details = await subject.fetchChecks(pull(1), signal)
expect(details).toEqual({ code, message: error.message })
expect(results.get(changeRequestKey(pull(2)))).toMatchObject({ title: 'Change 2' })
},
)
it('propagates cancellation and unexpected defects during a continuation', async () => {
for (const cancel of [true, false]) {
const controller = new AbortController()
const gh = fakeGh((args) => {
if (args.includes('id=fixed-rollup')) {
if (cancel) controller.abort(new Error('cancelled'))
throw new Error('unexpected')
}
return output({ data: { r0: { p0: firstPage() } } })
})
await expect(provider(gh.run).fetchChecks(pull(1), controller.signal)).rejects.toThrow(cancel ? 'cancelled' : 'unexpected')
}
})
it.each([
[{ message: 'Bad credentials' }, 1, 'gh: Bad credentials (HTTP 401)', 'unauthenticated', 'gh: Bad credentials (HTTP 401)'],
[{ message: 'API rate limit exceeded' }, 1, 'gh: API rate limit exceeded (HTTP 403)', 'unavailable', 'gh: API rate limit exceeded (HTTP 403)'],
[{ message: 'Bad credentials' }, 1, '', 'unauthenticated', 'Bad credentials'],
[{ message: '' }, 0, '', 'unavailable', 'unexpected GitHub response'],
[null, 1, '', 'unavailable', 'gh exited with 1'],
[[], 0, '', 'unavailable', 'unexpected GitHub response'],
])('preserves HTTP failure diagnostics for %j', async (body, exitCode, stderr, code, message) => {
const gh = fakeGh(() => output(body, exitCode, stderr))
const subject = provider(gh.run)
expect([...(await subject.fetch([pull(1)], signal)).values()]).toEqual([{ code, message }])
await expect(subject.findByHead(demo, 'topic', signal)).rejects.toThrow(`head branch lookup failed: ${message}`)
})
it('rejects unconfigured hosts without querying them and still fetches allowed hosts', async () => {
const gh = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1) } } }))
const denied = pull(2, { ...enterprise, host: 'unconfigured.example' })
const results = await provider(gh.run).fetch([denied, pull(1)], signal)
expect(gh.calls.map(call => call[3])).toEqual(['github.com'])
expect(results.get(changeRequestKey(denied))).toEqual({ code: 'unavailable', message: 'forge-github: host "unconfigured.example" is not configured' })
expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ title: 'Change 1' })
})
it('rejects references retained after their host was removed from configuration', async () => {
const gh = fakeGh(() => output(''))
const retained = provider(gh.run).changeRequestFromUrl('https://ghe.corp.example/team/svc/pull/2')!
const replacement = new GitHubProvider(gh.run, ['github.com'], limits)
const results = await replacement.fetch([retained], signal)
await expect(replacement.findByHead(retained.repository, 'topic', signal)).rejects.toThrow('is not configured')
expect(results.get(changeRequestKey(retained))).toMatchObject({ code: 'unavailable' })
expect(gh.calls).toEqual([])
})
it('runs one GraphQL request per host and merges the results', async () => {
const gh = fakeGh((args) => {
const host = args[3]
return output({ data: { r0: { p0: pullRequestNode(host === 'github.com' ? 1 : 2) } } })
})
const results = await provider(gh.run).fetch([pull(1), pull(2, enterprise)], signal)
expect(gh.calls.map(call => call.slice(0, 5))).toEqual([
['api', 'graphql', '--hostname', 'github.com', '-f'],
['api', 'graphql', '--hostname', 'ghe.corp.example', '-f'],
])
expect(gh.calls[0]?.[5]).toMatch(/^query=query \{ r0: repository\(owner: "octo", name: "demo"\)/)
expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ title: 'Change 1' })
expect(results.get(changeRequestKey(pull(2, enterprise)))).toMatchObject({ title: 'Change 2' })
})
it('maps the JSON of a partial-error response that exits with 1', async () => {
const gh = fakeGh(() => output({
data: { r0: { p0: null } },
errors: [{ type: 'NOT_FOUND', path: ['r0', 'p0'], message: 'Could not resolve to a PullRequest with the number of 1.' }],
}, 1, 'gh: Could not resolve to a PullRequest with the number of 1.'))
const results = await provider(gh.run).fetch([pull(1)], signal)
expect(results.get(changeRequestKey(pull(1)))).toEqual({ code: 'not-found', message: 'Could not resolve to a PullRequest with the number of 1.' })
})
it.each([
['To get started with GitHub CLI, please run: gh auth login', 'unauthenticated'],
['HTTP 401: Bad credentials (https://api.github.com/graphql)', 'unauthenticated'],
['error connecting to api.github.com', 'unavailable'],
])('classifies a failure without JSON: %s', async (stderr, code) => {
const gh = fakeGh(() => output('', 1, `\n${stderr}\n`))
const results = await provider(gh.run).fetch([pull(1), pull(2)], signal)
expect([...results.values()]).toEqual([{ code, message: stderr }, { code, message: stderr }])
})
it('names the exit code when gh prints nothing', async () => {
const results = await provider(fakeGh(() => output('', 4)).run).fetch([pull(1)], signal)
expect(results.get(changeRequestKey(pull(1)))).toEqual({ code: 'unavailable', message: 'gh exited with 4' })
})
it('propagates a runner rejection', async () => {
const subject = provider(async () => {
throw new Error('aborted')
})
await expect(subject.fetch([pull(1)], signal)).rejects.toThrow('aborted')
})
})
describe('GitHub provider head branch lookup', () => {
it('passes the repository and branch as GraphQL variables', async () => {
const gh = fakeGh(() => output({ data: { repository: { defaultBranchRef: { name: 'master' }, pullRequests: { nodes: [{ number: 5, isCrossRepository: false }] } } } }))
await expect(provider(gh.run).findByHead(demo, 'topic', signal)).resolves.toEqual([pull(5)])
expect(gh.calls[0]?.slice(0, 4)).toEqual(['api', 'graphql', '--hostname', 'github.com'])
expect(gh.calls[0]?.slice(6)).toEqual(['-f', 'owner=octo', '-f', 'name=demo', '-f', 'branch=topic'])
})
it('reports gh diagnostics when the lookup prints no JSON', async () => {
await expect(provider(fakeGh(() => output('', 1, 'HTTP 502')).run).findByHead(demo, 'topic', signal))
.rejects.toThrow('forge-github: head branch lookup failed: HTTP 502')
await expect(provider(fakeGh(() => output('', 2)).run).findByHead(demo, 'topic', signal))
.rejects.toThrow('forge-github: head branch lookup failed: gh exited with 2')
})
})
describe('GitHub provider readiness', () => {
it('is ready when gh auth status succeeds for the host', async () => {
const gh = fakeGh(() => output('Logged in to github.com'))
await expect(provider(gh.run).readiness('github.com', signal)).resolves.toEqual({ state: 'ready', message: null })
expect(gh.calls).toEqual([['auth', 'status', '--hostname', 'github.com']])
})
it('reports the first diagnostic line when gh is not signed in', async () => {
await expect(provider(fakeGh(() => output('', 1, 'You are not logged into any GitHub hosts.\nRun gh auth login')).run).readiness('github.com', signal))
.resolves.toEqual({ state: 'unauthenticated', message: 'You are not logged into any GitHub hosts.' })
await expect(provider(fakeGh(() => output('github.com\n X Failed to log in', 1)).run).readiness('github.com', signal))
.resolves.toEqual({ state: 'unauthenticated', message: 'github.com' })
await expect(provider(fakeGh(() => output('', 1)).run).readiness('github.com', signal))
.resolves.toEqual({ state: 'unauthenticated', message: 'gh is not signed in to github.com' })
})
it('is unavailable when gh cannot start and rethrows other failures', async () => {
await expect(provider(() => Promise.reject(new GhUnavailableError('gh could not start: spawn gh ENOENT')))
.readiness('github.com', signal)).resolves.toEqual({ state: 'unavailable', message: 'gh could not start: spawn gh ENOENT' })
await expect(provider(() => Promise.reject(new Error('timed out'))).readiness('github.com', signal)).rejects.toThrow('timed out')
})
})
describe('GitHub provider actions', () => {
const head = 'c'.repeat(40)
it('marks a pull request ready for review through gh pr ready on its host', async () => {
const gh = fakeGh(() => output(''))
await expect(provider(gh.run).markReady(pull(7, enterprise), signal)).resolves.toBeUndefined()
expect(gh.calls).toEqual([['pr', 'ready', '7', '--repo', 'ghe.corp.example/team/svc']])
})
it('merges a pull request through the REST merge endpoint with the confirmed head', async () => {
const gh = fakeGh(() => output({ sha: 'd'.repeat(40), merged: true, message: 'Pull Request successfully merged' }))
await expect(provider(gh.run).merge(pull(7), { method: 'squash', headSha: head }, signal)).resolves.toBeUndefined()
expect(gh.calls).toEqual([[
'api', '--hostname', 'github.com', '--method', 'PUT', 'repos/octo/demo/pulls/7/merge', '-f', 'merge_method=squash', '-f', `sha=${head}`,
]])
})
it.each([
['a moved head', { message: 'Head branch was modified. Review and try the merge again.' }, 'gh: Head branch was modified. Review and try the merge again. (HTTP 409)',
{ code: 'stale', message: 'Head branch was modified. Review and try the merge again.' }],
['an unmet merge rule', { message: 'Pull Request is not mergeable' }, 'gh: Pull Request is not mergeable (HTTP 405)',
{ code: 'failed', message: 'Pull Request is not mergeable' }],
['rejected credentials', { message: 'Bad credentials' }, 'gh: Bad credentials (HTTP 401)', { code: 'unauthenticated', message: 'Bad credentials' }],
['an error body without a message', { errors: [] }, 'gh: Validation Failed (HTTP 422)', { code: 'failed', message: 'gh: Validation Failed (HTTP 422)' }],
['an empty message', { message: ' ' }, 'gh: Not Found (HTTP 404)', { code: 'failed', message: 'gh: Not Found (HTTP 404)' }],
['no output', '', '', { code: 'failed', message: 'gh exited with 1' }],
])('reports a merge refused for %s', async (_label, body, stderr, failure) => {
await expect(provider(fakeGh(() => output(body, 1, stderr)).run).merge(pull(7), { method: 'merge', headSha: head }, signal)).resolves.toEqual(failure)
})
it('reports a failed ready mark by the first diagnostic line, recognizing a missing sign-in', async () => {
await expect(provider(fakeGh(() => output('', 1, '\nGraphQL: Resource not accessible by integration (markPullRequestReadyForReview)\n')).run)
.markReady(pull(7), signal)).resolves.toEqual({ code: 'failed', message: 'GraphQL: Resource not accessible by integration (markPullRequestReadyForReview)' })
await expect(provider(fakeGh(() => output('', 4, 'To get started with GitHub CLI, please run: gh auth login')).run).markReady(pull(7), signal))
.resolves.toEqual({ code: 'unauthenticated', message: 'To get started with GitHub CLI, please run: gh auth login' })
})
it('reports an action as failed when gh cannot start and rethrows other failures', async () => {
await expect(provider(() => Promise.reject(new GhUnavailableError('gh could not start: spawn gh ENOENT'))).markReady(pull(7), signal))
.resolves.toEqual({ code: 'failed', message: 'gh could not start: spawn gh ENOENT' })
await expect(provider(() => Promise.reject(new Error('aborted'))).merge(pull(7), { method: 'rebase', headSha: head }, signal)).rejects.toThrow('aborted')
})
it('refuses to address a pull request or head commit that cannot be inlined', async () => {
const gh = fakeGh(() => output(''))
const subject = provider(gh.run)
await expect(subject.markReady(pull(7, { ...demo, owner: 'oc/to' }), signal)).rejects.toThrow('forge-github: invalid repository "oc/to/demo"')
await expect(subject.merge(pull(0), { method: 'merge', headSha: head }, signal)).rejects.toThrow('forge-github: invalid pull request number 0')
await expect(subject.merge(pull(7), { method: 'merge', headSha: 'HEAD' }, signal)).rejects.toThrow('forge-github: invalid head commit "HEAD"')
await expect(subject.merge(pull(7), { method: 'merge', headSha: 'e'.repeat(64) }, signal)).resolves.toBeUndefined()
expect(gh.calls).toHaveLength(1)
})
})
describe('GitHub provider reads', () => {
const head = 'c'.repeat(40)
const pushed = 'd'.repeat(40)
const base = 'b'.repeat(40)
/** Answer the pull request commit read with `commits`, and every other command, the compare read among them, with `other`. */
function pinned(
commits: { readonly head: string; readonly base: string } | GhOutput = { head, base },
other: (args: readonly string[]) => GhOutput = () => output('diff --git a/x b/x\n'),
) {
return fakeGh((args) => {
if (!args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)) return other(args)
return 'exitCode' in commits ? commits : output({ data: { repository: { pullRequest: { headRefOid: commits.head, baseRefOid: commits.base } } } })
})
}
it('reads the diff between the recorded base and the requested head through the compare API after one commit read', async () => {
const gh = pinned()
await expect(provider(gh.run).diff(pull(7, enterprise), head, signal)).resolves.toBe('diff --git a/x b/x\n')
expect(gh.calls).toEqual([
['api', 'graphql', '--hostname', 'ghe.corp.example',
'-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', 'owner=team', '-f', 'name=svc', '-F', 'number=7'],
['api', '--hostname', 'ghe.corp.example', '--allow-escape-sequences', '-H', 'Accept: application/vnd.github.diff',
`repos/team/svc/compare/${base}...${head}`],
])
})
it('compares the head commit without regard to case and addresses the requested spelling', async () => {
const gh = pinned()
await expect(provider(gh.run).diff(pull(7), head.toUpperCase(), signal)).resolves.toBe('diff --git a/x b/x\n')
expect(gh.calls[1]?.at(-1)).toBe(`repos/octo/demo/compare/${base}...${head.toUpperCase()}`)
})
it('starts the comparison from the base the status recorded while it confirms the head', async () => {
const confirmed = Promise.withResolvers<GhOutput>()
const gh = fakeGh(args => args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)
? confirmed.promise : output('diff --git a/x b/x\n'))
const read = provider(gh.run).diff(pull(7), head, signal, base)
await vi.waitFor(() => { expect(gh.calls).toHaveLength(2) })
expect(gh.calls.map(call => call.at(-1))).toContain(`repos/octo/demo/compare/${base}...${head}`)
confirmed.resolve(output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: base.toUpperCase() } } } }))
await expect(read).resolves.toBe('diff --git a/x b/x\n')
expect(gh.calls).toHaveLength(2)
})
it('cancels the early comparison and waits for it when the head moved, the base differs, or the head read fails', async () => {
const moved = 'e'.repeat(40)
for (const [answer, expected, compares] of [
[output({ data: { repository: { pullRequest: { headRefOid: pushed, baseRefOid: base } } } }),
{ code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }, [base]],
[output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: moved } } } }), 'diff --git a/y b/y\n', [base, moved]],
[output('', 1, 'gh: Something went wrong (HTTP 502)'), { code: 'unavailable', message: 'gh: Something went wrong (HTTP 502)' }, [base]],
] as const) {
const calls: string[][] = []
const cancelled: string[] = []
const run: GhRunner = async (args, operation) => {
calls.push([...args])
if (args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)) return answer
const range = String(args.at(-1))
if (range.includes(`${moved}...`)) return output('diff --git a/y b/y\n')
// The early comparison runs until the provider cancels it.
await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) })
cancelled.push(range)
throw operation.reason
}
await expect(provider(run).diff(pull(7), head, signal, base)).resolves.toEqual(expected)
expect(calls.map(call => String(call.at(-1))).filter(target => target.includes('/compare/'))
.map(target => target.split('/compare/')[1]?.split('...')[0])).toEqual(compares)
expect(cancelled).toEqual([`repos/octo/demo/compare/${base}...${head}`])
}
})
it('refuses a base that is not a full commit object name before starting gh', async () => {
const gh = pinned()
await expect(provider(gh.run).diff(pull(7), head, signal, 'HEAD~1')).rejects.toThrow('forge-github: invalid base commit "HEAD~1"')
expect(gh.calls).toEqual([])
})
it('reports a head that moved past the requested commit as stale without reading the diff', async () => {
const gh = pinned({ head: pushed, base })
await expect(provider(gh.run).diff(pull(7), head, signal))
.resolves.toEqual({ code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` })
expect(gh.calls).toHaveLength(1)
})
it.each([
['a missing pull request', output({ data: { repository: { pullRequest: null } }, errors: [{ type: 'NOT_FOUND', message: 'Could not resolve' }] }, 1,
'gh: Could not resolve to a PullRequest with the number of 7.'),
{ code: 'not-found', message: 'gh: Could not resolve to a PullRequest with the number of 7.' }],
['a refusal', output({ message: 'Server Error' }, 1, 'gh: Server Error (HTTP 502)'), { code: 'unavailable', message: 'Server Error' }],
['an answer without commits', output({ data: { repository: {} } }),
{ code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' }],
['a base that is not a commit object name', output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: 'main' } } } }),
{ code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' }],
] as const)('reports a commit read that fails with %s without reading the diff', async (_label, answer, failure) => {
const gh = pinned(answer)
await expect(provider(gh.run).diff(pull(7), head, signal)).resolves.toEqual(failure)
expect(gh.calls).toHaveLength(1)
})
it('reads the diff with a gh older than 2.97, which rejects the escape option, by repeating the read without it', async () => {
// What gh 2.96.0 prints for an option it does not know.
const gh = pinned(undefined, args => args.includes('--allow-escape-sequences')
? output('', 1, 'unknown flag: --allow-escape-sequences\n\nUsage: gh api <endpoint> [flags]\n')
: output('diff --git a/x b/x\n'))
await expect(provider(gh.run).diff(pull(7), head, signal)).resolves.toBe('diff --git a/x b/x\n')
expect(gh.calls.map(call => call.includes('--allow-escape-sequences'))).toEqual([false, true, false])
})
it('refuses a head that is not a full commit object name before starting gh', async () => {
const gh = pinned()
await expect(provider(gh.run).diff(pull(7), 'HEAD', signal)).rejects.toThrow('forge-github: invalid head commit "HEAD"')
expect(gh.calls).toEqual([])
})
it('reads a file at a commit through the raw contents API, encoding each path segment', async () => {
const gh = fakeGh(() => output('line\n'))
await expect(provider(gh.run).fileText(pull(7), head, 'docs/中文 notes/#1.md', signal)).resolves.toBe('line\n')
expect(gh.calls).toEqual([[
'api', '--hostname', 'github.com', '-H', 'Accept: application/vnd.github.raw+json',
`repos/octo/demo/contents/docs/${encodeURIComponent('中文 notes')}/%231.md?ref=${head}`,
]])
})
it('builds the web page of a file at a commit', () => {
expect(provider(fakeGh(() => output('')).run).fileUrl(pull(7), head, 'src/a b.ts')).toBe(`https://github.com/octo/demo/blob/${head}/src/a%20b.ts`)
})
it.each([
['a missing pull request', '', 'GraphQL: Could not resolve to a PullRequest with the number of 7. (repository.pullRequest)',
{ code: 'not-found', message: 'GraphQL: Could not resolve to a PullRequest with the number of 7. (repository.pullRequest)' }],
['a missing file', { message: 'Not Found' }, 'gh: Not Found (HTTP 404)', { code: 'not-found', message: 'Not Found' }],
// What gh 2.100 prints when GitHub refuses a diff over its size limit, as the pull request diff endpoint does past 300 files.
['a diff over GitHub\'s size limit', {
message: 'Sorry, the diff exceeded the maximum number of files (300). Consider using \'List pull requests files\' API or locally '
+ 'cloning the repository instead.',
errors: [{ resource: 'PullRequest', field: 'diff', code: 'too_large' }],
status: '406',
}, 'gh: Sorry, the diff exceeded the maximum number of files (300). Consider using \'List pull requests files\' API or locally '
+ 'cloning the repository instead. (HTTP 406)', {
code: 'too-large',
message: 'Sorry, the diff exceeded the maximum number of files (300). Consider using \'List pull requests files\' API or locally '
+ 'cloning the repository instead.',
}],
['a diff GitHub refuses to render without an error code', { message: 'Not Acceptable' }, 'gh: Not Acceptable (HTTP 406)',
{ code: 'too-large', message: 'Not Acceptable' }],
['a file over the contents API limit', {
message: 'This API returns blobs up to 1 MB in size. The requested blob is too large to fetch via the API.',
errors: [{ resource: 'Blob', field: 'data', code: 'too_large' }],
}, 'gh: This API returns blobs up to 1 MB in size. The requested blob is too large to fetch via the API. (HTTP 403)', {
code: 'too-large', message: 'This API returns blobs up to 1 MB in size. The requested blob is too large to fetch via the API.',
}],
['another refusal', { message: 'Server Error' }, 'gh: Server Error (HTTP 502)', { code: 'unavailable', message: 'Server Error' }],
['a missing sign-in', '', 'To get started with GitHub CLI, please run: gh auth login',
{ code: 'unauthenticated', message: 'To get started with GitHub CLI, please run: gh auth login' }],
['no output', '', '', { code: 'unavailable', message: 'gh exited with 1' }],
])('reports a read refused for %s', async (_label, body, stderr, failure) => {
await expect(provider(pinned(undefined, () => output(body, 1, stderr)).run).diff(pull(7), head, signal)).resolves.toEqual(failure)
})
it('reports a read as unavailable when gh cannot start, rethrows other failures, and refuses an invalid commit', async () => {
await expect(provider(() => Promise.reject(new GhUnavailableError('gh could not start: spawn gh ENOENT'))).diff(pull(7), head, signal))
.resolves.toEqual({ code: 'unavailable', message: 'gh could not start: spawn gh ENOENT' })
await expect(provider(() => Promise.reject(new Error('aborted'))).fileText(pull(7), head, 'a', signal)).rejects.toThrow('aborted')
await expect(provider(fakeGh(() => output('')).run).fileText(pull(7), 'main', 'a', signal)).rejects.toThrow('forge-github: invalid commit "main"')
})
it('reports output over maxOutputBytes as too large and a run past timeoutMs as unavailable', async () => {
const overLimit = pinned(undefined, () => { throw new GhOutputLimitError('gh api output exceeds maxOutputBytes') })
await expect(provider(overLimit.run).diff(pull(7), head, signal))
.resolves.toEqual({ code: 'too-large', message: 'gh api output exceeds maxOutputBytes' })
expect(overLimit.calls).toHaveLength(2)
await expect(provider(() => Promise.reject(new GhTimeoutError('gh api timed out after 30000ms'))).fileText(pull(7), head, 'a', signal))
.resolves.toEqual({ code: 'unavailable', message: 'gh api timed out after 30000ms' })
})
})
describe('GitHub provider commits', () => {
const sha = (n: number): string => String(n).repeat(40).slice(0, 40)
const node = (n: number, author: unknown = { name: 'Octo Cat', user: { login: 'octo' } }) => ({ commit: {
oid: sha(n), messageHeadline: `commit ${n}`, authoredDate: `2026-10-0${n}T00:00:00Z`, author,
parents: { nodes: n === 1 ? [] : [{ oid: sha(n - 1) }] },
} })
const page = (nodes: unknown[], next: string | null, head = sha(3), total = 3) => output({ data: { repository: { pullRequest: {
headRefOid: head, commits: { totalCount: total, pageInfo: { hasNextPage: next !== null, endCursor: next }, nodes },
} } } })
it('lists the commits oldest first across pages pinned to one head, bounded by maxCommits', async () => {
const gh = fakeGh(args => args.includes('cursor=page-1') ? page([node(3, { name: 'Ann', user: null })], null) : page([node(1), node(2, null)], 'page-1'))
await expect(provider(gh.run).commits(pull(7), signal)).resolves.toEqual({
headSha: sha(3),
truncated: false,
commits: [
{ sha: sha(1), parent: null, title: 'commit 1', author: 'octo', authoredAt: '2026-10-01T00:00:00Z' },
{ sha: sha(2), parent: sha(1), title: 'commit 2', author: null, authoredAt: '2026-10-02T00:00:00Z' },
{ sha: sha(3), parent: sha(2), title: 'commit 3', author: 'Ann', authoredAt: '2026-10-03T00:00:00Z' },
],
})
expect(gh.calls[0]).toEqual(expect.arrayContaining(['--hostname', 'github.com', 'owner=octo', 'name=demo', 'number=7', 'first=100']))
expect(gh.calls[0]?.some(arg => arg.startsWith('cursor='))).toBe(false)
expect(gh.calls[1]).toEqual(expect.arrayContaining(['cursor=page-1']))
const bounded = fakeGh(() => page([node(1), node(2)], 'page-1'))
await expect(new GitHubProvider(bounded.run, ['github.com'], { ...limits, maxCommits: 2 }).commits(pull(7), signal))
.resolves.toMatchObject({ truncated: true, commits: [{ sha: sha(1) }, { sha: sha(2) }] })
expect(bounded.calls).toHaveLength(1)
expect(bounded.calls[0]).toEqual(expect.arrayContaining(['first=2']))
})
it('refuses a listing whose head or count moves between pages, or whose pages do not advance', async () => {
const moved = fakeGh(args => args.includes('cursor=page-1') ? page([node(3)], null, sha(4)) : page([node(1)], 'page-1'))
await expect(provider(moved.run).commits(pull(7), signal))
.resolves.toEqual({ code: 'unavailable', message: 'The pull request head changed while its commits were read' })
const recounted = fakeGh(args => args.includes('cursor=page-1') ? page([node(3)], null, sha(3), 4) : page([node(1)], 'page-1'))
await expect(provider(recounted.run).commits(pull(7), signal))
.resolves.toEqual({ code: 'unavailable', message: 'The pull request head changed while its commits were read' })
// A page that repeats the cursor it was read after would be requested again forever.
const stuck = fakeGh(args => page(args.includes('cursor=page-1') ? [] : [node(1)], 'page-1'))
await expect(provider(stuck.run).commits(pull(7), signal))
.resolves.toEqual({ code: 'unavailable', message: 'GitHub commit pagination did not advance' })
expect(stuck.calls).toHaveLength(2)
const endless = fakeGh(() => output({ data: { repository: { pullRequest: {
headRefOid: sha(3), commits: { totalCount: 3, pageInfo: { hasNextPage: true, endCursor: null }, nodes: [node(1)] },
} } } }))
await expect(provider(endless.run).commits(pull(7), signal))
.resolves.toEqual({ code: 'unavailable', message: 'GitHub commit pagination did not advance' })
})
it('refuses a missing pull request and an unexpected response', async () => {
await expect(provider(fakeGh(() => output({ data: { repository: { pullRequest: null } } })).run).commits(pull(7), signal))
.resolves.toEqual({ code: 'not-found', message: 'pull request 7 was not found' })
await expect(provider(fakeGh(() => output({ data: { repository: null } })).run).commits(pull(7), signal))
.resolves.toEqual({ code: 'not-found', message: 'pull request 7 was not found' })
await expect(provider(fakeGh(() => output({ data: { repository: { pullRequest: { headRefOid: 1 } } } })).run).commits(pull(7), signal))
.resolves.toEqual({ code: 'unavailable', message: 'unexpected GitHub response to the commit listing' })
})
it('reports GraphQL errors, a body that is not GraphQL, and a refused request as failures', async () => {
await expect(provider(fakeGh(() => output({ errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible' }] })).run).commits(pull(7), signal))
.resolves.toEqual({ code: 'unavailable', message: 'Resource not accessible' })
await expect(provider(fakeGh(() => output('not json')).run).commits(pull(7), signal))
.resolves.toEqual({ code: 'unavailable', message: 'unexpected GitHub response' })
await expect(provider(fakeGh(() => output('', 1, 'To get started with GitHub CLI, please run: gh auth login')).run).commits(pull(7), signal))
.resolves.toEqual({ code: 'unauthenticated', message: 'To get started with GitHub CLI, please run: gh auth login' })
})
it('reads the changes between two commits through the compare API in its diff form', async () => {
const gh = fakeGh(() => output('diff --git a/x b/x\n'))
await expect(provider(gh.run).compare(pull(7, enterprise), sha(1), sha(3), signal)).resolves.toBe('diff --git a/x b/x\n')
expect(gh.calls).toEqual([[
'api', '--hostname', 'ghe.corp.example', '--allow-escape-sequences', '-H', 'Accept: application/vnd.github.diff',
`repos/team/svc/compare/${sha(1)}...${sha(3)}`,
]])
const refused = 'gh: Sorry, this diff is taking too long to generate. (HTTP 406)'
await expect(provider(fakeGh(() => output({ message: 'Sorry, this diff is taking too long to generate.' }, 1, refused)).run)
.compare(pull(7), sha(1), sha(3), signal)).resolves.toEqual({ code: 'too-large', message: 'Sorry, this diff is taking too long to generate.' })
await expect(provider(gh.run).compare(pull(7), 'main', sha(3), signal)).rejects.toThrow('forge-github: invalid commit "main"')
await expect(provider(gh.run).compare(pull(7), sha(1), 'HEAD~1', signal)).rejects.toThrow('forge-github: invalid commit "HEAD~1"')
})
})
it('rejects readiness, actions, and content reads for an unconfigured host without starting gh', async () => {
const gh = fakeGh(() => output('unexpected'))
const subject = provider(gh.run)
const target = pull(7, { ...demo, host: 'unconfigured.example' })
const message = 'forge-github: host "unconfigured.example" is not configured'
await expect(subject.readiness(target.repository.host, signal)).resolves.toEqual({ state: 'unavailable', message })
await expect(subject.markReady(target, signal)).resolves.toEqual({ code: 'failed', message })
await expect(subject.merge(target, { method: 'squash', headSha: 'a'.repeat(40) }, signal)).resolves.toEqual({ code: 'failed', message })
await expect(subject.diff(target, 'a'.repeat(40), signal)).resolves.toEqual({ code: 'unavailable', message })
await expect(subject.fileText(target, 'a'.repeat(40), 'file.txt', signal)).resolves.toEqual({ code: 'unavailable', message })
await expect(subject.commits(target, signal)).resolves.toEqual({ code: 'unavailable', message })
await expect(subject.compare(target, 'a'.repeat(40), 'b'.repeat(40), signal)).resolves.toEqual({ code: 'unavailable', message })
// A repository that cannot name a request is refused before its host, as every read refuses it.
const unsafe = pull(7, { ...demo, host: 'unconfigured.example', owner: 'oc/to' })
const invalid = 'forge-github: invalid repository "oc/to/demo"'
await expect(subject.diff(unsafe, 'a'.repeat(40), signal)).rejects.toThrow(invalid)
await expect(subject.fileText(unsafe, 'a'.repeat(40), 'file.txt', signal)).rejects.toThrow(invalid)
await expect(subject.commits(unsafe, signal)).rejects.toThrow(invalid)
await expect(subject.compare(unsafe, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow(invalid)
expect(gh.calls).toEqual([])
})