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

133 lines
6.5 KiB
TypeScript

/** The signed-in user's viewed marks on pull request files through GraphQL, pinned to one head commit. */
import type {
ChangeRequestRef, ForgeActionFailure, ForgeFailure, ForgeViewedFiles, ForgeViewedRequest, ForgeViewedState,
} from '@deepseek-ai/dsh-experimental-forge'
import { pullRequestNode } from './pull-request-node.ts'
import { z } from 'zod'
import type { GraphqlReader } from './commits.ts'
import type { GraphqlWriter } from './mutation.ts'
import { checkedRepository, isCommit } from './urls.ts'
/** Files GitHub returns per GraphQL page, its connection maximum. */
const PAGE = 100
/** One page of a pull request's files with the viewer's marks and its head; `cursor` continues after the previous page. */
export const VIEWED_QUERY = `query($owner: String!, $name: String!, $number: Int!, $first: Int!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
headRefOid
files(first: $first, after: $cursor) { totalCount pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } }
}
}
}`
/** Mark one file viewed; variables `id` (pull request node id) and `path`, sent on standard input. */
export const MARK_VIEWED_MUTATION = `mutation($id: ID!, $path: String!) {
markFileAsViewed(input: { pullRequestId: $id, path: $path }) { clientMutationId }
}`
/** Clear one file's viewed mark; variables `id` (pull request node id) and `path`, sent on standard input. */
export const UNMARK_VIEWED_MUTATION = `mutation($id: ID!, $path: String!) {
unmarkFileAsViewed(input: { pullRequestId: $id, path: $path }) { clientMutationId }
}`
const STATES: ReadonlyMap<string, ForgeViewedState> = new Map([['VIEWED', 'viewed'], ['UNVIEWED', 'unviewed'], ['DISMISSED', 'dismissed']])
const pageSchema = z.object({
data: z.object({
repository: z.object({
pullRequest: z.object({
headRefOid: z.string(),
files: z.object({
totalCount: z.number().int().nonnegative(),
pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() }),
nodes: z.array(z.object({ path: z.string(), viewerViewedState: z.string() })),
}).nullable(),
}).nullable(),
}).nullable(),
}),
})
/**
* Read the viewer's marks on a pull request's files, at most `limit`, reading pages until the limit or the last file.
* Every page must name `headSha` as the head, so the marks never mix two pushes, and a cursor no earlier page returned,
* so the listing ends. A state GitHub adds later reads as `unviewed`.
* @param read - runs one GraphQL request.
* @param ref - pull request address.
* @param headSha - head commit the marks must be read at.
* @param limit - most files listed.
* @param signal - cancellation of every page.
* @returns the marks at `headSha`, `stale` once the head is another commit, or why GitHub did not list them.
* @throws when `headSha` is not a full commit object name.
*/
export async function readViewed(
read: GraphqlReader,
ref: ChangeRequestRef,
headSha: string,
limit: number,
signal: AbortSignal,
): Promise<ForgeViewedFiles | ForgeFailure> {
const { host, owner, name } = checkedRepository(ref)
if (!isCommit(headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(headSha)}`)
// A map keeps a path such as `__proto__`, which assignment to a plain object drops.
const files = new Map<string, ForgeViewedState>()
let listed = 0
const cursors = new Set<string>()
let cursor: string | null = null
for (;;) {
const body = await read([
'api', 'graphql', '--hostname', host, '-f', `query=${VIEWED_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`,
'-F', `number=${ref.number}`, '-F', `first=${Math.min(PAGE, limit - listed)}`, ...cursor === null ? [] : ['-f', `cursor=${cursor}`],
], signal)
if (!body.ok) return body.failure
const parsed = pageSchema.safeParse(body.body)
if (!parsed.success) return { code: 'unavailable', message: 'unexpected GitHub response to the viewed file read' }
const pull = parsed.data.data.repository?.pullRequest
if (pull == null) return { code: 'not-found', message: `pull request ${ref.number} was not found` }
if (pull.headRefOid.toLowerCase() !== headSha.toLowerCase()) {
return { code: 'stale', message: `the pull request head moved from ${headSha} to ${pull.headRefOid}` }
}
// GitHub answers null for the files of a pull request whose diff it cannot list.
if (pull.files === null) return { code: 'unavailable', message: 'GitHub did not list the pull request files' }
const { totalCount, pageInfo: { hasNextPage, endCursor }, nodes } = pull.files
for (const node of nodes) files.set(node.path, STATES.get(node.viewerViewedState) ?? 'unviewed')
listed += nodes.length
// `fromEntries` defines each path as an own property, `__proto__` included.
if (!hasNextPage || listed >= limit) {
return { headSha: pull.headRefOid, files: Object.fromEntries(files), truncated: totalCount > listed }
}
if (endCursor === null || cursors.has(endCursor)) return { code: 'unavailable', message: 'GitHub file pagination did not advance' }
cursors.add(endCursor)
cursor = endCursor
}
}
/**
* Set or clear the viewer's mark on one file. GitHub's mutations take no head commit, so the pull request head is read
* first and the write is refused once it is another commit; a push between that read and the write leaves the mark on
* the file as the new head has it.
* @param read - runs one GraphQL query.
* @param write - runs one GraphQL mutation.
* @param ref - pull request address.
* @param request - the file, its mark, and the head commit the user saw.
* @param signal - cancellation of both requests.
* @returns undefined once GitHub saved the mark, `stale` when the head moved, or why GitHub refused.
* @throws when `request.headSha` is not a full commit object name.
*/
export async function writeViewed(
read: GraphqlReader,
write: GraphqlWriter,
ref: ChangeRequestRef,
request: ForgeViewedRequest,
signal: AbortSignal,
): Promise<ForgeActionFailure | undefined> {
if (!isCommit(request.headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(request.headSha)}`)
const id = await pullRequestNode(read, ref, request.headSha, signal)
if (typeof id !== 'string') return id
const variables = { id, path: request.path }
return await write(ref.repository.host, request.viewed
? { query: MARK_VIEWED_MUTATION, field: 'markFileAsViewed', variables }
: { query: UNMARK_VIEWED_MUTATION, field: 'unmarkFileAsViewed', variables }, signal)
}