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<string, unknown> {
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<void>;
triager?: (
worktreePath: string,
artifactJsonPath: string,
) => AiReviewTriagerResult;
updatePullRequestBody?: (
state: DeliveryState,
ticket: TicketState,
) => void | Promise<void>;
} & 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<void>;
writeNote?: (
cwd: string,
prNumber: number,
result: StandaloneAiReviewResult,
) => Promise<void>;
};
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<AiReviewAgentResult, 'state'>,
): 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<ReviewOutcome | undefined> {
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<void> {
return new Promise((resolveSleep) => {
setTimeout(resolveSleep, milliseconds);
});
}
export function resolveNativeReviewThreads(
worktreePath: string,
comments: AiReviewComment[],
dependencies: ReviewCoreDependencies,
): AiReviewThreadResolution[] {
const resolutions: AiReviewThreadResolution[] = [];
const seen = new Set<string>();
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<TicketReviewDependencies, 'fetcher' | 'now' | 'sleep'> &
ReviewCoreDependencies,
): Promise<PollForAiReviewResult> {
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<DetectedReviewProcessingResult> {
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<string> {
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<boolean> {
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<DeliveryState> {
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<StandaloneAiReviewResult> {
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<T> = {
onTriageOrPartial: (
reviewPollResult: Extract<
PollForAiReviewResult,
{ status: 'triage_ready' | 'partial_timeout' }
>,
) => Promise<T>;
onCleanTimeout: (reviewPollResult: PollForAiReviewResult) => Promise<T>;
};
export async function runAiReviewLifecycleWithAdapters<T>(input: {
profile: ReviewPollingProfile;
worktreePath: string;
prNumber: number;
pollWindowStartedAt: number;
dependencies: Pick<TicketReviewDependencies, 'fetcher' | 'now' | 'sleep'> &
ReviewCoreDependencies;
onTriageOrPartial: AiReviewLifecycleHooks<T>['onTriageOrPartial'];
onCleanTimeout: AiReviewLifecycleHooks<T>['onCleanTimeout'];
}): Promise<T> {
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<DeliveryState> {
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<DeliveryState> {
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<StandaloneAiReviewResult> {
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<void> {
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<DeliveryState> {
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;
}
tools/delivery
review.ts
tools/delivery/review.ts