Files
deepseek-harness/packages/experimental/forge-github/tests/overview.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

181 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type { GraphqlReader } from '../src/commits.ts'
import type { GraphqlWriter } from '../src/mutation.ts'
import {
CANDIDATES_QUERY, gitHubReviewerCandidates, REQUEST_BOT_REVIEWS_MUTATION, REQUEST_REVIEWS_MUTATION, requestGitHubReviews,
UPDATE_BRANCH_MUTATION, updateGitHubBranch,
} from '../src/overview.ts'
import { pullRequestNode } from '../src/pull-request-node.ts'
import { pull } from './fixtures.ts'
const HEAD = 'f'.repeat(40)
const signal = new AbortController().signal
function reader(...bodies: unknown[]): { read: GraphqlReader; calls: (readonly string[])[] } {
const calls: (readonly string[])[] = []
return {
calls,
read: async (args) => {
calls.push(args)
const body = bodies.shift()
if (body instanceof Error) return { ok: false, failure: { code: body.message === 'signed out' ? 'unauthenticated' : 'unavailable', message: body.message } }
return { ok: true, body }
},
}
}
const node = (headRefOid = HEAD) => ({ data: { repository: { pullRequest: { id: 'PR_7', headRefOid } } } })
describe('pull request node reads', () => {
it.each([
['a head that moved', node('e'.repeat(40)), HEAD, { code: 'stale', message: `the pull request head moved from ${HEAD} to ${'e'.repeat(40)}` }],
['an unexpected response', { data: {} }, HEAD, { code: 'failed', message: 'unexpected GitHub response to the pull request read' }],
['a missing pull request', { data: { repository: { pullRequest: null } } }, HEAD, { code: 'failed', message: 'pull request 7 was not found' }],
['a missing repository', { data: { repository: null } }, null, { code: 'failed', message: 'pull request 7 was not found' }],
['a refused read', new Error('rate limited'), null, { code: 'failed', message: 'rate limited' }],
['a missing sign-in', new Error('signed out'), null, { code: 'unauthenticated', message: 'signed out' }],
])('refuses %s', async (_label, body, head, failure) => {
await expect(pullRequestNode(reader(body).read, pull(7), head, signal)).resolves.toEqual(failure)
})
it('returns the node id at the head, or at any head without one, comparing heads without regard to case', async () => {
await expect(pullRequestNode(reader(node()).read, pull(7), HEAD.toUpperCase(), signal)).resolves.toBe('PR_7')
await expect(pullRequestNode(reader(node('e'.repeat(40))).read, pull(7), null, signal)).resolves.toBe('PR_7')
})
})
describe('review requests', () => {
it('asks users and teams by node id, then bots by login with the bot suffix, adding to the requests GitHub holds', async () => {
const write = vi.fn<GraphqlWriter>(async () => undefined)
const reviewers = [
{ kind: 'user' as const, login: 'mona' }, { kind: 'bot' as const, login: 'copilot' }, { kind: 'bot' as const, login: 'helper[bot]' },
{ kind: 'team' as const, login: 'octo/forge' }, { kind: 'user' as const, login: 'lin' },
]
const ids = { data: { u0: { id: 'U_mona' }, u1: { id: 'U_lin' }, t0: { team: { id: 'T_forge' } } } }
const lookup = reader(node(), ids)
await expect(requestGitHubReviews(lookup.read, write, pull(7), { reviewers }, signal)).resolves.toBeUndefined()
// Names travel as variables, never inside the query text.
expect(lookup.calls[1]).toEqual(['api', 'graphql', '--hostname', 'github.com', '-f',
'query=query($u0: String!, $u1: String!, $o0: String!, $t0: String!) { u0: user(login: $u0) { id } u1: user(login: $u1) { id } '
+ 't0: organization(login: $o0) { team(slug: $t0) { id } } }',
'-f', 'u0=mona', '-f', 'u1=lin', '-f', 'o0=octo', '-f', 't0=forge'])
expect(write.mock.calls.map(call => call[1])).toEqual([
{ query: REQUEST_REVIEWS_MUTATION, field: 'requestReviews',
variables: { input: { pullRequestId: 'PR_7', union: true, userIds: ['U_mona', 'U_lin'], teamIds: ['T_forge'] } } },
{ query: REQUEST_BOT_REVIEWS_MUTATION, field: 'requestReviewsByLogin',
variables: { input: { pullRequestId: 'PR_7', union: true, botLogins: ['copilot[bot]', 'helper[bot]'] } } },
])
})
it('names only the kinds it requests, at any head, and stops at a refusal', async () => {
const write = vi.fn<GraphqlWriter>(async () => ({ code: 'failed', message: 'Could not resolve user' }))
const mixed = [{ kind: 'user' as const, login: 'mona' }, { kind: 'bot' as const, login: 'copilot' }]
await expect(requestGitHubReviews(reader(node('e'.repeat(40)), { data: { u0: { id: 'U_mona' } } }).read, write, pull(7), { reviewers: mixed }, signal))
.resolves.toEqual({ code: 'failed', message: 'Could not resolve user' })
expect(write).toHaveBeenCalledOnce()
expect(write.mock.calls[0]?.[1].variables).toEqual({ input: { pullRequestId: 'PR_7', union: true, userIds: ['U_mona'] } })
await requestGitHubReviews(reader(node(), { data: { t0: { team: { id: 'T_forge' } } } }).read, write, pull(7),
{ reviewers: [{ kind: 'team', login: 'octo/forge' }] }, signal)
expect(write.mock.calls[1]?.[1].variables).toEqual({ input: { pullRequestId: 'PR_7', union: true, teamIds: ['T_forge'] } })
const bots = reader(node())
await requestGitHubReviews(bots.read, write, pull(7), { reviewers: [{ kind: 'bot', login: 'copilot' }] }, signal)
expect(bots.calls).toHaveLength(1)
expect(write.mock.calls[2]?.[1].field).toBe('requestReviewsByLogin')
})
it.each([
['an unknown user', [{ kind: 'user' as const, login: 'ghost' }], { data: { u0: null } }, 'GitHub has no user ghost'],
['a hidden team', [{ kind: 'team' as const, login: 'octo/secret' }], { data: { t0: { team: null } } }, 'GitHub shows the signed-in user no team octo/secret'],
['an unexpected lookup', [{ kind: 'user' as const, login: 'mona' }], { errors: [] }, 'unexpected GitHub response to the reviewer lookup'],
])('refuses %s without writing', async (_label, reviewers, ids, message) => {
const write = vi.fn<GraphqlWriter>()
await expect(requestGitHubReviews(reader(node(), ids).read, write, pull(7), { reviewers }, signal)).resolves.toEqual({ code: 'failed', message })
expect(write).not.toHaveBeenCalled()
})
it.each([
['a refused lookup', new Error('rate limited'), { code: 'failed', message: 'rate limited' }],
['a lookup without sign-in', new Error('signed out'), { code: 'unauthenticated', message: 'signed out' }],
])('passes on %s', async (_label, failure, expected) => {
const write = vi.fn<GraphqlWriter>()
await expect(requestGitHubReviews(reader(node(), failure).read, write, pull(7), { reviewers: [{ kind: 'user', login: 'mona' }] }, signal))
.resolves.toEqual(expected)
})
it.each(['octo', 'octo/', '/forge', 'octo/forge/extra'])('refuses team %s, which is not named organization/slug, without reading', async (login) => {
const write = vi.fn<GraphqlWriter>()
const none = reader()
await expect(requestGitHubReviews(none.read, write, pull(7), { reviewers: [{ kind: 'team', login }] }, signal))
.resolves.toEqual({ code: 'failed', message: `Team ${JSON.stringify(login)} is not named organization/slug` })
expect(none.calls).toEqual([])
})
it('refuses an empty request without reading, and a failed node read without writing', async () => {
const write = vi.fn<GraphqlWriter>()
const empty = reader()
await expect(requestGitHubReviews(empty.read, write, pull(7), { reviewers: [] }, signal)).resolves.toEqual({ code: 'failed', message: 'No reviewer was named' })
expect(empty.calls).toEqual([])
await expect(requestGitHubReviews(reader(new Error('down')).read, write, pull(7), { reviewers: [{ kind: 'user', login: 'mona' }] }, signal))
.resolves.toEqual({ code: 'failed', message: 'down' })
expect(write).not.toHaveBeenCalled()
})
})
describe('branch updates', () => {
it.each([['merge', 'MERGE'], ['rebase', 'REBASE']] as const)('updates by %s with the confirmed head as GitHub expects it', async (method, updateMethod) => {
const write = vi.fn<GraphqlWriter>(async () => undefined)
await expect(updateGitHubBranch(reader(node()).read, write, pull(7), { method, headSha: HEAD }, signal)).resolves.toBeUndefined()
expect(write).toHaveBeenCalledWith('github.com', { query: UPDATE_BRANCH_MUTATION, field: 'updatePullRequestBranch',
variables: { input: { pullRequestId: 'PR_7', expectedHeadOid: HEAD, updateMethod } } }, signal)
})
it('refuses a moved head without writing and an invalid head without reading', async () => {
const write = vi.fn<GraphqlWriter>()
await expect(updateGitHubBranch(reader(node('e'.repeat(40))).read, write, pull(7), { method: 'merge', headSha: HEAD }, signal))
.resolves.toMatchObject({ code: 'stale' })
expect(write).not.toHaveBeenCalled()
const none = reader()
await expect(updateGitHubBranch(none.read, write, pull(7), { method: 'merge', headSha: 'main' }, signal))
.rejects.toThrow('forge-github: invalid head commit "main"')
expect(none.calls).toEqual([])
})
})
describe('reviewer candidates', () => {
const account = (login: string, name: string | null = null) => ({ login, name, avatarUrl: null })
const listing = (suggested: readonly unknown[], users: readonly unknown[], author: unknown = { login: 'Octo' }) => ({ data: { repository: {
assignableUsers: { nodes: users },
pullRequest: { author, suggestedReviewers: suggested.map(reviewer => ({ reviewer })) },
} } })
it('lists the suggestions first without the author, then the accounts, each once', async () => {
const { read, calls } = reader(listing([account('lin', 'Lin'), account('octo')], [account('LIN'), account('mona', 'Mona'), account('octo')]))
await expect(gitHubReviewerCandidates(read, pull(7), ' ', signal)).resolves.toEqual([
{ login: 'lin', name: 'Lin', avatarUrl: null, suggested: true },
{ login: 'mona', name: 'Mona', avatarUrl: null, suggested: false },
])
expect(calls[0]).toEqual(['api', 'graphql', '--hostname', 'github.com', '-f', `query=${CANDIDATES_QUERY}`, '-f', 'owner=octo', '-f', 'name=demo',
'-F', 'number=7', '-f', 'search='])
})
it('leaves suggestions out of a search and keeps at most twenty accounts', async () => {
const users = Array.from({ length: 25 }, (_, index) => account(`user${index}`))
const result = await gitHubReviewerCandidates(reader(listing([account('lin')], users, null)).read, pull(7), 'user', signal)
expect('code' in result ? result : result.map(entry => entry.login)).toEqual(users.slice(0, 20).map(user => user.login))
})
it('lists accounts when the pull request is gone', async () => {
const body = { data: { repository: { assignableUsers: { nodes: [account('mona')] }, pullRequest: null } } }
await expect(gitHubReviewerCandidates(reader(body).read, pull(7), '', signal))
.resolves.toEqual([{ login: 'mona', name: null, avatarUrl: null, suggested: false }])
})
it.each([
['a refused read', new Error('down'), { code: 'unavailable', message: 'down' }],
['an unexpected response', { data: {} }, { code: 'unavailable', message: 'unexpected GitHub response to the reviewer listing' }],
['a hidden repository', { data: { repository: null } }, { code: 'not-found', message: 'repository octo/demo is not visible' }],
])('reports %s', async (_label, body, failure) => {
await expect(gitHubReviewerCandidates(reader(body).read, pull(7), '', signal)).resolves.toEqual(failure)
})
})