Files
deepseek-harness/packages/experimental/forge-github/tests/provider.spec.ts
T
Yichen Jiang ef22fbe7d3 fix(forge): report unconfirmed writes and harden the overview actions
- ForgeActionFailure gains `unknown` for an answer that does not confirm
  the outcome; the GitHub provider passes it on instead of `failed`, the
  panel Remote refuses with `forgePanel/action-unknown`, a viewed mark
  stays, and the other actions ask the reader to check the browser.
- Reruns skip workflow runs with a pending check, try every finished run,
  and report the refused ones together; the card offers a rerun only when
  a failed check's run has finished.
- Review requests resolve users and teams to node ids and call
  `requestReviews`, which GitHub Enterprise Server 3.19 and 3.20 have;
  bots still go through `requestReviewsByLogin`.
- Viewed marks are kept per change request and head, so a reread at the
  same head keeps writes in flight and their locks, and paths named after
  object properties read only their own entries.
- The reviewer search never chooses from the listing of an earlier query.
- Documentation states the unknown code, the branch update evidence, and
  the `canBeRebased` approximation, and drops stray blank lines.
2026-11-09 20:56:46 +08:00

859 lines
54 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 { CANDIDATES_QUERY } from '../src/overview.ts'
import { VIEWED_QUERY } from '../src/viewed.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, maxViewedFiles: 3_000 }
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([])
})
describe('GitHub provider overview and viewed marks', () => {
const HEAD = 'f'.repeat(40)
const unconfigured = pull(1, { ...demo, host: 'gitlab.com' })
const node = { data: { repository: { pullRequest: { id: 'PR_1', headRefOid: HEAD, baseRefOid: 'e'.repeat(40) } } } }
/** A double whose GraphQL reads answer `reads` by query and whose writes take their body from standard input. */
function double(
reads: (query: string) => unknown,
write: (body: { query: string; variables: unknown }) => GhOutput = body => output({ data: { [/\{\s*(\w+)\(input:/.exec(body.query)?.[1] ?? '']: {} } }),
) {
const inputs: unknown[] = []
const calls: string[][] = []
const run: GhRunner = async (args, _signal, input) => {
calls.push([...args])
if (args.includes('--input')) {
const body = JSON.parse(input ?? '') as { query: string; variables: unknown }
inputs.push(body)
return write(body)
}
if (args[1] === 'graphql') return output(reads(args.find(arg => arg.startsWith('query='))?.slice('query='.length) ?? ''))
return output('')
}
return { run, inputs, calls }
}
it('reads viewed marks through the files listing, bounded by maxViewedFiles', async () => {
const gh = double(query => query === VIEWED_QUERY ? { data: { repository: { pullRequest: { headRefOid: HEAD, files: {
totalCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ path: 'a.ts', viewerViewedState: 'VIEWED' }],
} } } } } : {})
const subject = new GitHubProvider(gh.run, ['github.com'], { ...limits, maxViewedFiles: 7 })
await expect(subject.viewedFiles(pull(1), HEAD, signal)).resolves.toEqual({ headSha: HEAD, files: { 'a.ts': 'viewed' }, truncated: false })
expect(gh.calls[0]).toContain('first=7')
})
it('writes a viewed mark as JSON on standard input, never in the arguments', async () => {
const gh = double(() => node)
await expect(provider(gh.run).setViewed(pull(1), { path: 'src/$(x).ts', viewed: true, headSha: HEAD }, signal)).resolves.toBeUndefined()
expect(gh.calls[1]).toEqual(['api', 'graphql', '--hostname', 'github.com', '--input', '-'])
expect(gh.inputs[0]).toMatchObject({ variables: { id: 'PR_1', path: 'src/$(x).ts' } })
expect(gh.calls.flat().some(arg => arg.includes('$(x)'))).toBe(false)
})
it.each([
['a refusing GraphQL error', output({ data: null, errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] }),
{ code: 'failed', message: 'Resource not accessible by integration' }],
['a GraphQL error that leaves the outcome unknown', output({ data: null, errors: [{ message: 'Something went wrong' }] }),
{ code: 'unknown', message: 'Something went wrong' }],
['a refusal without a body', output('', 1, 'gh: Bad credentials (HTTP 401)'), { code: 'failed', message: 'gh: Bad credentials (HTTP 401)' }],
['a body that is not GraphQL', output({ message: 'Not Found' }), { code: 'unknown', message: 'unexpected GitHub response to unmarkFileAsViewed' }],
])('reports a mutation answered with %s as refused or as of unknown outcome', async (_label, answer, failure) => {
const gh = double(() => node, () => answer)
await expect(provider(gh.run).setViewed(pull(1), { path: 'a', viewed: false, headSha: HEAD }, signal)).resolves.toEqual(failure)
})
it('reports a mutation as failed when gh cannot start', async () => {
let reads = 0
const run: GhRunner = async (args) => {
if (args.includes('--input')) throw new GhUnavailableError('gh could not start')
reads++
return output(node)
}
await expect(provider(run).setViewed(pull(1), { path: 'a', viewed: true, headSha: HEAD }, signal))
.resolves.toEqual({ code: 'failed', message: 'gh could not start' })
expect(reads).toBe(1)
})
it('requests reviews, lists candidates, and updates the branch through GraphQL', async () => {
const gh = double(query => query === CANDIDATES_QUERY
? { data: { repository: { assignableUsers: { nodes: [{ login: 'mona', name: null, avatarUrl: null }] }, pullRequest: null } } }
: query.startsWith('query($u0') ? { data: { u0: { id: 'U_mona' } } } : node)
const subject = provider(gh.run)
await expect(subject.requestReviews(pull(1), { reviewers: [{ kind: 'user', login: 'mona' }] }, signal)).resolves.toBeUndefined()
await expect(subject.reviewerCandidates(pull(1), 'mo', signal)).resolves.toEqual([{ login: 'mona', name: null, avatarUrl: null, suggested: false }])
await expect(subject.updateBranch(pull(1), { method: 'rebase', headSha: HEAD }, signal)).resolves.toBeUndefined()
expect(gh.inputs.map(body => (body as { variables: unknown }).variables)).toEqual([
{ input: { pullRequestId: 'PR_1', union: true, userIds: ['U_mona'] } },
{ input: { pullRequestId: 'PR_1', expectedHeadOid: HEAD, updateMethod: 'REBASE' } },
])
})
it('reruns each workflow run with a failed Actions job once, after confirming the head', async () => {
const actions = (name: string, conclusion: string, run: number | null) => ({ ...checkRun(name, 'COMPLETED', conclusion),
checkSuite: { app: { id: 'github-actions' }, workflowRun: { databaseId: run, event: 'pull_request', workflow: { id: name, name } } } })
const contexts = [actions('lint', 'FAILURE', 41), actions('test', 'FAILURE', 41), actions('e2e', 'FAILURE', 42), actions('docs', 'SUCCESS', 43),
actions('other', 'FAILURE', null)]
const gh = fakeGh(args => args[1] === 'graphql' ? output({ data: { r0: { p0: pullRequestNode(1, {}, contexts) } } }) : output(''))
await expect(provider(gh.run).rerunChecks(pull(1), { headSha: HEAD }, signal)).resolves.toBeUndefined()
expect(gh.calls.filter(args => args[0] === 'api' && args[1] !== 'graphql')).toEqual([
['api', '--hostname', 'github.com', '--method', 'POST', 'repos/octo/demo/actions/runs/41/rerun-failed-jobs'],
['api', '--hostname', 'github.com', '--method', 'POST', 'repos/octo/demo/actions/runs/42/rerun-failed-jobs'],
])
})
it('tries every finished run after a refusal, leaves runs still in progress alone, and reports the refusals', async () => {
const check = (name: string, run: number, status: string, conclusion: string | null) => ({ ...checkRun(name, status, conclusion),
checkSuite: { app: { id: 'github-actions' }, workflowRun: { databaseId: run, event: 'pull_request', workflow: { id: `w${run}`, name } } } })
const contexts = [check('lint', 1, 'COMPLETED', 'FAILURE'), check('test', 2, 'COMPLETED', 'FAILURE'), check('build', 3, 'COMPLETED', 'FAILURE'),
check('e2e', 3, 'IN_PROGRESS', null)]
// A conflict names no new commit here, since the head was checked first.
const gh = fakeGh(args => args[1] === 'graphql' ? output({ data: { r0: { p0: pullRequestNode(1, {}, contexts) } } })
: args[5]?.includes('/runs/1/') === true ? output({ message: 'Conflict' }, 1, 'gh: Conflict (HTTP 409)') : output(''))
await expect(provider(gh.run).rerunChecks(pull(1), { headSha: HEAD }, signal))
.resolves.toEqual({ code: 'failed', message: 'GitHub refused to rerun 1 of 2 workflow runs: run 1: Conflict' })
expect(gh.calls.filter(args => args[4] === 'POST').map(args => args[5])).toEqual([
'repos/octo/demo/actions/runs/1/rerun-failed-jobs', 'repos/octo/demo/actions/runs/2/rerun-failed-jobs',
])
})
it('stops at a rerun refused for credentials, and refuses when every run with a failure still runs', async () => {
const check = (name: string, status: string, conclusion: string | null) => ({ ...checkRun(name, status, conclusion),
checkSuite: { app: { id: 'github-actions' }, workflowRun: { databaseId: 5, event: 'pull_request', workflow: { id: 'w', name: 'w' } } } })
const finished = fakeGh(args => args[1] === 'graphql' ? output({ data: { r0: { p0: pullRequestNode(1, {}, [check('lint', 'COMPLETED', 'FAILURE')]) } } })
: output({ message: 'Bad credentials' }, 1, 'gh: Bad credentials (HTTP 401)'))
await expect(provider(finished.run).rerunChecks(pull(1), { headSha: HEAD }, signal)).resolves.toMatchObject({ code: 'unauthenticated' })
const running = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1, {}, [check('lint', 'COMPLETED', 'FAILURE'), check('e2e', 'QUEUED', null)]) } } }))
await expect(provider(running.run).rerunChecks(pull(1), { headSha: HEAD }, signal))
.resolves.toEqual({ code: 'failed', message: 'Every workflow run with a failed job is still running; rerun it once it finishes' })
expect(running.calls.filter(args => args[4] === 'POST')).toEqual([])
})
it.each([
['a moved head', () => output({ data: { r0: { p0: pullRequestNode(1, { headRefOid: 'e'.repeat(40) }) } } }), { code: 'stale' }],
['no failed Actions job', () => output({ data: { r0: { p0: pullRequestNode(1, {}, [checkRun('lint', 'COMPLETED', 'SUCCESS')]) } } }),
{ code: 'failed', message: 'No failed GitHub Actions job of the head commit can be rerun' }],
['an unreadable status', () => output('', 1, 'gh: HTTP 401: Bad credentials'), { code: 'unauthenticated' }],
['a status read that failed', () => output({ data: { r0: { p0: null } } }), { code: 'failed', message: 'pull request not found' }],
])('refuses a rerun after %s', async (_label, answer, failure) => {
await expect(provider(fakeGh(answer).run).rerunChecks(pull(1), { headSha: HEAD }, signal)).resolves.toMatchObject(failure)
})
it('refuses a rerun at a head that is not a full commit object name', async () => {
await expect(provider(fakeGh(() => output('')).run).rerunChecks(pull(1), { headSha: 'main' }, signal))
.rejects.toThrow('forge-github: invalid head commit "main"')
})
it('refuses every overview and viewed operation on an unconfigured host without running gh', async () => {
const gh = fakeGh(() => output(''))
const subject = provider(gh.run)
const failure = { code: 'failed', message: 'forge-github: host "gitlab.com" is not configured' }
await expect(subject.viewedFiles(unconfigured, HEAD, signal)).resolves.toEqual({ ...failure, code: 'unavailable' })
await expect(subject.setViewed(unconfigured, { path: 'a', viewed: true, headSha: HEAD }, signal)).resolves.toEqual(failure)
await expect(subject.rerunChecks(unconfigured, { headSha: HEAD }, signal)).resolves.toEqual(failure)
await expect(subject.requestReviews(unconfigured, { reviewers: [{ kind: 'user', login: 'mona' }] }, signal)).resolves.toEqual(failure)
await expect(subject.reviewerCandidates(unconfigured, '', signal)).resolves.toEqual({ ...failure, code: 'unavailable' })
await expect(subject.updateBranch(unconfigured, { method: 'merge', headSha: HEAD }, signal)).resolves.toEqual(failure)
expect(gh.calls).toEqual([])
})
})