Files
deepseek-harness/packages/experimental/forge-github/tests/threads.spec.ts
T
Yichen Jiang 6e882cd6e3 fix(forge): confine review writes to the bound pull request and reconcile unknown submissions
A write to a thread or comment now reads, in the head check, the pull
request that holds it and is refused without a mutation when that is
another pull request. Submitting the pending review keeps the
submission to reconcile, so an unknown outcome blocks a second review.
GraphQL errors count as refusals only when every type names one, so a
GitHub timeout reports an unknown outcome. Pending reviews are found
by viewerDidAuthor among up to 100, without the unused commit field. A
resolved thread stays open while the reader writes in it, unsupported
writes no longer poll, and the docs state the partial two-step write.
2026-11-09 14:08:22 +08:00

340 lines
19 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { GhOutputLimitError, type GhOutput, type GhRunner } from '../src/gh.ts'
import { GitHubProvider } from '../src/provider.ts'
import type { GitHubLimits } from '../src/provider.ts'
import { REVIEW_QUERY, REVIEW_THREADS_QUERY, THREAD_COMMENTS_QUERY } from '../src/threads.ts'
import { demo, pull } from './fixtures.ts'
const head = 'c'.repeat(40)
const older = 'a'.repeat(40)
const pushed = 'd'.repeat(40)
const signal = new AbortController().signal
function output(stdout: unknown, exitCode = 0, stderr = ''): GhOutput {
return { exitCode, stdout: typeof stdout === 'string' ? stdout : JSON.stringify(stdout), stderr }
}
/** Answer each `gh` call through `handler`, recording the arguments. */
function fakeGh(handler: (args: readonly string[]) => GhOutput): { run: GhRunner; calls: string[][] } {
const calls: string[][] = []
return { calls, run: async (args) => { calls.push([...args]); return handler(args) } }
}
function provider(run: GhRunner, limits: Partial<GitHubLimits> = {}): GitHubProvider {
return new GitHubProvider(run, ['github.com'], { maxOutputBytes: 8_388_608, maxReviewThreads: 1_000, maxCommits: 250, ...limits })
}
function page(nodes: readonly unknown[], next: string | null = null): Record<string, unknown> {
return { pageInfo: { hasNextPage: next !== null, endCursor: next }, nodes }
}
/** One review comment node as the thread query selects it. */
function commentNode(id: string, overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id, state: 'SUBMITTED', createdAt: '2026-11-05T08:00:00Z', url: `https://github.com/octo/demo/pull/7#discussion_${id}`, body: `Body of ${id}`,
viewerCanUpdate: false, viewerCanDelete: false,
author: { __typename: 'User', login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4' },
...overrides,
}
}
/** One review thread node on a new-side line at the head, with one comment. */
function threadNode(id: string, overrides: Record<string, unknown> = {}, root: Record<string, unknown> = {}): Record<string, unknown> {
return {
id, isOutdated: false, isResolved: false, path: 'src/a.ts', diffSide: 'RIGHT', startDiffSide: null,
line: 12, startLine: null, originalLine: 12, originalStartLine: null, subjectType: 'LINE',
viewerCanReply: true, viewerCanResolve: true, viewerCanUnresolve: false,
root: { nodes: [{ diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c', commit: { oid: head }, originalCommit: { oid: head }, ...root }] },
comments: page([commentNode(`${id}-1`)]),
...overrides,
}
}
function firstPage(
threads: Record<string, unknown>, headRefOid = head, viewer: unknown = { login: 'octo' }, pull: Record<string, unknown> = {},
repository: Record<string, unknown> = {},
): Record<string, unknown> {
return { data: { viewer, repository: { isArchived: false, viewerPermission: 'READ', ...repository, pullRequest: {
id: 'pull-7', headRefOid, locked: false, reviews: { nodes: [] }, reviewThreads: threads, ...pull,
} } } }
}
function nextPage(threads: Record<string, unknown>, headRefOid = head): Record<string, unknown> {
return { data: { node: { headRefOid, reviewThreads: threads } } }
}
/** Whether a scripted answer is `gh` output as printed rather than a JSON body. */
function isRaw(answer: Record<string, unknown> | { readonly raw: GhOutput }): answer is { readonly raw: GhOutput } {
return 'raw' in answer
}
/** Answer the first thread page with `first`, later thread pages from `later` by cursor, and comment pages from `comments` by cursor. */
function review(
first: Record<string, unknown> | { readonly raw: GhOutput },
later: Readonly<Record<string, Record<string, unknown>>> = {},
comments: Readonly<Record<string, Record<string, unknown>>> = {},
): ReturnType<typeof fakeGh> {
return fakeGh((args) => {
const cursor = args.find(arg => arg.startsWith('cursor='))?.slice('cursor='.length) ?? ''
if (args.includes(`query=${REVIEW_QUERY}`)) return isRaw(first) ? first.raw : output(first)
if (args.includes(`query=${REVIEW_THREADS_QUERY}`)) return output(later[cursor] ?? { data: { node: null } })
if (args.includes(`query=${THREAD_COMMENTS_QUERY}`)) return output(comments[cursor] ?? { data: { node: null } })
throw new Error(`unexpected gh ${args.join(' ')}`)
})
}
describe('GitHub provider review threads', () => {
it('reads the first page with the head, the viewer, and the pull request as variables, and maps a thread placed at the head', async () => {
const gh = review(firstPage(page([threadNode('t1', { isResolved: true })])))
await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toEqual({
headSha: head,
viewer: 'octo',
truncated: false,
pendingReview: null,
viewerCanReview: true,
threads: [{
id: 't1', path: 'src/a.ts', side: 'R', subject: 'line',
current: { commit: head, startLine: 12, endLine: 12 },
original: { commit: head, startLine: 12, endLine: 12 },
diffHunk: '@@ -10,2 +10,3 @@\n a\n+b\n c',
resolved: true,
viewerCanReply: true,
viewerCanResolve: false,
comments: [{
id: 't1-1', body: 'Body of t1-1', createdAt: '2026-11-05T08:00:00Z', pending: false,
url: 'https://github.com/octo/demo/pull/7#discussion_t1-1',
author: { login: 'reviewer', avatarUrl: 'https://avatars.githubusercontent.com/u/1?s=48&v=4', bot: false },
viewerCanEdit: false, viewerCanDelete: false,
}],
}],
})
expect(gh.calls).toEqual([['api', 'graphql', '--hostname', 'github.com', '-f', `query=${REVIEW_QUERY}`,
'-f', `owner=${demo.owner}`, '-f', `name=${demo.name}`, '-F', 'number=7', '-F', 'first=100']])
})
it('places a thread GitHub moved to the head at its current lines and keeps the lines it was written on', async () => {
const gh = review(firstPage(page([threadNode('moved', { line: 136, originalLine: 135 }, { originalCommit: { oid: older } })])))
const result = await provider(gh.run).readReview(pull(7), head, signal)
expect(result).toMatchObject({ threads: [{
current: { commit: head, startLine: 136, endLine: 136 }, original: { commit: older, startLine: 135, endLine: 135 },
}] })
})
it.each([
['GitHub marks it outdated without a line', { isOutdated: true, line: null }, {}],
['GitHub marks it outdated but keeps a line', { isOutdated: true, line: 303, startLine: 294 }, {}],
['GitHub reports no line', { line: null }, {}],
['its comment is on a commit other than the head', {}, { commit: { oid: older }, originalCommit: { oid: older } }],
['its comment has no commit', {}, { commit: null }],
] as const)('gives a thread no current position when %s', async (_label, thread, root) => {
const gh = review(firstPage(page([threadNode('old', { originalLine: 9, ...thread }, root)])))
const result = await provider(gh.run).readReview(pull(7), head, signal)
expect(result).toMatchObject({ threads: [{ current: null, original: { startLine: 9, endLine: 9 } }] })
})
it('maps a file thread without positions or hunk, an old-side range, and a range that starts on the other side', async () => {
const gh = review(firstPage(page([
threadNode('file', { subjectType: 'FILE', line: null, originalLine: null }, { diffHunk: '' }),
threadNode('left', { diffSide: 'LEFT', startDiffSide: 'LEFT', startLine: 3, line: 5, originalStartLine: 3, originalLine: 5 }),
threadNode('cross', { diffSide: 'RIGHT', startDiffSide: 'LEFT', startLine: 4, line: 6, originalStartLine: 4, originalLine: 6 }),
threadNode('reversed', { startLine: 8, line: 6, originalStartLine: 0, originalLine: 6 }),
])))
const result = await provider(gh.run).readReview(pull(7), head, signal)
expect(result).toMatchObject({ threads: [
{ id: 'file', subject: 'file', side: 'R', current: null, original: null, diffHunk: null },
{ id: 'left', side: 'L', current: { startLine: 3, endLine: 5 }, original: { startLine: 3, endLine: 5 } },
{ id: 'cross', side: 'R', current: { startLine: 6, endLine: 6 }, original: { startLine: 6, endLine: 6 } },
{ id: 'reversed', current: { startLine: 6, endLine: 6 }, original: { startLine: 6, endLine: 6 } },
] })
})
it('maps pending comments, bots, and deleted authors, and drops a thread without comments', async () => {
const gh = review(firstPage(page([
threadNode('t1', { comments: page([
commentNode('c1', { author: { __typename: 'Bot', login: 'ci', avatarUrl: null } }),
commentNode('c2', { state: 'PENDING', author: null, url: null }),
]) }),
threadNode('empty', { comments: page([]) }, { diffHunk: '' }),
])))
const result = await provider(gh.run).readReview(pull(7), head, signal)
expect(result).toMatchObject({ threads: [{ id: 't1', comments: [
{ id: 'c1', author: { login: 'ci', avatarUrl: null, bot: true }, pending: false },
{ id: 'c2', author: null, pending: true, url: null },
] }] })
expect(result).toMatchObject({ threads: { length: 1 } })
})
it('maps the permissions of the viewer on threads and comments, resolving an unresolved thread and unresolving a resolved one', async () => {
const gh = review(firstPage(page([
threadNode('open', { viewerCanReply: false, viewerCanResolve: true, viewerCanUnresolve: false, comments: page([
commentNode('mine', { viewerCanUpdate: true, viewerCanDelete: true }),
]) }),
threadNode('settled', { isResolved: true, viewerCanResolve: false, viewerCanUnresolve: true }),
])))
const result = await provider(gh.run).readReview(pull(7), head, signal)
expect(result).toMatchObject({ threads: [
{ id: 'open', viewerCanReply: false, viewerCanResolve: true, comments: [{ id: 'mine', viewerCanEdit: true, viewerCanDelete: true }] },
{ id: 'settled', viewerCanReply: true, viewerCanResolve: true },
] })
})
it('reports no viewer when GitHub names none', async () => {
const gh = review(firstPage(page([]), head, null))
await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toMatchObject({ viewer: null })
})
it('reports the pending review the viewer authored with its comment count, after another user\'s', async () => {
const reviews = { nodes: [
{ id: 'review-other', viewerDidAuthor: false, comments: { totalCount: 5 } },
{ id: 'review-1', viewerDidAuthor: true, comments: { totalCount: 2 } },
] }
const gh = review(firstPage(page([]), head, { login: 'octo' }, { reviews }))
await expect(provider(gh.run).readReview(pull(7), head, signal))
.resolves.toEqual(expect.objectContaining({ pendingReview: { id: 'review-1', comments: 2 } }))
})
it('reports no pending review for one another user authored', async () => {
const node = { id: 'review-2', viewerDidAuthor: false, comments: { totalCount: 1 } }
const gh = review(firstPage(page([]), head, { login: 'octo' }, { reviews: { nodes: [node] } }))
await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toMatchObject({ pendingReview: null })
})
it.each([
['an archived repository', {}, { isArchived: true, viewerPermission: 'ADMIN' }, false],
['a locked conversation without write access', { locked: true }, { viewerPermission: 'TRIAGE' }, false],
['a locked conversation without a known permission', { locked: true }, { viewerPermission: null }, false],
['a locked conversation with write access', { locked: true }, { viewerPermission: 'WRITE' }, true],
['an open conversation with read access', {}, { viewerPermission: 'READ' }, true],
] as const)('reports whether the viewer can review in %s', async (_label, pullFields, repository, expected) => {
const gh = review(firstPage(page([]), head, { login: 'octo' }, pullFields, repository))
await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toMatchObject({ viewerCanReview: expected })
})
it('reads the following thread and comment pages, asking only for the threads left under maxReviewThreads', async () => {
const gh = review(
firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })], 'threads-1')),
{ 'threads-1': nextPage(page([threadNode('t2')], 'threads-2')) },
{ 'comments-1': { data: { node: { comments: page([commentNode('c2')]) } } } },
)
const result = await provider(gh.run, { maxReviewThreads: 2 }).readReview(pull(7), head, signal)
expect(result).toMatchObject({ truncated: true, threads: [
{ id: 't1', comments: [{ id: 'c1' }, { id: 'c2' }] }, { id: 't2' },
] })
expect(gh.calls.slice(1)).toEqual([
['api', 'graphql', '--hostname', 'github.com', '-f', `query=${THREAD_COMMENTS_QUERY}`, '-f', 'id=t1', '-f', 'cursor=comments-1'],
['api', 'graphql', '--hostname', 'github.com', '-f', `query=${REVIEW_THREADS_QUERY}`, '-f', 'id=pull-7', '-f', 'cursor=threads-1', '-F', 'first=1'],
])
expect(gh.calls[0]).toContain('first=2')
})
it('reports a head other than the requested commit as stale, on the first page or on a later one', async () => {
const moved = review(firstPage(page([threadNode('t1')]), pushed))
await expect(provider(moved.run).readReview(pull(7), head, signal))
.resolves.toEqual({ code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` })
const later = review(firstPage(page([threadNode('t1')], 'threads-1')), { 'threads-1': nextPage(page([threadNode('t2')]), pushed) })
await expect(provider(later.run).readReview(pull(7), head.toUpperCase(), signal))
.resolves.toEqual({ code: 'stale', message: `the pull request head moved from ${head.toUpperCase()} to ${pushed}` })
})
it('keeps the threads read before a page that passes maxOutputBytes and marks the result truncated', async () => {
const first = firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })], 'threads-1'))
const size = JSON.stringify(first).length
const comments = { 'comments-1': { data: { node: { comments: page([commentNode('c2')]) } } } }
const threads = { 'threads-1': nextPage(page([threadNode('t2')])) }
const byComments = review(first, threads, comments)
await expect(provider(byComments.run, { maxOutputBytes: size + 10 }).readReview(pull(7), head, signal))
.resolves.toEqual({ headSha: head, viewer: 'octo', threads: [], truncated: true, pendingReview: null, viewerCanReview: true })
const commentBytes = JSON.stringify(comments['comments-1']).length
const byThreads = review(first, threads, comments)
await expect(provider(byThreads.run, { maxOutputBytes: size + commentBytes + 10 }).readReview(pull(7), head, signal))
.resolves.toMatchObject({ threads: [{ id: 't1' }], truncated: true })
})
it('fails a read whose first page alone passes maxOutputBytes as too-large', async () => {
const run: GhRunner = async () => { throw new GhOutputLimitError('gh output exceeds maxOutputBytes') }
await expect(provider(run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'too-large', message: 'gh output exceeds maxOutputBytes' })
})
it.each([
['a thread page without a cursor', firstPage({ pageInfo: { hasNextPage: true, endCursor: null }, nodes: [] }), {}, {},
'GitHub review thread pagination did not advance'],
['a thread page that repeats its cursor', firstPage(page([], 'threads-1')), { 'threads-1': nextPage(page([], 'threads-1')) }, {},
'GitHub review thread pagination did not advance'],
['a comment page that repeats its cursor', firstPage(page([threadNode('t1', { comments: page([commentNode('c1')], 'comments-1') })])), {},
{ 'comments-1': { data: { node: { comments: page([], 'comments-1') } } } }, 'GitHub review comment pagination did not advance'],
['a comment page without a cursor', firstPage(page([threadNode('t1', { comments: { pageInfo: { hasNextPage: true, endCursor: null }, nodes: [] } })])),
{}, {}, 'GitHub review comment pagination did not advance'],
['an unexpected first page', { data: { repository: { pullRequest: { id: 'pull-7' } } } }, {}, {}, 'unexpected GitHub response to the review thread read'],
['an unexpected thread page', firstPage(page([], 'threads-1')), {}, {}, 'unexpected GitHub response to the review thread read'],
['an unexpected comment page', firstPage(page([threadNode('t1', { comments: page([], 'comments-1') })])), {}, {},
'unexpected GitHub response to the review thread read'],
] as const)('reports %s as unavailable', async (_label, first, later, comments, message) => {
const gh = review(first, later, comments)
await expect(provider(gh.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message })
})
it.each([
['a pull request', { data: { viewer: { login: 'octo' }, repository: { isArchived: false, viewerPermission: 'READ', pullRequest: null } } }],
['a repository', { data: { viewer: { login: 'octo' }, repository: null } }],
])('reports %s GitHub does not have as not-found, as the commit listing does', async (_label, first) => {
await expect(provider(review(first).run).readReview(pull(7), head, signal))
.resolves.toEqual({ code: 'not-found', message: 'pull request 7 was not found' })
})
it('passes on the failure of any page read and of a response that is not JSON', async () => {
const missing = review({ raw: output('', 1, 'gh: Could not resolve to a PullRequest with the number of 7.') })
await expect(provider(missing.run).readReview(pull(7), head, signal))
.resolves.toEqual({ code: 'not-found', message: 'gh: Could not resolve to a PullRequest with the number of 7.' })
const garbled = review({ raw: output('not json') })
await expect(provider(garbled.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message: 'unexpected GitHub response' })
const refused = review({ raw: output({ data: null, errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }] }) })
await expect(provider(refused.run).readReview(pull(7), head, signal))
.resolves.toEqual({ code: 'unavailable', message: 'Resource not accessible by integration' })
const failing = fakeGh(args => args.includes(`query=${REVIEW_QUERY}`)
? output(firstPage(page([threadNode('t1', { comments: page([], 'comments-1') })], 'threads-1')))
: output('', 1, 'gh: Server Error (HTTP 502)'))
await expect(provider(failing.run).readReview(pull(7), head, signal)).resolves.toEqual({ code: 'unavailable', message: 'gh: Server Error (HTTP 502)' })
const failingThreads = fakeGh(args => args.includes(`query=${REVIEW_QUERY}`)
? output(firstPage(page([threadNode('t1')], 'threads-1')))
: output('', 1, 'gh: Server Error (HTTP 502)'))
await expect(provider(failingThreads.run).readReview(pull(7), head, signal))
.resolves.toEqual({ code: 'unavailable', message: 'gh: Server Error (HTTP 502)' })
})
it('refuses an unconfigured host without starting gh and a head that is not a commit object name', async () => {
const gh = review(firstPage(page([])))
const elsewhere = { ...demo, host: 'ghe.other.example' }
await expect(provider(gh.run).readReview(pull(7, elsewhere), head, signal))
.resolves.toEqual({ code: 'unavailable', message: 'forge-github: host "ghe.other.example" is not configured' })
await expect(provider(gh.run).readReview(pull(7), 'main', signal)).rejects.toThrow('forge-github: invalid head commit "main"')
expect(gh.calls).toEqual([])
})
})