import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import type { AiReviewAgentResult, AiReviewAgentState, AiReviewComment, AiReviewFetcherResult, AiReviewThreadResolution, AiReviewTriagerResult, DeliveryState, ReviewOutcome, ReviewResult, StandaloneAiReviewResult, StandalonePullRequest, TicketState, } from './types'; import { buildPrReviewArtifactStem, buildReviewArtifactPaths, readFetchArtifact, readTriageArtifact, updateTriageArtifact, writeFetchArtifact, writeTriageArtifact, type AiReviewFetchArtifact, type AiReviewTriageArtifact, } from './review-artifacts'; import { type ReviewPollingProfile, DEFAULT_REVIEW_POLLING_PROFILE, RECONCILE_REVIEW_POLLING_PROFILE, computeExtendedReviewPollMaxWaitMinutes, resolveDeliveryReviewPollingProfile, } from './review-polling-profile'; export { type ReviewPollingProfile, DEFAULT_REVIEW_POLLING_PROFILE, RECONCILE_REVIEW_POLLING_PROFILE, computeExtendedReviewPollMaxWaitMinutes, resolveDeliveryReviewPollingProfile, } from './review-polling-profile'; const STANDALONE_TRIAGE_ARTIFACT_DIR = '.agents/triage-standalone'; function formatError(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function parseOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } function parseOptionalNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } function parseOptionalBoolean(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined; } export function buildReviewPollCheckMinutes( intervalMinutes: number, maxWaitMinutes: number, ): number[] { if (intervalMinutes <= 0 || maxWaitMinutes <= 0) { throw new Error('Review polling interval and max wait must be positive.'); } const checks: number[] = []; for ( let minute = intervalMinutes; minute <= maxWaitMinutes; minute += intervalMinutes ) { checks.push(minute); } return checks; } export function resolveReviewPollWindowStart( startedAt: string | undefined, now: () => number = Date.now, ): { pollWindowStartedAt: number; pollWindowStartedAtIso: string } { const parsed = Date.parse(startedAt ?? ''); if (!Number.isNaN(parsed)) { return { pollWindowStartedAt: parsed, pollWindowStartedAtIso: new Date(parsed).toISOString(), }; } const fallback = now(); return { pollWindowStartedAt: fallback, pollWindowStartedAtIso: new Date(fallback).toISOString(), }; } export function parseAiReviewFetcherOutput( output: string, ): AiReviewFetcherResult { let parsed: unknown; try { parsed = JSON.parse(output); } catch (error) { throw new Error( `AI review fetcher must emit JSON. ${formatError(error)}\n${output.trim()}`.trim(), { cause: error, }, ); } if (!isRecord(parsed)) { throw new Error( 'AI review fetcher output must be a JSON object with `agents`, `detected`, `vendors`, and `comments` fields.', ); } if ( !Array.isArray(parsed.agents) || !parsed.agents.every( (entry) => isRecord(entry) && typeof entry.agent === 'string' && (entry.state === 'started' || entry.state === 'completed' || entry.state === 'findings_detected') && (typeof entry.findingsCount === 'undefined' || parseOptionalNumber(entry.findingsCount) !== undefined) && (typeof entry.note === 'undefined' || parseOptionalString(entry.note) !== undefined), ) || typeof parsed.detected !== 'boolean' || !Array.isArray(parsed.vendors) || !parsed.vendors.every((value) => typeof value === 'string') || !Array.isArray(parsed.comments) ) { throw new Error( 'AI review fetcher output must be JSON with `agents`, boolean `detected`, string[] `vendors`, and array `comments` fields.', ); } const comments = parsed.comments.map((entry) => { if (!isRecord(entry)) { throw new Error('AI review fetcher comments must be JSON objects.'); } if ( typeof entry.vendor !== 'string' || typeof entry.channel !== 'string' || typeof entry.author_login !== 'string' || typeof entry.author_type !== 'string' || typeof entry.body !== 'string' || typeof entry.kind !== 'string' ) { throw new Error( 'AI review fetcher comments must include string `vendor`, `channel`, `author_login`, `author_type`, `body`, and `kind` fields.', ); } if ( entry.channel !== 'issue_comment' && entry.channel !== 'review_summary' && entry.channel !== 'inline_review' ) { throw new Error(`Unknown AI review comment channel: ${entry.channel}`); } if ( entry.kind !== 'summary' && entry.kind !== 'finding' && entry.kind !== 'unknown' ) { throw new Error(`Unknown AI review comment kind: ${entry.kind}`); } return { authorLogin: entry.author_login, authorType: entry.author_type, body: entry.body, channel: entry.channel, databaseId: parseOptionalNumber(entry.database_id), isOutdated: parseOptionalBoolean(entry.is_outdated), isResolved: parseOptionalBoolean(entry.is_resolved), kind: entry.kind, line: parseOptionalNumber(entry.line), path: parseOptionalString(entry.path), threadId: parseOptionalString(entry.thread_id), threadViewerCanResolve: parseOptionalBoolean( entry.thread_viewer_can_resolve, ), updatedAt: parseOptionalString(entry.updated_at), url: parseOptionalString(entry.url), vendor: entry.vendor, } satisfies AiReviewComment; }); return { agents: parsed.agents.map((entry) => ({ agent: entry.agent as string, state: entry.state as AiReviewAgentState, findingsCount: parseOptionalNumber(entry.findingsCount), note: parseOptionalString(entry.note), })) satisfies AiReviewAgentResult[], comments, detected: parsed.detected, reviewedHeadSha: parseOptionalString(parsed.reviewed_head_sha), vendors: parsed.vendors, }; } export function parseAiReviewTriagerOutput( output: string, ): AiReviewTriagerResult { let parsed: unknown; try { parsed = JSON.parse(output); } catch (error) { throw new Error( `AI review triager must emit JSON. ${formatError(error)}\n${output.trim()}`.trim(), { cause: error, }, ); } if (!isRecord(parsed)) { throw new Error( 'AI review triager output must be a JSON object with `outcome`, `note`, and `vendors` fields.', ); } if ( (parsed.outcome !== 'clean' && parsed.outcome !== 'needs_patch' && parsed.outcome !== 'patched') || typeof parsed.note !== 'string' || !Array.isArray(parsed.vendors) || !parsed.vendors.every((value) => typeof value === 'string') ) { throw new Error( 'AI review triager output must be JSON with `outcome`, string `note`, and string[] `vendors` fields.', ); } return { actionSummary: parseOptionalString(parsed.action_summary), note: parsed.note, nonActionSummary: parseOptionalString(parsed.non_action_summary), outcome: parsed.outcome, vendors: parsed.vendors, }; } function summarizeReviewMessage(message: string): string { const normalized = message.replace(/\s+/g, ' ').trim(); return normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized; } export function parseResolveReviewThreadOutput(output: string): { message?: string; resolved: boolean; } { let parsed: unknown; try { parsed = JSON.parse(output); } catch (error) { throw new Error( `GitHub review-thread resolution must emit JSON. ${formatError(error)}`.trim(), { cause: error, }, ); } if (!isRecord(parsed)) { throw new Error( 'GitHub review-thread resolution output must be a JSON object.', ); } if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { const firstError = parsed.errors.find(isRecord); return { resolved: false, message: summarizeReviewMessage( typeof firstError?.message === 'string' ? firstError.message : 'GitHub reported a review-thread resolution error.', ), }; } const thread = isRecord(parsed.data) && isRecord(parsed.data.resolveReviewThread) && isRecord(parsed.data.resolveReviewThread.thread) ? parsed.data.resolveReviewThread.thread : undefined; if (thread?.isResolved === true) { return { resolved: true }; } return { resolved: false, message: 'GitHub did not confirm that the review thread was resolved.', }; } const THREAD_REPLY_BEFORE_RESOLVE = 'Addressed during patch phase — see PR body for full finding disposition.'; function sanitizeThreadReplyBody(body: string): string { return body.replace(/\s+/g, ' ').trim(); } type ReviewCoreDependencies = { relativeToRepo: (cwd: string, absolutePath: string) => string; replyToReviewThread?: ( worktreePath: string, databaseId: number, body: string, ) => void; resolveReviewFetcher: () => string; resolveReviewThread: (worktreePath: string, threadId: string) => string; resolveReviewTriager: () => string; runProcess: (cwd: string, cmd: string[]) => string; }; export type TicketReviewDependencies = { ensureBranchPushed?: (cwd: string, branch: string) => void; fetcher?: (worktreePath: string, prNumber: number) => AiReviewFetcherResult; now?: () => number; replyToReviewThread?: ReviewCoreDependencies['replyToReviewThread']; resolveThreads?: ( worktreePath: string, comments: AiReviewComment[], ) => AiReviewThreadResolution[]; sleep?: (milliseconds: number) => Promise; triager?: ( worktreePath: string, artifactJsonPath: string, ) => AiReviewTriagerResult; updatePullRequestBody?: ( state: DeliveryState, ticket: TicketState, ) => void | Promise; } & ReviewCoreDependencies; export type StandaloneAiReviewDependencies = Pick< TicketReviewDependencies, | 'fetcher' | 'now' | 'replyToReviewThread' | 'resolveThreads' | 'sleep' | 'triager' > & ReviewCoreDependencies & { previousOutcome?: ReviewOutcome; pullRequest?: StandalonePullRequest; resolveStandalonePullRequest: ( cwd: string, prNumber?: number, ) => StandalonePullRequest; updatePullRequestBody?: ( cwd: string, pullRequest: StandalonePullRequest, result: StandaloneAiReviewResult, ) => void | Promise; writeNote?: ( cwd: string, prNumber: number, result: StandaloneAiReviewResult, ) => Promise; }; export type PollForAiReviewResult = | { status: 'triage_ready'; result: AiReviewFetcherResult; effectiveMaxWaitMinutes: number; } | { status: 'partial_timeout'; result: AiReviewFetcherResult; incompleteAgents: string[]; effectiveMaxWaitMinutes: number; } | { status: 'clean_timeout'; incompleteAgents?: string[]; effectiveMaxWaitMinutes: number; }; type DetectedReviewProcessingResult = { fetchArtifactPath: string; incompleteAgents?: string[]; note: string; outcome: ReviewResult; reviewedHeadSha?: string; recordedAt: string; triageArtifactPath: string; }; type CleanReviewProcessingResult = { incompleteAgents?: string[]; note: string; outcome: ReviewResult; recordedAt: string; }; function runAiReviewFetcher( worktreePath: string, prNumber: number, dependencies: ReviewCoreDependencies, ): AiReviewFetcherResult { const fetcher = dependencies.resolveReviewFetcher(); const output = dependencies.runProcess(worktreePath, [ fetcher, String(prNumber), ]); return parseAiReviewFetcherOutput(output); } function runAiReviewTriager( worktreePath: string, artifactJsonPath: string, dependencies: ReviewCoreDependencies, ): AiReviewTriagerResult { const triager = dependencies.resolveReviewTriager(); const output = dependencies.runProcess(worktreePath, [ triager, artifactJsonPath, ]); return parseAiReviewTriagerOutput(output); } function isTerminalReviewAgent( agent: Pick, ): boolean { return agent.state === 'completed' || agent.state === 'findings_detected'; } function hasActionableReviewFindings(result: AiReviewFetcherResult): boolean { return result.agents.some((agent) => agent.state === 'findings_detected'); } function hasInFlightReviewAgents(result: AiReviewFetcherResult): boolean { return result.agents.some((agent) => agent.state === 'started'); } function allDetectedAgentsReadyForTriage( result: AiReviewFetcherResult, ): boolean { return ( result.agents.length > 0 && result.agents.every((agent) => isTerminalReviewAgent(agent)) ); } function listIncompleteReviewAgents(result: AiReviewFetcherResult): string[] { return result.agents .filter((agent) => agent.state === 'started') .map((agent) => agent.agent); } function formatReviewAgentList(agents: string[]): string { return agents.join(', '); } function formatPartialAiReviewTimeoutNote( maxWaitMinutes: number, agents: string[], ): string { return `AI review reached the ${maxWaitMinutes}-minute limit while waiting on: ${formatReviewAgentList(agents)}. Triage the captured findings and rerun manually if needed.`; } function formatIncompleteAiReviewWithoutFindingsNote( maxWaitMinutes: number, agents: string[], ): string { return `AI review reached the ${maxWaitMinutes}-minute limit while waiting on: ${formatReviewAgentList(agents)}. No actionable findings were captured. Rerun manually if needed.`; } function formatNoAiReviewFeedbackNote(maxWaitMinutes: number): string { return `No AI review feedback was detected within the ${maxWaitMinutes}-minute polling window.`; } function mergeReviewOutcome( previous: ReviewResult | undefined, next: ReviewResult | undefined, ): ReviewOutcome | undefined { if (previous === 'patched' || next === 'patched') { return 'patched'; } if (previous === 'clean' || next === 'clean') { return 'clean'; } return undefined; } function accumulateReviewOutcome( previous: ReviewResult | undefined, next: ReviewResult, ): ReviewResult | undefined { if (next === 'clean' || next === 'patched') { return mergeReviewOutcome(previous, next) ?? next; } return next; } function accumulateTicketReviewOutcome( previous: ReviewResult | undefined, next: ReviewResult, ): ReviewResult | undefined { return accumulateReviewOutcome(previous, next); } function mapStandaloneReviewOutcome(outcome: ReviewResult): ReviewResult { return outcome === 'needs_patch' ? 'operator_input_needed' : outcome; } function formatCumulativePatchedReviewNote(note: string | undefined): string { if (note === undefined || note.length === 0) { return 'Earlier review cycles led to prudent follow-up patches; the latest review pass found no additional prudent changes.'; } if (note.includes('no additional prudent follow-up changes')) { return note; } return `${note} Earlier review cycles led to prudent follow-up patches, and the latest review pass found no additional prudent follow-up changes.`; } function formatAccumulatedReviewNote( previous: ReviewResult | undefined, next: ReviewResult, note: string | undefined, ): string | undefined { if (previous === 'patched' && next === 'clean') { return formatCumulativePatchedReviewNote(note); } return note; } function defaultFinalReviewNote( outcome: ReviewResult, note: string | undefined, previousNote: string | undefined, ): string | undefined { if (note !== undefined) { return note; } if (outcome === 'clean') { return 'External AI review completed without prudent follow-up changes.'; } return previousNote; } function formatNoFeedbackReviewNote( previous: ReviewResult | undefined, maxWaitMinutes: number, ): string { return ( formatAccumulatedReviewNote( previous, 'clean', formatNoAiReviewFeedbackNote(maxWaitMinutes), ) ?? formatNoAiReviewFeedbackNote(maxWaitMinutes) ); } async function readStandaloneAiReviewOutcome( cwd: string, prNumber: number, ): Promise { const triageArtifactPath = resolve( cwd, STANDALONE_TRIAGE_ARTIFACT_DIR, `pr-${prNumber}`, 'review.triage.json', ); if (!existsSync(triageArtifactPath)) { return undefined; } const triageArtifact = readTriageArtifact(triageArtifactPath); if ( triageArtifact?.outcome === 'clean' || triageArtifact?.outcome === 'patched' ) { return triageArtifact.outcome; } return undefined; } function shouldResolveDetectedReviewThreads( mode: 'standalone' | 'ticketed', outcome: ReviewResult, ): boolean { if (mode === 'ticketed') { return outcome === 'patched'; } return outcome === 'patched'; } function defaultSleep(milliseconds: number): Promise { return new Promise((resolveSleep) => { setTimeout(resolveSleep, milliseconds); }); } export function resolveNativeReviewThreads( worktreePath: string, comments: AiReviewComment[], dependencies: ReviewCoreDependencies, ): AiReviewThreadResolution[] { const resolutions: AiReviewThreadResolution[] = []; const seen = new Set(); for (const comment of comments) { if ( comment.channel !== 'inline_review' || (comment.kind !== 'finding' && comment.isOutdated !== true) || comment.isResolved === true || !comment.threadId || seen.has(comment.threadId) ) { continue; } seen.add(comment.threadId); if (comment.threadViewerCanResolve === false) { resolutions.push({ status: 'unresolvable', threadId: comment.threadId, url: comment.url, vendor: comment.vendor, message: 'GitHub did not expose this review thread as resolvable.', }); continue; } if (dependencies.replyToReviewThread && comment.databaseId !== undefined) { try { dependencies.replyToReviewThread( worktreePath, comment.databaseId, sanitizeThreadReplyBody(THREAD_REPLY_BEFORE_RESOLVE), ); } catch { // Reply is best-effort; resolution still proceeds. } } try { const response = dependencies.resolveReviewThread( worktreePath, comment.threadId, ); const parsed = parseResolveReviewThreadOutput(response); if (!parsed.resolved) { resolutions.push({ status: 'failed', threadId: comment.threadId, url: comment.url, vendor: comment.vendor, message: parsed.message, }); continue; } resolutions.push({ status: 'resolved', threadId: comment.threadId, url: comment.url, vendor: comment.vendor, }); } catch (error) { const message = summarizeReviewMessage(formatError(error)); resolutions.push({ status: message.includes('already resolved') ? 'already_resolved' : 'failed', threadId: comment.threadId, url: comment.url, vendor: comment.vendor, message, }); } } return resolutions; } export async function pollForAiReview( worktreePath: string, prNumber: number, profile: ReviewPollingProfile, pollWindowStartedAt: number, dependencies: Pick & ReviewCoreDependencies, ): Promise { const now = dependencies.now ?? Date.now; const sleepFn = dependencies.sleep ?? defaultSleep; const fetcher = dependencies.fetcher ?? ((nextWorktreePath: string, nextPrNumber: number) => runAiReviewFetcher(nextWorktreePath, nextPrNumber, dependencies)); const intervalMinutes = profile.intervalMinutes; const maxWaitMinutes = profile.maxWaitMinutes; const checks = buildReviewPollCheckMinutes(intervalMinutes, maxWaitMinutes); const extendedMaxWaitMinutes = computeExtendedReviewPollMaxWaitMinutes( intervalMinutes, maxWaitMinutes, ); let extended = false; for (let index = 0; index < checks.length; index += 1) { const checkMinute = checks[index]!; const dueAt = pollWindowStartedAt + checkMinute * 60_000; const remaining = dueAt - now(); if (remaining > 0) { await sleepFn(remaining); } const result = fetcher(worktreePath, prNumber); if (!result.detected) { continue; } if (allDetectedAgentsReadyForTriage(result)) { return { status: 'triage_ready', result, effectiveMaxWaitMinutes: extended ? extendedMaxWaitMinutes : maxWaitMinutes, }; } if ( profile.extendByOneInterval && !extended && checkMinute === maxWaitMinutes && hasInFlightReviewAgents(result) ) { checks.push(extendedMaxWaitMinutes); extended = true; continue; } // At the final wait boundary (extended when extendByOneInterval is true, // or maxWaitMinutes when not extending), surface partial/clean timeout. const isAtFinalBoundary = checkMinute === extendedMaxWaitMinutes || (!profile.extendByOneInterval && checkMinute === maxWaitMinutes); if (isAtFinalBoundary && hasInFlightReviewAgents(result)) { const incompleteAgents = listIncompleteReviewAgents(result); const effectiveMax = profile.extendByOneInterval ? extendedMaxWaitMinutes : maxWaitMinutes; if (hasActionableReviewFindings(result)) { return { status: 'partial_timeout', result, incompleteAgents, effectiveMaxWaitMinutes: effectiveMax, }; } return { status: 'clean_timeout', incompleteAgents, effectiveMaxWaitMinutes: effectiveMax, }; } } return { status: 'clean_timeout', effectiveMaxWaitMinutes: maxWaitMinutes, }; } async function writeAiReviewArtifacts( artifactStemPath: string, result: AiReviewFetcherResult, fetchedAt: string, ): Promise<{ fetchArtifactPath: string; triageArtifactPath: string }> { const { fetchArtifactPath, triageArtifactPath } = buildReviewArtifactPaths(artifactStemPath); const fetchArtifact: AiReviewFetchArtifact = { schemaVersion: 1, fetchedAt, reviewedHeadSha: result.reviewedHeadSha, detected: result.detected, vendors: result.vendors, agents: result.agents, comments: result.comments, }; await writeFetchArtifact(fetchArtifactPath, fetchArtifact); return { fetchArtifactPath, triageArtifactPath, }; } async function processDetectedAiReview(options: { artifactStemPath: string; detectedReview: AiReviewFetcherResult; effectiveMaxWaitMinutes: number; incompleteAgents?: string[]; mapOutcome?: (outcome: ReviewResult) => ReviewResult; mode: 'standalone' | 'ticketed'; previousOutcome: ReviewResult | undefined; resolveThreads: ( worktreePath: string, comments: AiReviewComment[], ) => AiReviewThreadResolution[]; triager: ( worktreePath: string, artifactJsonPath: string, ) => AiReviewTriagerResult; worktreePath: string; }): Promise { const fetchedAt = new Date().toISOString(); const artifacts = await writeAiReviewArtifacts( options.artifactStemPath, options.detectedReview, fetchedAt, ); const triageResult = options.triager( options.worktreePath, artifacts.fetchArtifactPath, ); const latestOutcome = options.mapOutcome?.(triageResult.outcome) ?? triageResult.outcome; const threadResolutions = shouldResolveDetectedReviewThreads( options.mode, latestOutcome, ) ? options.resolveThreads( options.worktreePath, options.detectedReview.comments, ) : []; const recordedAt = new Date().toISOString(); const triageArtifact: AiReviewTriageArtifact = { schemaVersion: 1, recordedAt, reviewedHeadSha: options.detectedReview.reviewedHeadSha, outcome: accumulateReviewOutcome(options.previousOutcome, latestOutcome) ?? latestOutcome, note: options.incompleteAgents?.length ? formatPartialAiReviewTimeoutNote( options.effectiveMaxWaitMinutes, options.incompleteAgents, ) : (formatAccumulatedReviewNote( options.previousOutcome, latestOutcome, triageResult.note, ) ?? triageResult.note), actionSummary: triageResult.actionSummary, nonActionSummary: triageResult.nonActionSummary, incompleteAgents: options.incompleteAgents, threadResolutions: threadResolutions.length > 0 ? threadResolutions : undefined, }; await writeTriageArtifact(artifacts.triageArtifactPath, triageArtifact); return { fetchArtifactPath: artifacts.fetchArtifactPath, incompleteAgents: options.incompleteAgents, note: triageArtifact.note, outcome: triageArtifact.outcome, reviewedHeadSha: options.detectedReview.reviewedHeadSha, recordedAt, triageArtifactPath: artifacts.triageArtifactPath, }; } function processCleanAiReview(options: { effectiveMaxWaitMinutes: number; incompleteAgents?: string[]; maxWaitMinutes: number; previousOutcome: ReviewResult | undefined; }): CleanReviewProcessingResult { const recordedAt = new Date().toISOString(); return { incompleteAgents: options.incompleteAgents, note: options.incompleteAgents?.length ? formatIncompleteAiReviewWithoutFindingsNote( options.effectiveMaxWaitMinutes, options.incompleteAgents, ) : formatNoFeedbackReviewNote( options.previousOutcome, options.maxWaitMinutes, ), outcome: accumulateReviewOutcome(options.previousOutcome, 'clean') ?? 'clean', recordedAt, }; } async function writeCleanTriageArtifact( artifactStemPath: string, input: { incompleteAgents?: string[]; note: string; outcome: ReviewResult; recordedAt: string; reviewedHeadSha?: string; }, ): Promise { const { triageArtifactPath } = buildReviewArtifactPaths(artifactStemPath); await writeTriageArtifact(triageArtifactPath, { schemaVersion: 1, recordedAt: input.recordedAt, reviewedHeadSha: input.reviewedHeadSha, outcome: input.outcome, note: input.note, incompleteAgents: input.incompleteAgents, }); return triageArtifactPath; } async function commitPrReviewArtifactsAfterRecordReview( repoRoot: string, ticketId: string, absolutePaths: string[], relativeToRepo: (cwd: string, absolutePath: string) => string, runProcess: (cwd: string, cmd: string[]) => string, ): Promise { try { runProcess(repoRoot, ['git', 'rev-parse', '--git-dir']); } catch { return false; } const relPaths: string[] = []; for (const abs of absolutePaths) { if (!existsSync(abs)) { continue; } const rel = relativeToRepo(repoRoot, abs); if (rel.length > 0) { relPaths.push(rel); } } const unique = [...new Set(relPaths)]; if (unique.length === 0) { return false; } for (const rel of unique) { try { runProcess(repoRoot, ['git', 'add', '--', rel]); } catch (error) { console.warn( `Could not stage review artifact ${rel} for commit: ${formatError(error)}`, ); } } const stagedNames = (() => { try { return runProcess(repoRoot, ['git', 'diff', '--cached', '--name-only']); } catch { return ''; } })(); if (!stagedNames.trim()) { return false; } try { runProcess(repoRoot, [ 'git', 'commit', '-m', `chore(delivery): commit PR review artifacts after record-review ${ticketId}`, ]); return true; } catch (error) { console.warn( `Could not commit staged review artifacts for ${ticketId}: ${formatError(error)}`, ); return false; } } async function applyTicketReviewUpdate( cwd: string, state: DeliveryState, ticketId: string, updateTicket: (ticket: TicketState) => TicketState, dependencies: Pick< TicketReviewDependencies, | 'updatePullRequestBody' | 'relativeToRepo' | 'runProcess' | 'ensureBranchPushed' >, ): Promise { const updatedTickets = state.tickets.map((ticket) => ticket.id === ticketId ? updateTicket(ticket) : ticket, ); const updatedIndex = updatedTickets.findIndex( (ticket) => ticket.id === ticketId, ); if (updatedIndex === -1) { throw new Error(`Unknown ticket ${ticketId}.`); } const updatedTarget = updatedTickets[updatedIndex]!; const isLastTicket = updatedIndex === updatedTickets.length - 1; const finalizedTickets: TicketState[] = isLastTicket && updatedTarget.status === 'reviewed' && (updatedTarget.reviewOutcome === 'clean' || updatedTarget.reviewOutcome === 'patched') ? updatedTickets.map((ticket, index) => index === updatedIndex ? { ...ticket, status: 'done' } : ticket, ) : updatedTickets; const nextState: DeliveryState = { ...state, tickets: finalizedTickets, }; const persistedTarget = nextState.tickets[updatedIndex]!; const triageArtifactPath = persistedTarget.reviewTriageArtifactPath ? resolve(cwd, persistedTarget.reviewTriageArtifactPath) : undefined; try { await dependencies.updatePullRequestBody?.(nextState, persistedTarget); if (triageArtifactPath) { await updateTriageArtifact(triageArtifactPath, (current) => ({ ...current, schemaVersion: 1, recordedAt: current?.recordedAt ?? persistedTarget.reviewRecordedAt ?? new Date().toISOString(), outcome: current?.outcome ?? persistedTarget.reviewOutcome ?? 'clean', note: current?.note ?? 'External AI review completed without prudent follow-up changes.', reviewedHeadSha: current?.reviewedHeadSha ?? persistedTarget.reviewHeadSha, prBodyRefresh: { attemptedAt: new Date().toISOString(), status: 'updated', }, })); } } catch (error) { console.warn( `Review was recorded locally for ${persistedTarget.id}, but PR body update failed: ${formatError(error)}`, ); if (triageArtifactPath) { await updateTriageArtifact(triageArtifactPath, (current) => ({ ...current, schemaVersion: 1, recordedAt: current?.recordedAt ?? persistedTarget.reviewRecordedAt ?? new Date().toISOString(), outcome: current?.outcome ?? persistedTarget.reviewOutcome ?? 'clean', note: current?.note ?? 'External AI review completed without prudent follow-up changes.', reviewedHeadSha: current?.reviewedHeadSha ?? persistedTarget.reviewHeadSha, prBodyRefresh: { attemptedAt: new Date().toISOString(), status: 'failed', message: formatError(error), }, })); } } const fetchPath = persistedTarget.reviewFetchArtifactPath ? resolve(cwd, persistedTarget.reviewFetchArtifactPath) : undefined; const triagePath = persistedTarget.reviewTriageArtifactPath ? resolve(cwd, persistedTarget.reviewTriageArtifactPath) : undefined; const artifactPaths = [triagePath, fetchPath].filter( (p): p is string => p !== undefined, ); if (artifactPaths.length > 0) { const committed = await commitPrReviewArtifactsAfterRecordReview( cwd, ticketId, artifactPaths, dependencies.relativeToRepo, dependencies.runProcess, ); if (committed) { dependencies.ensureBranchPushed?.( persistedTarget.worktreePath, persistedTarget.branch, ); } } return nextState; } async function persistStandaloneAiReviewResult( cwd: string, pullRequest: StandalonePullRequest, result: StandaloneAiReviewResult, dependencies: Pick< StandaloneAiReviewDependencies, 'updatePullRequestBody' | 'writeNote' >, ): Promise { const writeNote = dependencies.writeNote ?? writeStandaloneAiReviewNote; await writeNote(cwd, pullRequest.number, result); const triageArtifactPath = result.triageArtifactPath ? resolve(cwd, result.triageArtifactPath) : undefined; try { await dependencies.updatePullRequestBody?.(cwd, pullRequest, result); if (triageArtifactPath) { await updateTriageArtifact(triageArtifactPath, (current) => ({ ...current, schemaVersion: 1, recordedAt: current?.recordedAt ?? result.recordedAt ?? new Date().toISOString(), outcome: current?.outcome ?? result.outcome, note: current?.note ?? result.note, reviewedHeadSha: current?.reviewedHeadSha ?? result.reviewedHeadSha, prBodyRefresh: { attemptedAt: new Date().toISOString(), status: 'updated', }, })); } } catch (error) { console.warn( `Standalone AI review was recorded locally for PR #${pullRequest.number}, but PR body update failed: ${formatError(error)}`, ); if (triageArtifactPath) { await updateTriageArtifact(triageArtifactPath, (current) => ({ ...current, schemaVersion: 1, recordedAt: current?.recordedAt ?? result.recordedAt ?? new Date().toISOString(), outcome: current?.outcome ?? result.outcome, note: current?.note ?? result.note, reviewedHeadSha: current?.reviewedHeadSha ?? result.reviewedHeadSha, prBodyRefresh: { attemptedAt: new Date().toISOString(), status: 'failed', message: formatError(error), }, })); } } return result; } export type AiReviewLifecycleHooks = { onTriageOrPartial: ( reviewPollResult: Extract< PollForAiReviewResult, { status: 'triage_ready' | 'partial_timeout' } >, ) => Promise; onCleanTimeout: (reviewPollResult: PollForAiReviewResult) => Promise; }; export async function runAiReviewLifecycleWithAdapters(input: { profile: ReviewPollingProfile; worktreePath: string; prNumber: number; pollWindowStartedAt: number; dependencies: Pick & ReviewCoreDependencies; onTriageOrPartial: AiReviewLifecycleHooks['onTriageOrPartial']; onCleanTimeout: AiReviewLifecycleHooks['onCleanTimeout']; }): Promise { const reviewPollResult = await pollForAiReview( input.worktreePath, input.prNumber, input.profile, input.pollWindowStartedAt, input.dependencies, ); if ( reviewPollResult.status === 'triage_ready' || reviewPollResult.status === 'partial_timeout' ) { return input.onTriageOrPartial(reviewPollResult); } return input.onCleanTimeout(reviewPollResult); } export async function runTicketReviewLifecycle( state: DeliveryState, cwd: string, ticketId: string | undefined, dependencies: TicketReviewDependencies, ): Promise { const target = state.tickets.find((ticket) => ticketId ? ticket.id === ticketId : ticket.status === 'in_review', ); if (!target || target.status !== 'in_review' || !target.prNumber) { throw new Error('No in-review ticket with an open PR was found.'); } const now = dependencies.now ?? Date.now; const triager = dependencies.triager ?? ((worktreePath: string, artifactJsonPath: string) => runAiReviewTriager(worktreePath, artifactJsonPath, dependencies)); const resolveThreads = dependencies.resolveThreads ?? ((worktreePath: string, comments: AiReviewComment[]) => resolveNativeReviewThreads(worktreePath, comments, dependencies)); const { pollWindowStartedAt, pollWindowStartedAtIso } = resolveReviewPollWindowStart(target.prOpenedAt, now); const profile = resolveDeliveryReviewPollingProfile(state); return runAiReviewLifecycleWithAdapters({ profile, worktreePath: target.worktreePath, prNumber: target.prNumber, pollWindowStartedAt, dependencies, async onTriageOrPartial(reviewPollResult) { const processedReview = await processDetectedAiReview({ artifactStemPath: resolve( cwd, state.reviewsDirPath, buildPrReviewArtifactStem(target.id), ), detectedReview: reviewPollResult.result, effectiveMaxWaitMinutes: reviewPollResult.effectiveMaxWaitMinutes, incompleteAgents: reviewPollResult.status === 'partial_timeout' ? reviewPollResult.incompleteAgents : undefined, mode: 'ticketed', previousOutcome: target.reviewOutcome, resolveThreads, triager, worktreePath: target.worktreePath, }); const nextStatus = processedReview.outcome === 'needs_patch' ? 'needs_patch' : 'reviewed'; return applyTicketReviewUpdate( cwd, state, target.id, (ticket) => ({ ...ticket, prOpenedAt: ticket.prOpenedAt ?? pollWindowStartedAtIso, status: nextStatus, reviewFetchArtifactPath: dependencies.relativeToRepo( cwd, processedReview.fetchArtifactPath, ), reviewTriageArtifactPath: dependencies.relativeToRepo( cwd, processedReview.triageArtifactPath, ), reviewHeadSha: processedReview.reviewedHeadSha, reviewRecordedAt: processedReview.recordedAt, reviewOutcome: accumulateTicketReviewOutcome( ticket.reviewOutcome, processedReview.outcome, ), }), dependencies, ); }, async onCleanTimeout(reviewPollResult) { const processedReview = processCleanAiReview({ effectiveMaxWaitMinutes: reviewPollResult.effectiveMaxWaitMinutes, incompleteAgents: reviewPollResult.status === 'clean_timeout' ? reviewPollResult.incompleteAgents : undefined, maxWaitMinutes: profile.maxWaitMinutes, previousOutcome: target.reviewOutcome, }); const triageArtifactPath = await writeCleanTriageArtifact( resolve( cwd, state.reviewsDirPath, buildPrReviewArtifactStem(target.id), ), { incompleteAgents: processedReview.incompleteAgents, note: processedReview.note, outcome: processedReview.outcome, recordedAt: processedReview.recordedAt, reviewedHeadSha: target.reviewHeadSha, }, ); return applyTicketReviewUpdate( cwd, state, target.id, (ticket) => ({ ...ticket, prOpenedAt: ticket.prOpenedAt ?? pollWindowStartedAtIso, status: 'reviewed', reviewFetchArtifactPath: ticket.reviewFetchArtifactPath, reviewTriageArtifactPath: dependencies.relativeToRepo( cwd, triageArtifactPath, ), reviewHeadSha: ticket.reviewHeadSha, reviewRecordedAt: processedReview.recordedAt, reviewOutcome: accumulateTicketReviewOutcome( ticket.reviewOutcome, processedReview.outcome, ), }), dependencies, ); }, }); } export async function runReconcileLateTicketReview( state: DeliveryState, cwd: string, ticketId: string, dependencies: TicketReviewDependencies, ): Promise { const target = state.tickets.find((ticket) => ticket.id === ticketId); if (!target) { throw new Error(`Unknown ticket ${ticketId}.`); } if (target.status !== 'done') { throw new Error( `Ticket ${ticketId} must be done before ticket PR triage (use poll-review while the ticket is in review).`, ); } if (!target.prNumber) { throw new Error( `Ticket ${ticketId} has no PR number; cannot reconcile review.`, ); } const now = dependencies.now ?? Date.now; const triager = dependencies.triager ?? ((worktreePath: string, artifactJsonPath: string) => runAiReviewTriager(worktreePath, artifactJsonPath, dependencies)); const resolveThreads = dependencies.resolveThreads ?? ((worktreePath: string, comments: AiReviewComment[]) => resolveNativeReviewThreads(worktreePath, comments, dependencies)); const profile = RECONCILE_REVIEW_POLLING_PROFILE; const { pollWindowStartedAt: resolvedStart, pollWindowStartedAtIso } = resolveReviewPollWindowStart(target.prOpenedAt, now); const pollWindowStartedAt = Math.min( resolvedStart, now() - (profile.intervalMinutes + 1) * 60_000, ); return runAiReviewLifecycleWithAdapters({ profile, worktreePath: target.worktreePath, prNumber: target.prNumber, pollWindowStartedAt, dependencies, async onTriageOrPartial(reviewPollResult) { const processedReview = await processDetectedAiReview({ artifactStemPath: resolve( cwd, state.reviewsDirPath, buildPrReviewArtifactStem(target.id), ), detectedReview: reviewPollResult.result, effectiveMaxWaitMinutes: reviewPollResult.effectiveMaxWaitMinutes, incompleteAgents: reviewPollResult.status === 'partial_timeout' ? reviewPollResult.incompleteAgents : undefined, mode: 'ticketed', previousOutcome: target.reviewOutcome, resolveThreads, triager, worktreePath: target.worktreePath, }); return applyTicketReviewUpdate( cwd, state, target.id, (ticket) => ({ ...ticket, prOpenedAt: ticket.prOpenedAt ?? pollWindowStartedAtIso, status: 'done', reviewFetchArtifactPath: dependencies.relativeToRepo( cwd, processedReview.fetchArtifactPath, ), reviewTriageArtifactPath: dependencies.relativeToRepo( cwd, processedReview.triageArtifactPath, ), reviewHeadSha: processedReview.reviewedHeadSha, reviewRecordedAt: processedReview.recordedAt, reviewOutcome: accumulateTicketReviewOutcome( ticket.reviewOutcome, processedReview.outcome, ), }), dependencies, ); }, async onCleanTimeout(reviewPollResult) { const processedReview = processCleanAiReview({ effectiveMaxWaitMinutes: reviewPollResult.effectiveMaxWaitMinutes, incompleteAgents: reviewPollResult.status === 'clean_timeout' ? reviewPollResult.incompleteAgents : undefined, maxWaitMinutes: profile.maxWaitMinutes, previousOutcome: target.reviewOutcome, }); const triageArtifactPath = await writeCleanTriageArtifact( resolve( cwd, state.reviewsDirPath, buildPrReviewArtifactStem(target.id), ), { incompleteAgents: processedReview.incompleteAgents, note: processedReview.note, outcome: processedReview.outcome, recordedAt: processedReview.recordedAt, reviewedHeadSha: target.reviewHeadSha, }, ); return applyTicketReviewUpdate( cwd, state, target.id, (ticket) => ({ ...ticket, prOpenedAt: ticket.prOpenedAt ?? pollWindowStartedAtIso, status: 'done', reviewFetchArtifactPath: ticket.reviewFetchArtifactPath, reviewTriageArtifactPath: dependencies.relativeToRepo( cwd, triageArtifactPath, ), reviewOutcome: accumulateTicketReviewOutcome( ticket.reviewOutcome, processedReview.outcome, ), reviewRecordedAt: processedReview.recordedAt, }), dependencies, ); }, }); } export async function runStandaloneAiReviewLifecycle( cwd: string, prNumber: number | undefined, dependencies: StandaloneAiReviewDependencies, ): Promise { const pullRequest = dependencies.pullRequest ?? dependencies.resolveStandalonePullRequest(cwd, prNumber); const previousOutcome = dependencies.previousOutcome ?? (await readStandaloneAiReviewOutcome(cwd, pullRequest.number)); const triager = dependencies.triager ?? ((worktreePath: string, artifactJsonPath: string) => runAiReviewTriager(worktreePath, artifactJsonPath, dependencies)); const resolveThreads = dependencies.resolveThreads ?? ((worktreePath: string, comments: AiReviewComment[]) => resolveNativeReviewThreads(worktreePath, comments, dependencies)); const now = dependencies.now ?? Date.now; const { pollWindowStartedAt } = resolveReviewPollWindowStart( pullRequest.createdAt, now, ); const profile = DEFAULT_REVIEW_POLLING_PROFILE; return runAiReviewLifecycleWithAdapters({ profile, worktreePath: cwd, prNumber: pullRequest.number, pollWindowStartedAt, dependencies, async onTriageOrPartial(reviewPollResult) { const processedReview = await processDetectedAiReview({ artifactStemPath: resolve( cwd, STANDALONE_TRIAGE_ARTIFACT_DIR, `pr-${pullRequest.number}`, 'review', ), detectedReview: reviewPollResult.result, effectiveMaxWaitMinutes: reviewPollResult.effectiveMaxWaitMinutes, incompleteAgents: reviewPollResult.status === 'partial_timeout' ? reviewPollResult.incompleteAgents : undefined, mapOutcome: mapStandaloneReviewOutcome, mode: 'standalone', previousOutcome, resolveThreads, triager, worktreePath: cwd, }); const triageArtifact = readTriageArtifact( processedReview.triageArtifactPath, ); const standaloneResult: StandaloneAiReviewResult = { actionSummary: triageArtifact?.actionSummary, comments: reviewPollResult.result.comments, fetchArtifactPath: dependencies.relativeToRepo( cwd, processedReview.fetchArtifactPath, ), incompleteAgents: processedReview.incompleteAgents, nonActionSummary: triageArtifact?.nonActionSummary, triageArtifactPath: dependencies.relativeToRepo( cwd, processedReview.triageArtifactPath, ), note: processedReview.note, outcome: processedReview.outcome, prNumber: pullRequest.number, prUrl: pullRequest.url, reviewedHeadSha: processedReview.reviewedHeadSha, recordedAt: processedReview.recordedAt, threadResolutions: triageArtifact?.threadResolutions, vendors: reviewPollResult.result.vendors, }; return persistStandaloneAiReviewResult( cwd, pullRequest, standaloneResult, dependencies, ); }, async onCleanTimeout(reviewPollResult) { const processedReview = processCleanAiReview({ effectiveMaxWaitMinutes: reviewPollResult.effectiveMaxWaitMinutes, incompleteAgents: reviewPollResult.status === 'clean_timeout' ? reviewPollResult.incompleteAgents : undefined, maxWaitMinutes: profile.maxWaitMinutes, previousOutcome, }); const triageArtifactPath = await writeCleanTriageArtifact( resolve( cwd, STANDALONE_TRIAGE_ARTIFACT_DIR, `pr-${pullRequest.number}`, 'review', ), { incompleteAgents: processedReview.incompleteAgents, note: processedReview.note, outcome: processedReview.outcome, recordedAt: processedReview.recordedAt, }, ); const existingFetchArtifactPath = buildReviewArtifactPaths( resolve( cwd, STANDALONE_TRIAGE_ARTIFACT_DIR, `pr-${pullRequest.number}`, 'review', ), ).fetchArtifactPath; const standaloneResult: StandaloneAiReviewResult = { fetchArtifactPath: existsSync(existingFetchArtifactPath) ? dependencies.relativeToRepo(cwd, existingFetchArtifactPath) : undefined, incompleteAgents: processedReview.incompleteAgents, note: processedReview.note, outcome: processedReview.outcome, prNumber: pullRequest.number, prUrl: pullRequest.url, recordedAt: processedReview.recordedAt, triageArtifactPath: dependencies.relativeToRepo( cwd, triageArtifactPath, ), vendors: [], }; return persistStandaloneAiReviewResult( cwd, pullRequest, standaloneResult, dependencies, ); }, }); } export async function writeStandaloneAiReviewNote( cwd: string, prNumber: number, result: StandaloneAiReviewResult, ): Promise { const notePath = resolve( cwd, STANDALONE_TRIAGE_ARTIFACT_DIR, `pr-${prNumber}`, 'note.md', ); await mkdir(dirname(notePath), { recursive: true }); await writeFile( notePath, [ '# AI Review Note', '', `- PR: ${result.prUrl}`, `- Outcome: \`${result.outcome}\``, result.recordedAt ? `- Recorded at: ${result.recordedAt}` : undefined, `- Note: ${result.note}`, result.fetchArtifactPath ? `- Fetch artifact: \`${result.fetchArtifactPath}\`` : undefined, result.triageArtifactPath ? `- Triage artifact: \`${result.triageArtifactPath}\`` : undefined, ] .filter((line): line is string => line !== undefined) .join('\n') + '\n', 'utf8', ); } export async function recordTicketReview( state: DeliveryState, cwd: string, ticketId: string, outcome: ReviewResult, note: string | undefined, dependencies: Pick< TicketReviewDependencies, | 'resolveThreads' | 'updatePullRequestBody' | 'replyToReviewThread' | 'resolveReviewThread' | 'relativeToRepo' | 'resolveReviewFetcher' | 'resolveReviewTriager' | 'runProcess' >, ): Promise { const target = state.tickets.find((ticket) => ticket.id === ticketId); if (!target) { throw new Error(`Unknown ticket ${ticketId}.`); } if ( target.status !== 'needs_patch' && target.status !== 'in_review' && target.status !== 'operator_input_needed' ) { throw new Error( `Ticket ${ticketId} must be in review before recording an outcome.`, ); } const resolveThreads = dependencies.resolveThreads ?? ((worktreePath: string, comments: AiReviewComment[]) => resolveNativeReviewThreads(worktreePath, comments, dependencies)); const artifactStemPath = resolve( cwd, state.reviewsDirPath, buildPrReviewArtifactStem(ticketId), ); let fetchArtifactPath = target.reviewFetchArtifactPath ? resolve(cwd, target.reviewFetchArtifactPath) : undefined; let triageArtifactPath = target.reviewTriageArtifactPath ? resolve(cwd, target.reviewTriageArtifactPath) : undefined; let fetchArtifact = fetchArtifactPath ? readFetchArtifact(fetchArtifactPath) : undefined; let existingTriageArtifact = triageArtifactPath ? readTriageArtifact(triageArtifactPath) : undefined; if ( !fetchArtifact && target.reviewComments && target.reviewComments.length > 0 ) { const paths = buildReviewArtifactPaths(artifactStemPath); fetchArtifactPath = paths.fetchArtifactPath; await writeFetchArtifact(fetchArtifactPath, { schemaVersion: 1, fetchedAt: new Date().toISOString(), reviewedHeadSha: target.reviewHeadSha, detected: true, vendors: target.reviewVendors && target.reviewVendors.length > 0 ? target.reviewVendors : [ ...new Set( target.reviewComments.map((comment) => comment.vendor), ), ], agents: [], comments: target.reviewComments, }); fetchArtifact = readFetchArtifact(fetchArtifactPath); } if (!existingTriageArtifact) { const paths = buildReviewArtifactPaths(artifactStemPath); triageArtifactPath = paths.triageArtifactPath; await writeTriageArtifact(triageArtifactPath, { schemaVersion: 1, recordedAt: new Date().toISOString(), reviewedHeadSha: target.reviewHeadSha, outcome: target.reviewOutcome ?? (target.status === 'needs_patch' ? 'needs_patch' : target.status === 'operator_input_needed' ? 'operator_input_needed' : 'clean'), note: target.reviewNote ?? 'External AI review completed without prudent follow-up changes.', actionSummary: target.reviewActionSummary, nonActionSummary: target.reviewNonActionSummary, incompleteAgents: target.reviewIncompleteAgents, threadResolutions: target.reviewThreadResolutions, }); existingTriageArtifact = readTriageArtifact(triageArtifactPath); } if (!triageArtifactPath || !existingTriageArtifact) { throw new Error( `Ticket ${ticketId} has incomplete review state: triage artifact is missing.`, ); } if ( outcome === 'patched' && (!fetchArtifact || fetchArtifact.comments.length === 0) && (!existingTriageArtifact.threadResolutions || existingTriageArtifact.threadResolutions.length === 0) ) { throw new Error( `Ticket ${ticketId} has incomplete review state: fetch artifact is missing or empty, so patched review threads cannot be reconciled safely.`, ); } const reviewThreadResolutions = outcome === 'patched' && existingTriageArtifact?.threadResolutions && existingTriageArtifact.threadResolutions.length > 0 ? existingTriageArtifact.threadResolutions : outcome === 'patched' && fetchArtifact?.comments ? resolveThreads(target.worktreePath, fetchArtifact.comments) : existingTriageArtifact?.threadResolutions; if ( outcome === 'patched' && reviewThreadResolutions && reviewThreadResolutions.length > 0 && triageArtifactPath ) { await updateTriageArtifact(triageArtifactPath, (current) => ({ schemaVersion: 1, recordedAt: new Date().toISOString(), reviewedHeadSha: current?.reviewedHeadSha ?? existingTriageArtifact?.reviewedHeadSha ?? target.reviewHeadSha, outcome, note: formatAccumulatedReviewNote( target.reviewOutcome, outcome, defaultFinalReviewNote( outcome, note, current?.note ?? existingTriageArtifact?.note, ), ) ?? defaultFinalReviewNote( outcome, note, current?.note ?? existingTriageArtifact?.note, ) ?? 'External AI review completed without prudent follow-up changes.', actionSummary: current?.actionSummary ?? existingTriageArtifact?.actionSummary, nonActionSummary: current?.nonActionSummary ?? existingTriageArtifact?.nonActionSummary, incompleteAgents: current?.incompleteAgents ?? existingTriageArtifact?.incompleteAgents, patchCommitShas: current?.patchCommitShas ?? existingTriageArtifact?.patchCommitShas, threadResolutions: reviewThreadResolutions, prBodyRefresh: current?.prBodyRefresh ?? existingTriageArtifact?.prBodyRefresh, })); } else if (triageArtifactPath) { await updateTriageArtifact(triageArtifactPath, (current) => ({ schemaVersion: 1, recordedAt: new Date().toISOString(), reviewedHeadSha: current?.reviewedHeadSha ?? existingTriageArtifact?.reviewedHeadSha ?? target.reviewHeadSha, outcome, note: formatAccumulatedReviewNote( target.reviewOutcome, outcome, defaultFinalReviewNote( outcome, note, current?.note ?? existingTriageArtifact?.note, ), ) ?? defaultFinalReviewNote( outcome, note, current?.note ?? existingTriageArtifact?.note, ) ?? 'External AI review completed without prudent follow-up changes.', actionSummary: current?.actionSummary ?? existingTriageArtifact?.actionSummary, nonActionSummary: current?.nonActionSummary ?? existingTriageArtifact?.nonActionSummary, incompleteAgents: current?.incompleteAgents ?? existingTriageArtifact?.incompleteAgents, patchCommitShas: current?.patchCommitShas ?? existingTriageArtifact?.patchCommitShas, threadResolutions: outcome === 'patched' ? reviewThreadResolutions : current?.threadResolutions, prBodyRefresh: current?.prBodyRefresh ?? existingTriageArtifact?.prBodyRefresh, })); } const nextState = await applyTicketReviewUpdate( cwd, state, ticketId, (ticket) => ({ ...ticket, status: outcome === 'operator_input_needed' ? 'operator_input_needed' : 'reviewed', reviewFetchArtifactPath: fetchArtifactPath ? dependencies.relativeToRepo(cwd, fetchArtifactPath) : ticket.reviewFetchArtifactPath, reviewTriageArtifactPath: dependencies.relativeToRepo( cwd, triageArtifactPath, ), reviewOutcome: accumulateTicketReviewOutcome( ticket.reviewOutcome, outcome, ), reviewRecordedAt: new Date().toISOString(), }), dependencies, ); return nextState; }