feat(pr): security-hardened review-response run — pr-respond body (U5)

Implements the fix-or-disagree agent loop behind pr-respond: batched
one-run-per-cycle over actionable threads (filtering resolved/outdated/
self/bot), with prompt-injection delimiting of untrusted comment bodies,
viewer-authenticated anti-spoof markers, a pre-push secret scan, fast-
forward-only push (no force-push path) with non-ff abort+re-batch, reply+
resolve, commit-last thread-state persistence with marker+SHA crash
recovery (R15), and an iteration cap on responseRounds (R8). GitHub/git/
agent ops injected; engine stays dashboard-import-free. Adds GraphQL
getPrReviewThreadsDetailed + getViewerLogin to the client. 23 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 20:20:25 -07:00
parent c13f9a123b
commit 31d4b5335b
7 changed files with 1459 additions and 3 deletions

View File

@@ -52,6 +52,24 @@ interface GitHubOperations {
}>;
mergePr(params: { number: number; method?: "merge" | "squash" | "rebase"; expectedHeadOid?: string }): Promise<PrInfo>;
getPrStatus(owner: string, repo: string, number: number): Promise<PrInfo>;
/** Reply to a specific review thread (U2). */
replyToReviewThread(threadId: string, body: string): Promise<void>;
/** Resolve a review thread (U2); caller checks viewerCanResolve first. */
resolveReviewThread(threadId: string): Promise<void>;
/** Authenticated viewer login — anti-spoof marker authentication (U5). */
getViewerLogin(): Promise<string>;
/** Deep-fetch review threads with the U5 fields (resolved/outdated/viewer*). */
getPrReviewThreadsDetailed(
owner: string | undefined,
repo: string | undefined,
number: number,
): Promise<Array<{
id: string;
isResolved: boolean;
isOutdated: boolean;
viewerCanResolve: boolean;
comments: Array<{ author: string; body: string; viewerDidAuthor: boolean }>;
}>>;
updatePr(params: { owner?: string; repo?: string; number: number; title?: string; body?: string }): Promise<PrInfo>;
closePr(params: { number: number }): Promise<PrInfo>;
/** ETag-conditional change probe (U2/U4); 304 ⇒ unchanged, rate-limit-free. */
@@ -356,8 +374,28 @@ function isStaleHeadError(err: unknown): boolean {
* falls back to its inert `disagreed-only` default.
*/
export function createPrNodeGithubOps(
github: Pick<GitHubOperations, "createPr" | "mergePr">,
github: Pick<
GitHubOperations,
| "createPr"
| "mergePr"
| "getPrStatus"
| "replyToReviewThread"
| "resolveReviewThread"
| "getViewerLogin"
| "getPrReviewThreadsDetailed"
>,
options: {
/**
* Resolve the PR-branch worktree path for a task id (the U5 response agent +
* git ops run there). Defaults to the process cwd when not supplied (the
* single-project daemon/serve case).
*/
getTaskWorktree?: (taskId: string) => string | undefined;
} = {},
): PrNodeGithubOps {
const getCwd = (entity: { sourceId: string }): string =>
options.getTaskWorktree?.(entity.sourceId) ?? process.cwd();
return {
resolvePrSource: (task) => {
const repo = getCurrentRepo();
@@ -398,6 +436,30 @@ export function createPrNodeGithubOps(
throw err;
}
},
// U5: the GitHub-client slice of the review-response run. The engine builds
// the git ops + mutating-agent runner from these + its store/settings.
respondOps: {
getReviewThreads: async (entity) => {
if (entity.prNumber == null) return [];
const { owner, name } = splitRepoSlug(entity.repo);
return github.getPrReviewThreadsDetailed(owner, name, entity.prNumber);
},
getViewerLogin: () => github.getViewerLogin(),
checkPrStillOpen: async (entity) => {
if (entity.prNumber == null) return { open: false, headOid: null };
const { owner, name } = splitRepoSlug(entity.repo);
try {
const info = await github.getPrStatus(owner ?? "", name ?? "", entity.prNumber);
return { open: info.status === "open" || info.status === "draft", headOid: null };
} catch {
return { open: false, headOid: null };
}
},
replyToThread: (threadId, body) => github.replyToReviewThread(threadId, body),
resolveThread: (threadId) => github.resolveReviewThread(threadId),
getCwd,
getTaskId: (entity) => entity.sourceId,
},
};
}