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

263 lines
13 KiB
TypeScript

/** Reads the review threads of a GitHub pull request at one head commit through GraphQL. */
import { Buffer } from 'node:buffer'
import type {
ChangeRequestRef, ForgeAnchor, ForgeCommentId, ForgeFailure, ForgeReviewComment, ForgeReviewData, ForgeReviewId, ForgeReviewThread,
ForgeThreadId,
} from '@deepseek-ai/dsh-experimental-forge'
import { z } from 'zod'
import type { GraphqlReader } from './commits.ts'
import { checkedRepository, isCommit } from './urls.ts'
/** Most nodes GitHub returns in one connection page. */
const PAGE_SIZE = 100
/** Repository permissions that may comment on a locked conversation. */
const WRITERS: ReadonlySet<string> = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
const COMMENT_FIELDS = 'id state createdAt url body viewerCanUpdate viewerCanDelete author { __typename login avatarUrl(size: 48) }'
const COMMENT_PAGE = `comments(first: ${PAGE_SIZE}, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { ${COMMENT_FIELDS} } }`
/**
* Thread fields. `root` reads the commits and diff hunk of the first comment once, since replies repeat them; the
* first page of comments starts without a cursor.
*/
const THREAD_FIELDS = `id isOutdated isResolved path diffSide startDiffSide line startLine originalLine originalStartLine subjectType
viewerCanReply viewerCanResolve viewerCanUnresolve
root: comments(first: 1) { nodes { diffHunk commit { oid } originalCommit { oid } } }
${COMMENT_PAGE.replace(', after: $cursor', '')}`
const THREAD_PAGE = `pageInfo { hasNextPage endCursor } nodes { ${THREAD_FIELDS} }`
/**
* First thread page with the head, the viewer, whether the viewer may comment, and the viewer's pending review; variables
* `owner` and `name` are strings, `number` and `first` integers.
*/
export const REVIEW_QUERY = `query($owner: String!, $name: String!, $number: Int!, $first: Int!) {
viewer { login }
repository(owner: $owner, name: $name) {
isArchived viewerPermission
pullRequest(number: $number) {
id headRefOid locked
reviews(states: PENDING, first: 100) { nodes { id viewerDidAuthor comments { totalCount } } }
reviewThreads(first: $first) { ${THREAD_PAGE} }
}
}
}`
/** Following thread page with the head it was read at; variables `id` and `cursor` are strings, `first` an integer. */
export const REVIEW_THREADS_QUERY = `query($id: ID!, $cursor: String!, $first: Int!) {
node(id: $id) { ... on PullRequest { headRefOid reviewThreads(first: $first, after: $cursor) { ${THREAD_PAGE} } } }
}`
/** Following comment page of one thread; variables `id` and `cursor` are strings. */
export const THREAD_COMMENTS_QUERY = `query($id: ID!, $cursor: String!) {
node(id: $id) { ... on PullRequestReviewThread { ${COMMENT_PAGE} } }
}`
/** Bounds of one review read. */
export interface ReviewLimits {
/** Most threads read. */
readonly maxReviewThreads: number
/**
* Most JSON bytes of all pages together: a later page that would pass it ends the read as truncated, and a first
* page whose output alone passes it fails the read as `too-large`.
*/
readonly maxOutputBytes: number
}
const pageInfoSchema = z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullable() })
const commitSchema = z.object({ oid: z.string().refine(isCommit) }).nullable()
const commentSchema = z.object({
id: z.string(),
state: z.string(),
createdAt: z.string(),
url: z.string().nullable(),
body: z.string(),
viewerCanUpdate: z.boolean(),
viewerCanDelete: z.boolean(),
author: z.object({ __typename: z.string(), login: z.string(), avatarUrl: z.string().nullable() }).nullable(),
})
const commentPageSchema = z.object({ pageInfo: pageInfoSchema, nodes: z.array(commentSchema) })
const threadSchema = z.object({
id: z.string(),
isOutdated: z.boolean(),
isResolved: z.boolean(),
path: z.string(),
diffSide: z.string(),
startDiffSide: z.string().nullable(),
line: z.number().int().nullable(),
startLine: z.number().int().nullable(),
originalLine: z.number().int().nullable(),
originalStartLine: z.number().int().nullable(),
subjectType: z.string(),
viewerCanReply: z.boolean(),
viewerCanResolve: z.boolean(),
viewerCanUnresolve: z.boolean(),
root: z.object({ nodes: z.array(z.object({ diffHunk: z.string(), commit: commitSchema, originalCommit: commitSchema })) }),
comments: commentPageSchema,
})
const threadPageSchema = z.object({ pageInfo: pageInfoSchema, nodes: z.array(threadSchema) })
const pendingSchema = z.object({
id: z.string(),
viewerDidAuthor: z.boolean(),
comments: z.object({ totalCount: z.number().int().nonnegative() }),
})
const pullRequestSchema = z.object({
id: z.string(),
headRefOid: z.string(),
locked: z.boolean(),
reviews: z.object({ nodes: z.array(pendingSchema) }),
reviewThreads: threadPageSchema,
})
const firstPageSchema = z.object({ data: z.object({
viewer: z.object({ login: z.string() }).nullable(),
repository: z.object({
isArchived: z.boolean(),
viewerPermission: z.string().nullable(),
pullRequest: pullRequestSchema.nullable(),
}).nullable(),
}) })
const nextPageSchema = z.object({ data: z.object({ node: z.object({ headRefOid: z.string(), reviewThreads: threadPageSchema }) }) })
const commentsPageSchema = z.object({ data: z.object({ node: z.object({ comments: commentPageSchema }) }) })
type ThreadNode = z.infer<typeof threadSchema>
type CommentNode = z.infer<typeof commentSchema>
/**
* Read the review threads of a pull request while its head is `headSha`. The first page also names the head; every
* following thread page names the head again, so a head that moves during the read returns
* `stale`, while a thread's comment pages do not. Threads are read up to `maxReviewThreads`, each with all of its
* comments. A later page that would bring the pages read, measured as JSON, past `maxOutputBytes` is dropped with the
* threads after it, and the result is marked truncated; `read` fails a first page whose output alone passes the limit.
* @param read - runs one GraphQL request.
* @param ref - pull request, with an owner and name already checked for inlining.
* @param headSha - head commit the threads must be read at.
* @param limits - thread and byte bounds.
* @param signal - cancellation of the read.
* @returns the threads, or why GitHub did not provide them at `headSha`.
*/
export async function readReviewThreads(
read: GraphqlReader, ref: ChangeRequestRef, headSha: string, limits: ReviewLimits, signal: AbortSignal,
): Promise<ForgeReviewData | ForgeFailure> {
const { host, owner, name } = checkedRepository(ref)
const graphql = async (variables: readonly string[]): Promise<{ readonly body: unknown; readonly bytes: number } | ForgeFailure> => {
const output = await read(['api', 'graphql', '--hostname', host, ...variables], signal)
if (!output.ok) return output.failure
return { body: output.body, bytes: Buffer.byteLength(JSON.stringify(output.body)) }
}
const unexpected: ForgeFailure = { code: 'unavailable', message: 'unexpected GitHub response to the review thread read' }
const stale = (head: string): ForgeFailure | undefined => head.toLowerCase() === headSha.toLowerCase() ? undefined
: { code: 'stale', message: `the pull request head moved from ${headSha} to ${head}` }
const first = await graphql(['-f', `query=${REVIEW_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`,
'-F', `number=${ref.number}`, '-F', `first=${Math.min(PAGE_SIZE, limits.maxReviewThreads)}`])
if ('code' in first) return first
const parsed = firstPageSchema.safeParse(first.body)
if (!parsed.success) return unexpected
const repository = parsed.data.data.repository
const pull = repository?.pullRequest
if (repository == null || pull == null) return { code: 'not-found', message: `pull request ${ref.number} was not found` }
const moved = stale(pull.headRefOid)
if (moved !== undefined) return moved
let bytes = first.bytes
let page = pull.reviewThreads
const threads: ForgeReviewThread[] = []
const viewer = parsed.data.data.viewer?.login ?? null
// GitHub lists a pending review only to its author; reading up to 100 finds the viewer's even if others were listed first.
const pending = pull.reviews.nodes.find(review => review.viewerDidAuthor)
const pendingReview = pending === undefined ? null : { id: pending.id as ForgeReviewId, comments: pending.comments.totalCount }
// GitHub refuses every write to an archived repository, and comments on a locked conversation from anyone without write access.
const viewerCanReview = !repository.isArchived && (!pull.locked || WRITERS.has(repository.viewerPermission ?? ''))
const result = (truncated: boolean): ForgeReviewData => ({ headSha, viewer, threads, truncated, pendingReview, viewerCanReview })
const cursors = new Set<string>()
for (;;) {
for (const node of page.nodes.slice(0, limits.maxReviewThreads - threads.length)) {
const comments = [...node.comments.nodes]
const commentCursors = new Set<string>()
let more = node.comments.pageInfo
while (more.hasNextPage) {
const cursor = more.endCursor
if (cursor === null || commentCursors.has(cursor)) return { code: 'unavailable', message: 'GitHub review comment pagination did not advance' }
commentCursors.add(cursor)
const next = await graphql(['-f', `query=${THREAD_COMMENTS_QUERY}`, '-f', `id=${node.id}`, '-f', `cursor=${cursor}`])
if ('code' in next) return next
const comment = commentsPageSchema.safeParse(next.body)
if (!comment.success) return unexpected
bytes += next.bytes
if (bytes > limits.maxOutputBytes) return result(true)
comments.push(...comment.data.data.node.comments.nodes)
more = comment.data.data.node.comments.pageInfo
}
if (comments.length > 0) threads.push(toThread(node, comments, headSha))
}
if (threads.length >= limits.maxReviewThreads || !page.pageInfo.hasNextPage) return result(page.pageInfo.hasNextPage)
const cursor = page.pageInfo.endCursor
if (cursor === null || cursors.has(cursor)) return { code: 'unavailable', message: 'GitHub review thread pagination did not advance' }
cursors.add(cursor)
const next = await graphql(['-f', `query=${REVIEW_THREADS_QUERY}`, '-f', `id=${pull.id}`, '-f', `cursor=${cursor}`,
'-F', `first=${Math.min(PAGE_SIZE, limits.maxReviewThreads - threads.length)}`])
if ('code' in next) return next
const following = nextPageSchema.safeParse(next.body)
if (!following.success) return unexpected
const movedLater = stale(following.data.data.node.headRefOid)
if (movedLater !== undefined) return movedLater
bytes += next.bytes
if (bytes > limits.maxOutputBytes) return result(true)
page = following.data.data.node.reviewThreads
}
}
/**
* Map one thread. GitHub keeps a thread's `line` on the commit of its first comment, which stays an older commit for
* a comment written on a commit the head no longer is; such a thread, like one GitHub marks outdated or leaves
* without a line, has no position at `head`. A range whose start is on the other side of the diff keeps its last line.
*/
function toThread(node: ThreadNode, comments: readonly CommentNode[], head: string): ForgeReviewThread {
const root = node.root.nodes[0]
const line = node.subjectType !== 'FILE'
const sameSide = node.startDiffSide === null || node.startDiffSide === node.diffSide
const placed = line && !node.isOutdated && node.line !== null && root?.commit?.oid.toLowerCase() === head.toLowerCase()
return {
id: node.id as ForgeThreadId,
path: node.path,
side: node.diffSide === 'LEFT' ? 'L' : 'R',
subject: line ? 'line' : 'file',
current: placed && node.line !== null ? anchor(head, sameSide ? node.startLine : null, node.line) : null,
original: line && node.originalLine !== null && root?.originalCommit != null
? anchor(root.originalCommit.oid, sameSide ? node.originalStartLine : null, node.originalLine) : null,
diffHunk: root === undefined || root.diffHunk === '' ? null : root.diffHunk,
resolved: node.isResolved,
viewerCanReply: node.viewerCanReply,
viewerCanResolve: node.isResolved ? node.viewerCanUnresolve : node.viewerCanResolve,
comments: comments.map(toComment),
}
}
function anchor(commit: string, start: number | null, end: number): ForgeAnchor {
return { commit, startLine: start !== null && start >= 1 && start <= end ? start : end, endLine: end }
}
function toComment(node: CommentNode): ForgeReviewComment {
return {
id: node.id as ForgeCommentId,
author: node.author === null ? null : { login: node.author.login, avatarUrl: node.author.avatarUrl, bot: node.author.__typename === 'Bot' },
body: node.body,
createdAt: node.createdAt,
pending: node.state === 'PENDING',
url: node.url,
viewerCanEdit: node.viewerCanUpdate,
viewerCanDelete: node.viewerCanDelete,
}
}