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

126 lines
7.4 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type { GraphqlReader } from '../src/commits.ts'
import type { GraphqlWriter } from '../src/mutation.ts'
import { PULL_REQUEST_NODE_QUERY } from '../src/pull-request-node.ts'
import { MARK_VIEWED_MUTATION, readViewed, UNMARK_VIEWED_MUTATION, VIEWED_QUERY, writeViewed } from '../src/viewed.ts'
import { pull } from './fixtures.ts'
const HEAD = 'f'.repeat(40)
const signal = new AbortController().signal
/** One files page at `head` with the viewer's marks, followed by the page after `next` when it is a cursor. */
function page(files: readonly [string, string][], options: { head?: string; total?: number; next?: string | null } = {}): unknown {
return { data: { repository: { pullRequest: { headRefOid: options.head ?? HEAD, files: {
totalCount: options.total ?? files.length,
pageInfo: { hasNextPage: options.next != null, endCursor: options.next ?? null },
nodes: files.map(([path, viewerViewedState]) => ({ path, viewerViewedState })),
} } } } }
}
/** A reader that answers each request with the next body, recording the requests. */
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()
return body instanceof Error ? { ok: false, failure: { code: 'unavailable', message: body.message } } : { ok: true, body }
},
}
}
describe('viewed file reads', () => {
it('reads the marks across pages at the requested head, mapping unknown states to unviewed', async () => {
const { read, calls } = reader(
page([['src/a.ts', 'VIEWED'], ['src/b.ts', 'DISMISSED']], { total: 3, next: 'files-1' }),
page([['src/c.ts', 'LATER_STATE']], { total: 3 }),
)
await expect(readViewed(read, pull(7), HEAD.toUpperCase(), 3_000, signal)).resolves.toEqual({
headSha: HEAD, files: { 'src/a.ts': 'viewed', 'src/b.ts': 'dismissed', 'src/c.ts': 'unviewed' }, truncated: false,
})
expect(calls[0]).toEqual(['api', 'graphql', '--hostname', 'github.com', '-f', `query=${VIEWED_QUERY}`, '-f', 'owner=octo', '-f', 'name=demo',
'-F', 'number=7', '-F', 'first=100'])
expect(calls[1]?.slice(-2)).toEqual(['-f', 'cursor=files-1'])
})
it('keeps paths that name object properties, such as __proto__ and constructor, with their own states', async () => {
const { read } = reader(page([['__proto__', 'VIEWED'], ['constructor', 'UNVIEWED'], ['toString', 'constructor']], { total: 3 }))
const result = await readViewed(read, pull(7), HEAD, 3_000, signal)
if ('code' in result) throw new Error(result.message)
expect(Object.entries(result.files)).toEqual([['__proto__', 'viewed'], ['constructor', 'unviewed'], ['toString', 'unviewed']])
})
it('stops at the limit and reports the rest as truncated', async () => {
const { read, calls } = reader(page([['src/a.ts', 'VIEWED'], ['src/b.ts', 'UNVIEWED']], { total: 5, next: 'files-1' }))
await expect(readViewed(read, pull(7), HEAD, 2, signal)).resolves.toEqual({
headSha: HEAD, files: { 'src/a.ts': 'viewed', 'src/b.ts': 'unviewed' }, truncated: true,
})
expect(calls[0]?.at(-1)).toBe('first=2')
expect(calls).toHaveLength(1)
})
it.each([
['a head that moved', [page([], { head: 'e'.repeat(40) })], { code: 'stale', message: `the pull request head moved from ${HEAD} to ${'e'.repeat(40)}` }],
['a head that moved on a later page', [page([['a', 'VIEWED']], { next: 'files-1' }), page([], { head: 'e'.repeat(40) })], { code: 'stale' }],
['a refused read', [new Error('rate limited')], { code: 'unavailable', message: 'rate limited' }],
['an unexpected response', [{ data: {} }], { code: 'unavailable', message: 'unexpected GitHub response to the viewed file read' }],
['a missing pull request', [{ data: { repository: { pullRequest: null } } }], { code: 'not-found', message: 'pull request 7 was not found' }],
['a missing repository', [{ data: { repository: null } }], { code: 'not-found' }],
['files GitHub cannot list', [{ data: { repository: { pullRequest: { headRefOid: HEAD, files: null } } } }],
{ code: 'unavailable', message: 'GitHub did not list the pull request files' }],
['a page without a cursor', [page([['a', 'VIEWED']], { next: null, total: 9 }), page([])], undefined],
['a repeated cursor', [page([['a', 'VIEWED']], { next: 'files-1' }), page([['b', 'VIEWED']], { next: 'files-1' })],
{ code: 'unavailable', message: 'GitHub file pagination did not advance' }],
] as const)('refuses %s', async (_label, bodies, failure) => {
const result = await readViewed(reader(...bodies).read, pull(7), HEAD, 3_000, signal)
if (failure === undefined) expect(result).toMatchObject({ truncated: true })
else expect(result).toMatchObject(failure)
})
it('reports a next page without a cursor as pagination that did not advance', async () => {
const body = page([['a', 'VIEWED']], { total: 9 }) as { data: { repository: { pullRequest: { files: { pageInfo: object } } } } }
body.data.repository.pullRequest.files.pageInfo = { hasNextPage: true, endCursor: null }
await expect(readViewed(reader(body).read, pull(7), HEAD, 3_000, signal))
.resolves.toEqual({ code: 'unavailable', message: 'GitHub file pagination did not advance' })
})
it('refuses a head that is not a full commit object name before reading', async () => {
const { read, calls } = reader()
await expect(readViewed(read, pull(7), 'main', 3_000, signal)).rejects.toThrow('forge-github: invalid head commit "main"')
expect(calls).toEqual([])
})
})
describe('viewed file writes', () => {
const node = { data: { repository: { pullRequest: { id: 'PR_7', headRefOid: HEAD } } } }
it.each([
[true, MARK_VIEWED_MUTATION, 'markFileAsViewed'],
[false, UNMARK_VIEWED_MUTATION, 'unmarkFileAsViewed'],
])('confirms the head, then sends the mutation for viewed %s with the node id and path as variables', async (viewed, query, field) => {
const { read, calls } = reader(node)
const write = vi.fn<GraphqlWriter>(async () => undefined)
await expect(writeViewed(read, write, pull(7), { path: 'src/a b.ts', viewed, headSha: HEAD }, signal)).resolves.toBeUndefined()
expect(calls[0]).toContain(`query=${PULL_REQUEST_NODE_QUERY}`)
expect(write).toHaveBeenCalledWith('github.com', { query, field, variables: { id: 'PR_7', path: 'src/a b.ts' } }, signal)
})
it('refuses to write once the head moved, and passes a refused write on', async () => {
const write = vi.fn<GraphqlWriter>(async () => ({ code: 'failed', message: 'Resource not accessible' }))
const moved = { data: { repository: { pullRequest: { id: 'PR_7', headRefOid: 'e'.repeat(40) } } } }
await expect(writeViewed(reader(moved).read, write, pull(7), { path: 'a', viewed: true, headSha: HEAD }, signal))
.resolves.toMatchObject({ code: 'stale' })
expect(write).not.toHaveBeenCalled()
await expect(writeViewed(reader(node).read, write, pull(7), { path: 'a', viewed: true, headSha: HEAD }, signal))
.resolves.toEqual({ code: 'failed', message: 'Resource not accessible' })
})
it('refuses a head that is not a full commit object name before reading', async () => {
const { read, calls } = reader()
await expect(writeViewed(read, vi.fn(), pull(7), { path: 'a', viewed: true, headSha: 'main' }, signal))
.rejects.toThrow('forge-github: invalid head commit "main"')
expect(calls).toEqual([])
})
})