From 979cb95b7c7159a3c59bccc8713006266ad6f388 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:15:11 -0400 Subject: [PATCH 01/16] Add review service end-to-end with IPC and UI --- apps/desktop/src/main/main.ts | 15 + .../src/main/services/ipc/registerIpc.ts | 32 + .../src/main/services/lanes/laneService.ts | 3 + .../services/review/reviewService.test.ts | 386 ++++++ .../src/main/services/review/reviewService.ts | 873 +++++++++++++ .../review/reviewTargetMaterializer.test.ts | 194 +++ .../review/reviewTargetMaterializer.ts | 343 ++++++ apps/desktop/src/main/services/state/kvDb.ts | 63 + apps/desktop/src/preload/global.d.ts | 14 + apps/desktop/src/preload/preload.test.ts | 47 + apps/desktop/src/preload/preload.ts | 23 + apps/desktop/src/renderer/browserMock.ts | 212 ++++ .../src/renderer/components/app/App.tsx | 4 + .../renderer/components/app/TabNav.test.tsx | 60 + .../src/renderer/components/app/TabNav.tsx | 2 + .../components/review/ReviewPage.test.tsx | 337 +++++ .../renderer/components/review/ReviewPage.tsx | 1092 +++++++++++++++++ .../renderer/components/review/reviewApi.ts | 70 ++ .../components/review/reviewRouteState.ts | 13 + .../renderer/components/review/reviewTypes.ts | 33 + apps/desktop/src/shared/ipc.ts | 6 + apps/desktop/src/shared/types/index.ts | 1 + apps/desktop/src/shared/types/review.ts | 188 +++ 23 files changed, 4011 insertions(+) create mode 100644 apps/desktop/src/main/services/review/reviewService.test.ts create mode 100644 apps/desktop/src/main/services/review/reviewService.ts create mode 100644 apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts create mode 100644 apps/desktop/src/main/services/review/reviewTargetMaterializer.ts create mode 100644 apps/desktop/src/renderer/components/app/TabNav.test.tsx create mode 100644 apps/desktop/src/renderer/components/review/ReviewPage.test.tsx create mode 100644 apps/desktop/src/renderer/components/review/ReviewPage.tsx create mode 100644 apps/desktop/src/renderer/components/review/reviewApi.ts create mode 100644 apps/desktop/src/renderer/components/review/reviewRouteState.ts create mode 100644 apps/desktop/src/renderer/components/review/reviewTypes.ts create mode 100644 apps/desktop/src/shared/types/review.ts diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 6114715d9..a0e9283a0 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -71,6 +71,7 @@ import { createAutomationService } from "./services/automations/automationServic import { createAutomationPlannerService } from "./services/automations/automationPlannerService"; import { createAutomationSecretService } from "./services/automations/automationSecretService"; import { createAutomationIngressService } from "./services/automations/automationIngressService"; +import { createReviewService } from "./services/review/reviewService"; import { createUsageTrackingService } from "./services/usage/usageTrackingService"; import { createBudgetCapService } from "./services/usage/budgetCapService"; import { createRebaseSuggestionService } from "./services/lanes/rebaseSuggestionService"; @@ -2000,6 +2001,18 @@ app.whenReady().then(async () => { onEvent: (event) => emitProjectEvent(projectRoot, IPC.automationsEvent, event), }); + const reviewService = createReviewService({ + db, + logger, + projectId, + projectRoot, + projectDefaultBranch: null, + laneService, + gitService, + agentChatService, + sessionService, + onEvent: (event) => emitProjectEvent(projectRoot, IPC.reviewEvent, event), + }); const automationIngressService = createAutomationIngressService({ logger, automationService, @@ -2961,6 +2974,7 @@ app.whenReady().then(async () => { queueLandingService, issueInventoryService, prSummaryService, + reviewService, jobEngine, automationService, automationPlannerService, @@ -3060,6 +3074,7 @@ app.whenReady().then(async () => { queueLandingService: null, issueInventoryService: null, prSummaryService: null, + reviewService: null, jobEngine: null, automationService: null, automationPlannerService: null, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 68d27328d..4eb0d82a4 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -38,6 +38,11 @@ import type { AutomationSaveDraftResult, AutomationSimulateRequest, AutomationSimulateResult, + ReviewLaunchContext, + ReviewListRunsArgs, + ReviewRun, + ReviewRunDetail, + ReviewStartRunArgs, AddMissionArtifactArgs, AddMissionInterventionArgs, ConflictProposal, @@ -522,6 +527,7 @@ import type { createPrPollingService } from "../prs/prPollingService"; import type { createQueueLandingService } from "../prs/queueLandingService"; import type { createIssueInventoryService } from "../prs/issueInventoryService"; import type { createPrSummaryService } from "../prs/prSummaryService"; +import type { createReviewService } from "../review/reviewService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { @@ -619,6 +625,7 @@ export type AppContext = { queueLandingService: ReturnType; issueInventoryService: ReturnType; prSummaryService: ReturnType; + reviewService: ReturnType; jobEngine: ReturnType; automationService: ReturnType; automationPlannerService: ReturnType; @@ -2539,6 +2546,31 @@ export function registerIpc({ return ctx.automationPlannerService.simulate(arg); }); + ipcMain.handle(IPC.reviewListLaunchContext, async (): Promise => { + const ctx = getCtx(); + return ctx.reviewService.listLaunchContext(); + }); + + ipcMain.handle(IPC.reviewListRuns, async (_event, arg: ReviewListRunsArgs = {}): Promise => { + const ctx = getCtx(); + return ctx.reviewService.listRuns(arg); + }); + + ipcMain.handle(IPC.reviewGetRunDetail, async (_event, arg: { runId: string }): Promise => { + const ctx = getCtx(); + return ctx.reviewService.getRunDetail({ runId: arg?.runId ?? "" }); + }); + + ipcMain.handle(IPC.reviewStartRun, async (_event, arg: ReviewStartRunArgs): Promise => { + const ctx = getCtx(); + return ctx.reviewService.startRun(arg); + }); + + ipcMain.handle(IPC.reviewRerun, async (_event, arg: { runId: string }): Promise => { + const ctx = getCtx(); + return ctx.reviewService.rerun(arg?.runId ?? ""); + }); + ipcMain.handle(IPC.missionsList, async (_event, arg: ListMissionsArgs = {}): Promise => { const ctx = getCtx(); return ctx.missionService.list(arg); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 3f20ca16d..21776d0f0 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -2501,6 +2501,9 @@ export function createLaneService({ db.run("delete from pr_pipeline_settings where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from pr_issue_inventory where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from pull_requests where lane_id = ? and project_id = ?", [laneId, projectId]); + db.run("delete from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from review_run_artifacts where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from review_runs where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from session_deltas where lane_id = ?", [laneId]); db.run("delete from terminal_sessions where lane_id = ?", [laneId]); db.run("delete from operations where lane_id = ?", [laneId]); diff --git a/apps/desktop/src/main/services/review/reviewService.test.ts b/apps/desktop/src/main/services/review/reviewService.test.ts new file mode 100644 index 000000000..8bde7ab21 --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewService.test.ts @@ -0,0 +1,386 @@ +import path from "node:path"; +import { createRequire } from "node:module"; +import initSqlJs from "sql.js"; +import type { Database, SqlJsStatic } from "sql.js"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMaterializer = vi.hoisted(() => ({ + materialize: vi.fn(), +})); + +vi.mock("./reviewTargetMaterializer", () => ({ + createReviewTargetMaterializer: () => ({ + materialize: (...args: unknown[]) => mockMaterializer.materialize(...args), + }), +})); + +import { createReviewService } from "./reviewService"; + +type SqlValue = string | number | null | Uint8Array; +type AdeDb = { + run: (sql: string, params?: SqlValue[]) => void; + get: = Record>(sql: string, params?: SqlValue[]) => T | null; + all: = Record>(sql: string, params?: SqlValue[]) => T[]; +}; + +function mapExecRows(rows: { columns: string[]; values: unknown[][] }[]): Record[] { + const first = rows[0]; + if (!first) return []; + return first.values.map((row) => { + const out: Record = {}; + first.columns.forEach((column, index) => { + out[column] = row[index]; + }); + return out; + }); +} + +let SQL: SqlJsStatic; + +beforeAll(async () => { + const require = createRequire(import.meta.url); + const wasmPath = require.resolve("sql.js/dist/sql-wasm.wasm"); + const wasmDir = path.dirname(wasmPath); + SQL = await initSqlJs({ + locateFile: (file) => path.join(wasmDir, file), + }); +}); + +function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { + const raw = new SQL.Database(); + raw.run(` + create table review_runs( + id text primary key, + project_id text not null, + lane_id text not null, + target_json text not null, + config_json text not null, + target_label text not null, + compare_target_json text, + status text not null, + summary text, + error_message text, + finding_count integer not null default 0, + severity_summary_json text, + chat_session_id text, + created_at text not null, + started_at text not null, + ended_at text, + updated_at text not null + ) + `); + raw.run(` + create table review_findings( + id text primary key, + run_id text not null, + title text not null, + severity text not null, + body text not null, + confidence real not null default 0.5, + evidence_json text, + file_path text, + line integer, + anchor_state text not null, + source_pass text not null, + publication_state text not null + ) + `); + raw.run(` + create table review_run_artifacts( + id text primary key, + run_id text not null, + artifact_type text not null, + title text not null, + mime_type text not null, + content_text text, + metadata_json text, + created_at text not null + ) + `); + + const run = (sql: string, params: SqlValue[] = []) => raw.run(sql, params); + const all = = Record>(sql: string, params: SqlValue[] = []): T[] => + mapExecRows(raw.exec(sql, params)) as T[]; + const get = = Record>(sql: string, params: SqlValue[] = []): T | null => + all(sql, params)[0] ?? null; + + return { raw, db: { run, all, get } }; +} + +async function waitFor(fn: () => T | Promise, predicate: (value: T) => boolean, timeoutMs = 3000): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const value = await fn(); + if (predicate(value)) return value; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("Timed out waiting for review service state"); +} + +describe("reviewService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("persists a completed review run and reopens its findings later", async () => { + const { db, raw } = createInMemoryAdeDb(); + mockMaterializer.materialize.mockResolvedValue({ + targetLabel: "feature/review-tab vs main", + compareTarget: { + kind: "default_branch", + label: "main", + ref: "main", + laneId: null, + branchRef: "main", + }, + fullPatchText: "diff --git a/src/review.ts b/src/review.ts\n@@ -1,1 +1,2 @@\n+return null;\n", + changedFiles: [ + { + filePath: "src/review.ts", + excerpt: "@@ -1,1 +1,2 @@\n+return null;", + lineNumbers: [2], + }, + ], + artifacts: [ + { + artifactType: "diff_bundle", + title: "Diff bundle", + mimeType: "text/plain", + contentText: "diff --git a/src/review.ts b/src/review.ts\n@@ -1,1 +1,2 @@\n+return null;\n", + metadata: null, + }, + ], + }); + + const events: Array<{ type: string; runId?: string; status?: string }> = []; + const service = createReviewService({ + db: db as any, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any, + projectId: "project-1", + projectRoot: "/tmp/ade", + projectDefaultBranch: "main", + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/review-tab", + worktreePath: "/tmp/ade/lane", + laneType: "worktree", + }), + list: vi.fn(async () => [ + { + id: "lane-review", + name: "feature/review-tab", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/review-tab", + color: null, + }, + ]), + } as any, + gitService: { + listRecentCommits: vi.fn(async () => [ + { + sha: "abc1234567890", + shortSha: "abc1234", + parents: [], + authorName: "Arul", + authoredAt: "2026-04-05T12:00:00.000Z", + subject: "Recent review work", + pushed: true, + }, + ]), + } as any, + agentChatService: { + createSession: vi.fn(async () => ({ + id: "session-review-1", + laneId: "lane-review", + provider: "codex", + model: "gpt-5.4-codex", + modelId: "openai/gpt-5.4-codex", + })), + getSessionSummary: vi.fn(async () => ({ + sessionId: "session-review-1", + laneId: "lane-review", + provider: "codex", + model: "gpt-5.4-codex", + modelId: "openai/gpt-5.4-codex", + title: "Review transcript", + surface: "automation", + status: "idle", + startedAt: "2026-04-05T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-04-05T12:05:00.000Z", + lastOutputPreview: "Review output", + summary: "Saved review transcript", + })), + runSessionTurn: vi.fn(async () => ({ + sessionId: "session-review-1", + provider: "codex", + model: "gpt-5.4-codex", + modelId: "openai/gpt-5.4-codex", + outputText: JSON.stringify({ + summary: "One anchored finding.", + findings: [ + { + title: "Missing fallback", + severity: "high", + body: "The new branch returns null without a fallback path.", + confidence: 0.91, + filePath: "src/review.ts", + line: 2, + evidence: [ + { + summary: "The diff adds a direct null return.", + filePath: "src/review.ts", + line: 2, + quote: "+return null;", + }, + ], + }, + ], + }), + })), + } as any, + sessionService: { + updateMeta: vi.fn(), + } as any, + onEvent: (event) => { + events.push({ type: event.type, runId: (event as any).runId, status: (event as any).status }); + }, + }); + + const run = await service.startRun({ + target: { mode: "lane_diff", laneId: "lane-review" }, + }); + + expect(run.status).toBe("queued"); + + const completed = await waitFor( + () => service.listRuns(), + (runs) => runs[0]?.status === "completed", + ); + expect(completed[0]?.targetLabel).toBe("feature/review-tab vs main"); + + const detail = await service.getRunDetail({ runId: run.id }); + expect(detail?.summary).toBe("One anchored finding."); + expect(detail?.findingCount).toBe(1); + expect(detail?.findings[0]?.anchorState).toBe("anchored"); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "prompt")).toBe(true); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "review_output")).toBe(true); + expect(detail?.chatSession?.sessionId).toBe("session-review-1"); + expect(events.some((event) => event.type === "run-completed" && event.runId === run.id && event.status === "completed")).toBe(true); + + const persistedRuns = mapExecRows(raw.exec("select status, finding_count from review_runs")); + expect(String(persistedRuns[0]?.status)).toBe("completed"); + expect(Number(persistedRuns[0]?.finding_count)).toBe(1); + }); + + it("reruns a prior review with the same target and config", async () => { + const { db } = createInMemoryAdeDb(); + mockMaterializer.materialize.mockResolvedValue({ + targetLabel: "feature/review-tab vs main", + compareTarget: { + kind: "default_branch", + label: "main", + ref: "main", + laneId: null, + branchRef: "main", + }, + fullPatchText: "", + changedFiles: [], + artifacts: [ + { + artifactType: "diff_bundle", + title: "Diff bundle", + mimeType: "text/plain", + contentText: "", + metadata: null, + }, + ], + }); + + const service = createReviewService({ + db: db as any, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any, + projectId: "project-1", + projectRoot: "/tmp/ade", + projectDefaultBranch: "main", + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/review-tab", + worktreePath: "/tmp/ade/lane", + laneType: "worktree", + }), + list: vi.fn(async () => []), + } as any, + gitService: { + listRecentCommits: vi.fn(async () => []), + } as any, + agentChatService: { + createSession: vi.fn(async () => ({ + id: "session-review-2", + laneId: "lane-review", + provider: "codex", + model: "gpt-5.4-codex", + modelId: "openai/gpt-5.4-codex", + })), + getSessionSummary: vi.fn(async () => null), + runSessionTurn: vi.fn(async () => ({ + sessionId: "session-review-2", + provider: "codex", + model: "gpt-5.4-codex", + modelId: "openai/gpt-5.4-codex", + outputText: JSON.stringify({ summary: "No issues.", findings: [] }), + })), + } as any, + sessionService: { + updateMeta: vi.fn(), + } as any, + }); + + const first = await service.startRun({ + target: { + mode: "commit_range", + laneId: "lane-review", + baseCommit: "abc123456789", + headCommit: "def456789012", + }, + config: { + selectionMode: "selected_commits", + compareAgainst: { kind: "default_branch" }, + dirtyOnly: false, + modelId: "openai/gpt-5.4-codex", + reasoningEffort: "high", + budgets: { maxFiles: 10, maxDiffChars: 1000, maxPromptChars: 1000, maxFindings: 4 }, + publishBehavior: "local_only", + }, + }); + await waitFor(() => service.listRuns(), (runs) => runs.some((run) => run.id === first.id && run.status === "completed")); + + const rerun = await service.rerun(first.id); + expect(rerun.id).not.toBe(first.id); + await waitFor(() => service.listRuns(), (runs) => runs.some((run) => run.id === rerun.id && run.status === "completed")); + + const allRuns = await service.listRuns(); + const rerunRecord = allRuns.find((entry) => entry.id === rerun.id); + expect(rerunRecord?.target).toEqual({ + mode: "commit_range", + laneId: "lane-review", + baseCommit: "abc123456789", + headCommit: "def456789012", + }); + expect(rerunRecord?.config.selectionMode).toBe("selected_commits"); + expect(rerunRecord?.config.reasoningEffort).toBe("high"); + }); +}); diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts new file mode 100644 index 000000000..391de2c69 --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -0,0 +1,873 @@ +import { randomUUID } from "node:crypto"; +import type { + ReviewArtifactType, + ReviewEventPayload, + ReviewEvidence, + ReviewFinding, + ReviewPublicationState, + ReviewResolvedCompareTarget, + ReviewRun, + ReviewRunArtifact, + ReviewRunConfig, + ReviewRunDetail, + ReviewRunStatus, + ReviewSeverity, + ReviewSeveritySummary, + ReviewSourcePass, + ReviewStartRunArgs, + ReviewTarget, + ReviewListRunsArgs, + ReviewLaunchContext, + ReviewLaunchLane, + ReviewLaunchCommit, +} from "../../../shared/types"; +import { getDefaultModelDescriptor, getModelById, resolveChatProviderForDescriptor } from "../../../shared/modelRegistry"; +import type { Logger } from "../logging/logger"; +import type { AdeDb } from "../state/kvDb"; +import { getErrorMessage, isRecord, nowIso, safeJsonParse } from "../shared/utils"; +import type { createLaneService } from "../lanes/laneService"; +import type { createGitOperationsService } from "../git/gitOperationsService"; +import type { createAgentChatService } from "../chat/agentChatService"; +import type { createSessionService } from "../sessions/sessionService"; +import { createReviewTargetMaterializer } from "./reviewTargetMaterializer"; + +type ReviewRunRow = { + id: string; + project_id: string; + lane_id: string; + target_json: string; + config_json: string; + target_label: string; + compare_target_json: string | null; + status: string; + summary: string | null; + error_message: string | null; + finding_count: number; + severity_summary_json: string | null; + chat_session_id: string | null; + created_at: string; + started_at: string; + ended_at: string | null; + updated_at: string; +}; + +type ReviewFindingRow = { + id: string; + run_id: string; + title: string; + severity: string; + body: string; + confidence: number; + evidence_json: string | null; + file_path: string | null; + line: number | null; + anchor_state: string; + source_pass: string; + publication_state: string; +}; + +type ReviewRunArtifactRow = { + id: string; + run_id: string; + artifact_type: string; + title: string; + mime_type: string; + content_text: string | null; + metadata_json: string | null; + created_at: string; +}; + +const DEFAULT_REVIEW_MODEL_ID = + getDefaultModelDescriptor("codex")?.id + ?? getDefaultModelDescriptor("unified")?.id + ?? "openai/gpt-5.4-codex"; + +const DEFAULT_BUDGETS: ReviewRunConfig["budgets"] = { + maxFiles: 60, + maxDiffChars: 180_000, + maxPromptChars: 220_000, + maxFindings: 12, +}; + +function defaultSeveritySummary(): ReviewSeveritySummary { + return { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + }; +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function truncateText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n...(truncated)...\n`; +} + +function cleanLine(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function normalizeSeverity(value: unknown): ReviewSeverity { + const raw = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (raw === "critical") return "critical"; + if (raw === "high" || raw === "major") return "high"; + if (raw === "medium" || raw === "moderate") return "medium"; + if (raw === "low" || raw === "minor") return "low"; + return "info"; +} + +function normalizeConfidence(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return clampNumber(value, 0, 1); + } + if (typeof value === "string") { + const raw = value.trim().toLowerCase(); + if (raw === "high") return 0.85; + if (raw === "medium") return 0.65; + if (raw === "low") return 0.35; + const parsed = Number(raw); + if (Number.isFinite(parsed)) return clampNumber(parsed, 0, 1); + } + return 0.5; +} + +function extractJsonObject(raw: string): Record | null { + const candidates: string[] = []; + const trimmed = raw.trim(); + if (trimmed.length) candidates.push(trimmed); + const fencedMatch = raw.match(/```(?:json)?\s*([\s\S]+?)```/i); + if (fencedMatch?.[1]) candidates.push(fencedMatch[1].trim()); + + const firstBrace = raw.indexOf("{"); + if (firstBrace >= 0) { + let depth = 0; + let inString = false; + let escaped = false; + for (let index = firstBrace; index < raw.length; index += 1) { + const char = raw[index] ?? ""; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "\"") { + inString = !inString; + continue; + } + if (inString) continue; + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) { + candidates.push(raw.slice(firstBrace, index + 1)); + break; + } + } + } + } + + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + if (isRecord(parsed)) return parsed; + } catch { + // Try the next candidate. + } + } + return null; +} + +function resolveTargetLaneId(target: ReviewTarget): string { + return target.laneId; +} + +function mapLaunchLane(lane: Awaited["list"]>>[number]): ReviewLaunchLane { + return { + id: lane.id, + name: lane.name, + laneType: lane.laneType, + branchRef: lane.branchRef, + baseRef: lane.baseRef, + color: lane.color ?? null, + }; +} + +function mapLaunchCommit(commit: Awaited["listRecentCommits"]>>[number]): ReviewLaunchCommit { + return { + sha: commit.sha, + shortSha: commit.shortSha, + subject: commit.subject, + authoredAt: commit.authoredAt, + pushed: commit.pushed, + }; +} + +function serializeSeveritySummary(summary: ReviewSeveritySummary): string { + return JSON.stringify(summary); +} + +function buildPrompt(args: { + run: ReviewRun; + diffText: string; + changedFiles: Array<{ filePath: string }>; +}): string { + const changedFilesSummary = args.changedFiles.length > 0 + ? args.changedFiles.map((entry) => `- ${entry.filePath}`).join("\n") + : "- No changed files were detected."; + + return [ + "You are ADE's local code reviewer.", + "Review only the provided local diff bundle.", + "Prioritize correctness, regressions, security, data loss, race conditions, risky migrations, and missing tests.", + "Do not suggest style-only nits or speculative rewrites.", + `Return strict JSON only with this exact top-level shape: {"summary": string, "findings": Finding[]}.`, + "Each Finding must be an object with:", + '- "title": short issue title', + '- "severity": one of "critical", "high", "medium", "low", "info"', + '- "body": concise explanation of the risk and why it matters', + '- "confidence": number between 0 and 1', + '- "filePath": changed file path when known, otherwise null', + '- "line": line number when known, otherwise null', + '- "evidence": array of objects with {"summary": string, "quote": string|null, "filePath": string|null, "line": number|null}', + `Return at most ${args.run.config.budgets.maxFindings} findings.`, + "If there are no real issues, return an empty findings array and explain that in summary.", + "", + `Review target: ${args.run.targetLabel}`, + `Selection mode: ${args.run.config.selectionMode}`, + `Publish behavior: ${args.run.config.publishBehavior}`, + "", + "Changed files:", + changedFilesSummary, + "", + "Diff bundle:", + truncateText(args.diffText, args.run.config.budgets.maxPromptChars), + ].join("\n"); +} + +function parseEvidence(value: unknown): ReviewEvidence[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!isRecord(entry)) return []; + const summary = cleanLine(String(entry.summary ?? "")); + if (!summary) return []; + return [{ + kind: "quote", + summary, + filePath: typeof entry.filePath === "string" ? entry.filePath.trim() || null : null, + line: typeof entry.line === "number" && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : null, + quote: typeof entry.quote === "string" ? entry.quote.trim() || null : null, + artifactId: typeof entry.artifactId === "string" ? entry.artifactId.trim() || null : null, + }]; + }); +} + +function computeAnchorState(args: { + filePath: string | null; + line: number | null; + changedFilesByPath: Map }>; +}): "anchored" | "file_only" | "missing" { + if (!args.filePath) return "missing"; + const match = args.changedFilesByPath.get(args.filePath); + if (!match) return "missing"; + if (args.line == null) return "file_only"; + return match.lineNumbers.has(args.line) ? "anchored" : "file_only"; +} + +function normalizeParsedFindings(args: { + runId: string; + parsed: Record | null; + changedFilesByPath: Map }>; +}): { summary: string | null; findings: ReviewFinding[] } { + const findingsRaw = Array.isArray(args.parsed?.findings) ? args.parsed?.findings : []; + const findings = findingsRaw.flatMap((entry) => { + if (!isRecord(entry)) return []; + const title = cleanLine(String(entry.title ?? "")); + const body = cleanLine(String(entry.body ?? "")); + if (!title || !body) return []; + const filePath = typeof entry.filePath === "string" ? entry.filePath.trim() || null : null; + const line = typeof entry.line === "number" && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : null; + const computedEvidence = parseEvidence(entry.evidence); + const fallbackFile = filePath ? args.changedFilesByPath.get(filePath) : null; + const evidence: ReviewEvidence[] = computedEvidence.length > 0 + ? computedEvidence + : fallbackFile + ? [{ + kind: "diff_hunk" as const, + summary: `Relevant diff context from ${filePath}`, + filePath, + line, + quote: fallbackFile.excerpt || null, + artifactId: null, + }] + : []; + const anchorState = computeAnchorState({ + filePath, + line, + changedFilesByPath: args.changedFilesByPath, + }); + + const finding: ReviewFinding = { + id: randomUUID(), + runId: args.runId, + title, + severity: normalizeSeverity(entry.severity), + body, + confidence: normalizeConfidence(entry.confidence), + evidence, + filePath, + line, + anchorState, + sourcePass: "single_pass" as ReviewSourcePass, + publicationState: "local_only" as ReviewPublicationState, + }; + return [finding]; + }); + + const summary = typeof args.parsed?.summary === "string" ? cleanLine(args.parsed.summary) : null; + return { summary, findings }; +} + +function tallySeveritySummary(findings: ReviewFinding[]): ReviewSeveritySummary { + const summary = defaultSeveritySummary(); + for (const finding of findings) { + summary[finding.severity] += 1; + } + return summary; +} + +function mapRunRow(row: ReviewRunRow): ReviewRun { + return { + id: row.id, + projectId: row.project_id, + laneId: row.lane_id, + target: safeJsonParse(row.target_json, { + mode: "working_tree", + laneId: row.lane_id, + }), + config: safeJsonParse(row.config_json, { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: DEFAULT_REVIEW_MODEL_ID, + reasoningEffort: null, + budgets: DEFAULT_BUDGETS, + publishBehavior: "local_only", + }), + targetLabel: row.target_label, + compareTarget: safeJsonParse(row.compare_target_json, null), + status: (row.status as ReviewRunStatus) ?? "failed", + summary: row.summary, + errorMessage: row.error_message, + findingCount: Number(row.finding_count ?? 0), + severitySummary: safeJsonParse(row.severity_summary_json, defaultSeveritySummary()), + chatSessionId: row.chat_session_id, + createdAt: row.created_at, + startedAt: row.started_at, + endedAt: row.ended_at, + updatedAt: row.updated_at, + }; +} + +function mapFindingRow(row: ReviewFindingRow): ReviewFinding { + return { + id: row.id, + runId: row.run_id, + title: row.title, + severity: normalizeSeverity(row.severity), + body: row.body, + confidence: clampNumber(Number(row.confidence ?? 0.5), 0, 1), + evidence: safeJsonParse(row.evidence_json, []), + filePath: row.file_path, + line: typeof row.line === "number" ? row.line : null, + anchorState: (row.anchor_state as ReviewFinding["anchorState"]) ?? "missing", + sourcePass: (row.source_pass as ReviewSourcePass) ?? "single_pass", + publicationState: (row.publication_state as ReviewPublicationState) ?? "local_only", + }; +} + +function mapArtifactRow(row: ReviewRunArtifactRow): ReviewRunArtifact { + return { + id: row.id, + runId: row.run_id, + artifactType: (row.artifact_type as ReviewArtifactType) ?? "diff_bundle", + title: row.title, + mimeType: row.mime_type, + contentText: row.content_text, + metadata: safeJsonParse | null>(row.metadata_json, null), + createdAt: row.created_at, + }; +} + +export function createReviewService({ + db, + logger, + projectId, + projectRoot, + projectDefaultBranch, + laneService, + gitService, + agentChatService, + sessionService, + onEvent, +}: { + db: AdeDb; + logger: Logger; + projectId: string; + projectRoot: string; + projectDefaultBranch: string | null; + laneService: Pick, "getLaneBaseAndBranch" | "list">; + gitService: Pick, "listRecentCommits">; + agentChatService: Pick, "createSession" | "getSessionSummary" | "runSessionTurn">; + sessionService: Pick, "updateMeta">; + onEvent?: (event: ReviewEventPayload) => void; +}) { + const materializer = createReviewTargetMaterializer({ laneService }); + const activeRuns = new Set(); + + function emit(event: ReviewEventPayload): void { + onEvent?.(event); + } + + function getRunRow(runId: string): ReviewRunRow | null { + return db.get( + "select * from review_runs where id = ? and project_id = ? limit 1", + [runId, projectId], + ); + } + + function updateRun(runId: string, patch: Partial<{ + target_label: string; + compare_target_json: string | null; + status: ReviewRunStatus; + summary: string | null; + error_message: string | null; + finding_count: number; + severity_summary_json: string; + chat_session_id: string | null; + ended_at: string | null; + updated_at: string; + }>): void { + const sets: string[] = []; + const params: Array = []; + for (const [key, value] of Object.entries(patch)) { + sets.push(`${key} = ?`); + params.push(value ?? null); + } + if (sets.length === 0) return; + params.push(runId, projectId); + db.run(`update review_runs set ${sets.join(", ")} where id = ? and project_id = ?`, params); + } + + function insertRun(run: ReviewRun): void { + db.run( + `insert into review_runs ( + id, + project_id, + lane_id, + target_json, + config_json, + target_label, + compare_target_json, + status, + summary, + error_message, + finding_count, + severity_summary_json, + chat_session_id, + created_at, + started_at, + ended_at, + updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + run.id, + run.projectId, + run.laneId, + JSON.stringify(run.target), + JSON.stringify(run.config), + run.targetLabel, + run.compareTarget ? JSON.stringify(run.compareTarget) : null, + run.status, + run.summary, + run.errorMessage, + run.findingCount, + serializeSeveritySummary(run.severitySummary), + run.chatSessionId, + run.createdAt, + run.startedAt, + run.endedAt, + run.updatedAt, + ], + ); + } + + function insertArtifact(runId: string, artifact: Omit): void { + db.run( + `insert into review_run_artifacts ( + id, + run_id, + artifact_type, + title, + mime_type, + content_text, + metadata_json, + created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + randomUUID(), + runId, + artifact.artifactType, + artifact.title, + artifact.mimeType, + artifact.contentText, + artifact.metadata ? JSON.stringify(artifact.metadata) : null, + nowIso(), + ], + ); + } + + function insertFinding(finding: ReviewFinding): void { + db.run( + `insert into review_findings ( + id, + run_id, + title, + severity, + body, + confidence, + evidence_json, + file_path, + line, + anchor_state, + source_pass, + publication_state + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + finding.id, + finding.runId, + finding.title, + finding.severity, + finding.body, + finding.confidence, + JSON.stringify(finding.evidence), + finding.filePath, + finding.line, + finding.anchorState, + finding.sourcePass, + finding.publicationState, + ], + ); + } + + async function listLaunchContext(): Promise { + const lanes = await laneService.list(); + const laneSummaries = lanes.map(mapLaunchLane); + const recentCommitsByLane = Object.fromEntries(await Promise.all( + laneSummaries.map(async (lane) => { + const commits = await gitService.listRecentCommits({ laneId: lane.id, limit: 20 }); + return [lane.id, commits.map(mapLaunchCommit)] as const; + }), + )); + return { + defaultLaneId: laneSummaries[0]?.id ?? null, + defaultBranchName: projectDefaultBranch ?? laneSummaries.find((lane) => lane.laneType === "primary")?.branchRef ?? null, + lanes: laneSummaries, + recentCommitsByLane, + recommendedModelId: DEFAULT_REVIEW_MODEL_ID, + }; + } + + function resolveConfig(target: ReviewTarget, partial?: Partial | null): ReviewRunConfig { + return { + compareAgainst: partial?.compareAgainst ?? { kind: "default_branch" }, + selectionMode: partial?.selectionMode + ?? (target.mode === "commit_range" + ? "selected_commits" + : target.mode === "working_tree" + ? "dirty_only" + : "full_diff"), + dirtyOnly: partial?.dirtyOnly ?? target.mode === "working_tree", + modelId: partial?.modelId?.trim() || DEFAULT_REVIEW_MODEL_ID, + reasoningEffort: partial?.reasoningEffort?.trim() || null, + budgets: { + maxFiles: clampNumber(Number(partial?.budgets?.maxFiles ?? DEFAULT_BUDGETS.maxFiles), 1, 500), + maxDiffChars: clampNumber(Number(partial?.budgets?.maxDiffChars ?? DEFAULT_BUDGETS.maxDiffChars), 4_000, 1_000_000), + maxPromptChars: clampNumber(Number(partial?.budgets?.maxPromptChars ?? DEFAULT_BUDGETS.maxPromptChars), 4_000, 1_000_000), + maxFindings: clampNumber(Number(partial?.budgets?.maxFindings ?? DEFAULT_BUDGETS.maxFindings), 1, 50), + }, + publishBehavior: "local_only", + }; + } + + async function executeRun(runId: string): Promise { + if (activeRuns.has(runId)) return; + activeRuns.add(runId); + try { + const row = getRunRow(runId); + if (!row) return; + const run = mapRunRow(row); + updateRun(runId, { + status: "running", + updated_at: nowIso(), + }); + emit({ type: "runs-updated", runId, laneId: run.laneId, status: "running" }); + + const materialized = await materializer.materialize({ + target: run.target, + config: run.config, + }); + + updateRun(runId, { + target_label: materialized.targetLabel, + compare_target_json: materialized.compareTarget ? JSON.stringify(materialized.compareTarget) : null, + updated_at: nowIso(), + }); + + for (const artifact of materialized.artifacts) { + insertArtifact(runId, artifact); + } + + if (!materialized.fullPatchText.trim()) { + const endedAt = nowIso(); + updateRun(runId, { + status: "completed", + summary: "No changes to review.", + error_message: null, + finding_count: 0, + severity_summary_json: serializeSeveritySummary(defaultSeveritySummary()), + ended_at: endedAt, + updated_at: endedAt, + }); + emit({ type: "run-completed", runId, laneId: run.laneId, status: "completed" }); + emit({ type: "runs-updated", runId, laneId: run.laneId, status: "completed" }); + return; + } + + const descriptor = getModelById(run.config.modelId); + if (!descriptor) { + throw new Error(`Unknown review model '${run.config.modelId}'.`); + } + const { provider, model } = resolveChatProviderForDescriptor(descriptor); + const session = await agentChatService.createSession({ + laneId: run.laneId, + provider, + model, + modelId: descriptor.id, + reasoningEffort: run.config.reasoningEffort, + permissionMode: "plan", + sessionProfile: "workflow", + surface: "automation", + }); + const sessionTitle = `Review: ${materialized.targetLabel}`; + sessionService.updateMeta({ + sessionId: session.id, + title: sessionTitle, + }); + updateRun(runId, { + chat_session_id: session.id, + updated_at: nowIso(), + }); + + const prompt = buildPrompt({ + run: { + ...run, + targetLabel: materialized.targetLabel, + compareTarget: materialized.compareTarget, + }, + diffText: truncateText(materialized.fullPatchText, run.config.budgets.maxDiffChars), + changedFiles: materialized.changedFiles.slice(0, run.config.budgets.maxFiles), + }); + insertArtifact(runId, { + artifactType: "prompt", + title: "Review prompt", + mimeType: "text/plain", + contentText: prompt, + metadata: { + modelId: descriptor.id, + reasoningEffort: run.config.reasoningEffort, + }, + }); + + const result = await agentChatService.runSessionTurn({ + sessionId: session.id, + text: prompt, + displayText: sessionTitle, + reasoningEffort: run.config.reasoningEffort, + timeoutMs: 15 * 60 * 1000, + }); + insertArtifact(runId, { + artifactType: "review_output", + title: "Reviewer output", + mimeType: "application/json", + contentText: result.outputText, + metadata: { + provider: result.provider, + model: result.model, + modelId: result.modelId ?? descriptor.id, + }, + }); + + const changedFilesByPath = new Map(materialized.changedFiles.map((entry) => [ + entry.filePath, + { excerpt: entry.excerpt, lineNumbers: new Set(entry.lineNumbers) }, + ])); + const parsed = extractJsonObject(result.outputText); + const normalized = normalizeParsedFindings({ + runId, + parsed, + changedFilesByPath, + }); + const findings = normalized.findings.slice(0, run.config.budgets.maxFindings); + for (const finding of findings) { + insertFinding(finding); + } + const severitySummary = tallySeveritySummary(findings); + const endedAt = nowIso(); + updateRun(runId, { + status: "completed", + summary: normalized.summary ?? (findings.length > 0 ? `Review completed with ${findings.length} finding(s).` : "No actionable findings."), + error_message: null, + finding_count: findings.length, + severity_summary_json: serializeSeveritySummary(severitySummary), + ended_at: endedAt, + updated_at: endedAt, + }); + emit({ type: "run-completed", runId, laneId: run.laneId, status: "completed" }); + emit({ type: "runs-updated", runId, laneId: run.laneId, status: "completed" }); + } catch (error) { + const endedAt = nowIso(); + updateRun(runId, { + status: "failed", + error_message: getErrorMessage(error), + ended_at: endedAt, + updated_at: endedAt, + }); + logger.warn("review.run_failed", { + runId, + projectRoot, + error: getErrorMessage(error), + }); + const row = getRunRow(runId); + emit({ + type: "run-completed", + runId, + laneId: row?.lane_id ?? "", + status: "failed", + }); + emit({ type: "runs-updated", runId, laneId: row?.lane_id, status: "failed" }); + } finally { + activeRuns.delete(runId); + } + } + + async function startRun(args: ReviewStartRunArgs): Promise { + const laneId = resolveTargetLaneId(args.target); + laneService.getLaneBaseAndBranch(laneId); + const config = resolveConfig(args.target, args.config); + const startedAt = nowIso(); + const run: ReviewRun = { + id: randomUUID(), + projectId, + laneId, + target: args.target, + config, + targetLabel: args.target.mode === "commit_range" + ? `${laneId} ${args.target.baseCommit.slice(0, 7)}..${args.target.headCommit.slice(0, 7)}` + : args.target.mode === "working_tree" + ? `${laneId} working tree` + : `${laneId} review`, + compareTarget: null, + status: "queued", + summary: null, + errorMessage: null, + findingCount: 0, + severitySummary: defaultSeveritySummary(), + chatSessionId: null, + createdAt: startedAt, + startedAt, + endedAt: null, + updatedAt: startedAt, + }; + insertRun(run); + emit({ type: "run-started", runId: run.id, laneId }); + emit({ type: "runs-updated", runId: run.id, laneId, status: "queued" }); + void executeRun(run.id); + return run; + } + + async function rerun(runId: string): Promise { + const row = getRunRow(runId); + if (!row) throw new Error(`Review run '${runId}' was not found.`); + const existing = mapRunRow(row); + return startRun({ + target: existing.target, + config: existing.config, + }); + } + + async function listRuns(args: ReviewListRunsArgs = {}): Promise { + const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 50))); + const sql = [ + "select * from review_runs where project_id = ?", + args.laneId ? "and lane_id = ?" : "", + args.status && args.status !== "all" ? "and status = ?" : "", + "order by created_at desc limit ?", + ].join(" "); + const params: Array = [projectId]; + if (args.laneId) params.push(args.laneId); + if (args.status && args.status !== "all") params.push(args.status); + params.push(limit); + return db.all(sql, params).map(mapRunRow); + } + + async function getRunDetail(args: { runId: string }): Promise { + const row = getRunRow(args.runId); + if (!row) return null; + const run = mapRunRow(row); + const findings = db.all( + `select * from review_findings + where run_id = ? + order by + case severity + when 'critical' then 0 + when 'high' then 1 + when 'medium' then 2 + when 'low' then 3 + else 4 + end asc, + coalesce(file_path, '') asc, + coalesce(line, 2147483647) asc, + title asc`, + [args.runId], + ).map(mapFindingRow); + const artifacts = db.all( + "select * from review_run_artifacts where run_id = ? order by created_at asc", + [args.runId], + ).map(mapArtifactRow); + const chatSession = run.chatSessionId + ? await agentChatService.getSessionSummary(run.chatSessionId).catch(() => null) + : null; + return { + ...run, + findings, + artifacts, + chatSession, + }; + } + + return { + listLaunchContext, + startRun, + rerun, + listRuns, + getRunDetail, + }; +} diff --git a/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts b/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts new file mode 100644 index 000000000..330e4d24f --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts @@ -0,0 +1,194 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReviewRunConfig } from "../../../shared/types"; + +const mockGit = vi.hoisted(() => ({ + runGit: vi.fn(), + runGitOrThrow: vi.fn(), +})); + +vi.mock("../git/git", () => ({ + runGit: (...args: unknown[]) => mockGit.runGit(...args), + runGitOrThrow: (...args: unknown[]) => mockGit.runGitOrThrow(...args), +})); + +import { createReviewTargetMaterializer } from "./reviewTargetMaterializer"; + +function makeConfig(overrides: Partial = {}): ReviewRunConfig { + return { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: "openai/gpt-5.4-codex", + reasoningEffort: "medium", + budgets: { + maxFiles: 60, + maxDiffChars: 180_000, + maxPromptChars: 220_000, + maxFindings: 12, + }, + publishBehavior: "local_only", + ...overrides, + }; +} + +describe("reviewTargetMaterializer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("materializes lane vs default branch full diff", async () => { + const laneService = { + getLaneBaseAndBranch: vi.fn().mockImplementation((laneId: string) => ({ + baseRef: "main", + branchRef: laneId === "lane-review" ? "feature/review-tab" : "bugfix/review-engine", + worktreePath: "/tmp/lane-review", + laneType: "worktree", + })), + list: vi.fn(), + } as any; + mockGit.runGitOrThrow + .mockResolvedValueOnce("merge-base-sha\n") + .mockResolvedValueOnce("diff --git a/src/review.ts b/src/review.ts\n@@ -10,2 +10,3 @@\n line\n+new line\n") + .mockResolvedValueOnce("M\tsrc/review.ts\n"); + + const materializer = createReviewTargetMaterializer({ laneService }); + const result = await materializer.materialize({ + target: { mode: "lane_diff", laneId: "lane-review" }, + config: makeConfig(), + }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["merge-base", "main", "feature/review-tab"], + expect.objectContaining({ cwd: "/tmp/lane-review" }), + ); + expect(result.targetLabel).toBe("feature/review-tab vs main"); + expect(result.compareTarget).toEqual({ + kind: "default_branch", + label: "main", + ref: "main", + laneId: null, + branchRef: "main", + }); + expect(result.changedFiles[0]?.filePath).toBe("src/review.ts"); + }); + + it("materializes lane vs another lane", async () => { + const laneService = { + getLaneBaseAndBranch: vi.fn().mockImplementation((laneId: string) => ({ + baseRef: "main", + branchRef: laneId === "lane-review" ? "feature/review-tab" : "bugfix/review-engine", + worktreePath: "/tmp/lane-review", + laneType: "worktree", + })), + list: vi.fn(), + } as any; + mockGit.runGitOrThrow + .mockResolvedValueOnce("merge-base-sha\n") + .mockResolvedValueOnce("diff --git a/src/engine.ts b/src/engine.ts\n@@ -20,2 +20,3 @@\n line\n+new line\n") + .mockResolvedValueOnce("M\tsrc/engine.ts\n"); + + const materializer = createReviewTargetMaterializer({ laneService }); + const result = await materializer.materialize({ + target: { mode: "lane_diff", laneId: "lane-review" }, + config: makeConfig({ + compareAgainst: { kind: "lane", laneId: "lane-bugfix" }, + }), + }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["merge-base", "bugfix/review-engine", "feature/review-tab"], + expect.objectContaining({ cwd: "/tmp/lane-review" }), + ); + expect(result.compareTarget).toEqual({ + kind: "lane", + label: "bugfix/review-engine", + ref: "bugfix/review-engine", + laneId: "lane-bugfix", + branchRef: "bugfix/review-engine", + }); + }); + + it("materializes a selected commit range", async () => { + const laneService = { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/review-tab", + worktreePath: "/tmp/lane-review", + laneType: "worktree", + }), + list: vi.fn(), + } as any; + mockGit.runGitOrThrow + .mockResolvedValueOnce("diff --git a/src/commit.ts b/src/commit.ts\n@@ -1,1 +1,2 @@\n+change\n") + .mockResolvedValueOnce("M\tsrc/commit.ts\n"); + + const materializer = createReviewTargetMaterializer({ laneService }); + const result = await materializer.materialize({ + target: { + mode: "commit_range", + laneId: "lane-review", + baseCommit: "abc123456789", + headCommit: "def456789012", + }, + config: makeConfig({ + selectionMode: "selected_commits", + }), + }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["diff", "--no-color", "--find-renames", "abc123456789..def456789012"], + expect.objectContaining({ cwd: "/tmp/lane-review" }), + ); + expect(result.targetLabel).toContain("abc1234..def4567"); + }); + + it("materializes staged, unstaged, and untracked working tree changes", async () => { + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-review-working-tree-")); + fs.mkdirSync(path.join(worktreePath, "src"), { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "src", "new-file.ts"), "export const untracked = true;\n", "utf8"); + + const laneService = { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/review-tab", + worktreePath, + laneType: "worktree", + }), + list: vi.fn(), + } as any; + mockGit.runGit + .mockResolvedValueOnce({ + exitCode: 0, + stdout: "diff --git a/src/staged.ts b/src/staged.ts\n@@ -1,1 +1,2 @@\n+staged change\n", + stderr: "", + }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: "diff --git a/src/unstaged.ts b/src/unstaged.ts\n@@ -1,1 +1,2 @@\n+unstaged change\n", + stderr: "", + }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: "M src/staged.ts\n M src/unstaged.ts\n?? src/new-file.ts\n", + stderr: "", + }); + + const materializer = createReviewTargetMaterializer({ laneService }); + const result = await materializer.materialize({ + target: { mode: "working_tree", laneId: "lane-review" }, + config: makeConfig({ + selectionMode: "dirty_only", + dirtyOnly: true, + }), + }); + + expect(result.fullPatchText).toContain("## Staged changes"); + expect(result.fullPatchText).toContain("## Unstaged changes"); + expect(result.fullPatchText).toContain("## Untracked files"); + expect(result.artifacts.some((artifact) => artifact.artifactType === "untracked_snapshot")).toBe(true); + expect(result.changedFiles.some((file) => file.filePath === "src/new-file.ts")).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts b/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts new file mode 100644 index 000000000..733a49bb3 --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts @@ -0,0 +1,343 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { + ReviewResolvedCompareTarget, + ReviewRunArtifact, + ReviewRunConfig, + ReviewTarget, +} from "../../../shared/types"; +import type { createLaneService } from "../lanes/laneService"; +import { runGit, runGitOrThrow } from "../git/git"; + +type ReviewMaterializedFile = { + filePath: string; + excerpt: string; + lineNumbers: number[]; +}; + +export type ReviewMaterializedTarget = { + targetLabel: string; + compareTarget: ReviewResolvedCompareTarget | null; + fullPatchText: string; + changedFiles: ReviewMaterializedFile[]; + artifacts: Array>; +}; + +type LaneInfo = ReturnType["getLaneBaseAndBranch"]>; + +function normalizeBranchRef(ref: string): string { + return ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\//, ""); +} + +function truncateText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n...(truncated)...\n`; +} + +function readTextFileSafe(absPath: string, maxBytes: number): { exists: boolean; text: string; isBinary: boolean } { + try { + const stat = fs.statSync(absPath); + if (!stat.isFile()) return { exists: false, text: "", isBinary: false }; + const buffer = fs.readFileSync(absPath); + if (buffer.includes(0)) { + return { exists: true, text: "", isBinary: true }; + } + const text = buffer.toString("utf8"); + return { exists: true, text: truncateText(text, maxBytes), isBinary: false }; + } catch { + return { exists: false, text: "", isBinary: false }; + } +} + +function parseNameStatus(stdout: string): string[] { + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const parts = line.split("\t").filter(Boolean); + if (parts.length >= 3 && /^R\d+$/i.test(parts[0] ?? "")) { + return parts[2] ?? ""; + } + return parts.at(-1) ?? ""; + }) + .filter(Boolean); +} + +function parseDiffFiles(patchText: string, fallbackPaths: string[]): ReviewMaterializedFile[] { + const byPath = new Map }>(); + for (const fallbackPath of fallbackPaths) { + if (!byPath.has(fallbackPath)) { + byPath.set(fallbackPath, { lines: [], lineNumbers: new Set() }); + } + } + + const lines = patchText.split(/\r?\n/); + let currentPath: string | null = null; + let currentNewLine: number | null = null; + + const ensureEntry = (filePath: string) => { + const existing = byPath.get(filePath); + if (existing) return existing; + const created = { lines: [] as string[], lineNumbers: new Set() }; + byPath.set(filePath, created); + return created; + }; + + for (const line of lines) { + const diffMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + if (diffMatch) { + const oldPath = diffMatch[1] ?? ""; + const newPath = diffMatch[2] ?? ""; + currentPath = newPath === "/dev/null" ? oldPath : newPath; + currentNewLine = null; + continue; + } + if (!currentPath) continue; + const entry = ensureEntry(currentPath); + entry.lines.push(line); + + const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunkMatch) { + currentNewLine = Number(hunkMatch[1] ?? "0"); + continue; + } + if (currentNewLine == null) continue; + + if (line.startsWith("+") && !line.startsWith("+++")) { + entry.lineNumbers.add(currentNewLine); + currentNewLine += 1; + continue; + } + if (line.startsWith("-") && !line.startsWith("---")) { + continue; + } + if (!line.startsWith("\\")) { + entry.lineNumbers.add(currentNewLine); + currentNewLine += 1; + } + } + + return Array.from(byPath.entries()).map(([filePath, entry]) => ({ + filePath, + excerpt: truncateText(entry.lines.join("\n").trim(), 4_000), + lineNumbers: Array.from(entry.lineNumbers).sort((a, b) => a - b), + })); +} + +async function resolveDefaultCompareTarget(lane: LaneInfo): Promise { + const branchRef = normalizeBranchRef(lane.branchRef); + const baseRef = normalizeBranchRef(lane.baseRef); + if (lane.laneType === "primary") { + const upstreamRef = `${branchRef}@{upstream}`; + const upstreamRes = await runGit(["rev-parse", "--verify", upstreamRef], { + cwd: lane.worktreePath, + timeoutMs: 5_000, + }); + if (upstreamRes.exitCode === 0 && upstreamRes.stdout.trim()) { + return { + kind: "default_branch", + label: upstreamRef, + ref: upstreamRef, + laneId: null, + branchRef: upstreamRef, + }; + } + const originRef = `origin/${branchRef}`; + const originRes = await runGit(["rev-parse", "--verify", originRef], { + cwd: lane.worktreePath, + timeoutMs: 5_000, + }); + if (originRes.exitCode === 0 && originRes.stdout.trim()) { + return { + kind: "default_branch", + label: originRef, + ref: originRef, + laneId: null, + branchRef: originRef, + }; + } + } + return { + kind: "default_branch", + label: baseRef, + ref: baseRef, + laneId: null, + branchRef: baseRef, + }; +} + +function buildDiffArtifact(contentText: string): Omit { + return { + artifactType: "diff_bundle", + title: "Diff bundle", + mimeType: "text/plain", + contentText, + metadata: null, + }; +} + +export function createReviewTargetMaterializer({ + laneService, +}: { + laneService: Pick, "getLaneBaseAndBranch" | "list">; +}) { + async function materializeLaneDiff(args: { + target: Extract; + config: ReviewRunConfig; + }): Promise { + const lane = laneService.getLaneBaseAndBranch(args.target.laneId); + const sourceRef = normalizeBranchRef(lane.branchRef); + let compareTarget = await resolveDefaultCompareTarget(lane); + if (args.config.compareAgainst.kind === "lane") { + const compareLane = laneService.getLaneBaseAndBranch(args.config.compareAgainst.laneId); + compareTarget = { + kind: "lane", + label: normalizeBranchRef(compareLane.branchRef), + ref: normalizeBranchRef(compareLane.branchRef), + laneId: args.config.compareAgainst.laneId, + branchRef: normalizeBranchRef(compareLane.branchRef), + }; + } + + const mergeBase = await runGitOrThrow(["merge-base", compareTarget.ref ?? "HEAD", sourceRef], { + cwd: lane.worktreePath, + timeoutMs: 10_000, + }).then((stdout) => stdout.trim()); + const patchText = await runGitOrThrow(["diff", "--no-color", "--find-renames", `${mergeBase}..${sourceRef}`], { + cwd: lane.worktreePath, + timeoutMs: 30_000, + maxOutputBytes: 8 * 1024 * 1024, + }); + const nameStatus = await runGitOrThrow(["diff", "--name-status", "--find-renames", `${mergeBase}..${sourceRef}`], { + cwd: lane.worktreePath, + timeoutMs: 15_000, + maxOutputBytes: 2 * 1024 * 1024, + }); + const changedFiles = parseDiffFiles(patchText, parseNameStatus(nameStatus)); + + return { + targetLabel: `${sourceRef} vs ${compareTarget.label}`, + compareTarget, + fullPatchText: patchText, + changedFiles, + artifacts: [buildDiffArtifact(patchText)], + }; + } + + async function materializeCommitRange(args: { + target: Extract; + }): Promise { + const lane = laneService.getLaneBaseAndBranch(args.target.laneId); + const range = `${args.target.baseCommit}..${args.target.headCommit}`; + const patchText = await runGitOrThrow(["diff", "--no-color", "--find-renames", range], { + cwd: lane.worktreePath, + timeoutMs: 30_000, + maxOutputBytes: 8 * 1024 * 1024, + }); + const nameStatus = await runGitOrThrow(["diff", "--name-status", "--find-renames", range], { + cwd: lane.worktreePath, + timeoutMs: 15_000, + maxOutputBytes: 2 * 1024 * 1024, + }); + const changedFiles = parseDiffFiles(patchText, parseNameStatus(nameStatus)); + + return { + targetLabel: `${normalizeBranchRef(lane.branchRef)} ${args.target.baseCommit.slice(0, 7)}..${args.target.headCommit.slice(0, 7)}`, + compareTarget: null, + fullPatchText: patchText, + changedFiles, + artifacts: [buildDiffArtifact(patchText)], + }; + } + + async function materializeWorkingTree(args: { + target: Extract; + }): Promise { + const lane = laneService.getLaneBaseAndBranch(args.target.laneId); + const branchRef = normalizeBranchRef(lane.branchRef); + const stagedPatch = await runGit(["diff", "--cached", "--no-color", "--find-renames"], { + cwd: lane.worktreePath, + timeoutMs: 30_000, + maxOutputBytes: 8 * 1024 * 1024, + }); + const unstagedPatch = await runGit(["diff", "--no-color", "--find-renames"], { + cwd: lane.worktreePath, + timeoutMs: 30_000, + maxOutputBytes: 8 * 1024 * 1024, + }); + const statusResult = await runGit(["status", "--porcelain=v1"], { + cwd: lane.worktreePath, + timeoutMs: 10_000, + maxOutputBytes: 2 * 1024 * 1024, + }); + + const untrackedArtifacts: Array> = []; + const untrackedSections: string[] = []; + const fallbackPaths = new Set(); + const statusLines = statusResult.stdout + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean); + for (const line of statusLines) { + const code = line.slice(0, 2); + const rawPath = line.slice(3).trim(); + if (!rawPath) continue; + const normalizedPath = rawPath.includes("->") + ? rawPath.split("->").at(-1)?.trim() ?? rawPath + : rawPath; + fallbackPaths.add(normalizedPath); + if (code === "??") { + const absPath = path.join(lane.worktreePath, normalizedPath); + const file = readTextFileSafe(absPath, 128_000); + if (file.exists && !file.isBinary) { + const contentText = file.text; + untrackedSections.push(`### Untracked file: ${normalizedPath}\n${contentText}`); + untrackedArtifacts.push({ + artifactType: "untracked_snapshot", + title: `Untracked: ${normalizedPath}`, + mimeType: "text/plain", + contentText, + metadata: { filePath: normalizedPath }, + }); + } + } + } + + const sections: string[] = []; + if (stagedPatch.stdout.trim()) { + sections.push(`## Staged changes\n${stagedPatch.stdout.trim()}`); + } + if (unstagedPatch.stdout.trim()) { + sections.push(`## Unstaged changes\n${unstagedPatch.stdout.trim()}`); + } + if (untrackedSections.length > 0) { + sections.push(`## Untracked files\n${untrackedSections.join("\n\n")}`); + } + const fullPatchText = sections.join("\n\n").trim(); + const changedFiles = parseDiffFiles(fullPatchText, Array.from(fallbackPaths)); + + return { + targetLabel: `${branchRef} working tree`, + compareTarget: null, + fullPatchText, + changedFiles, + artifacts: [buildDiffArtifact(fullPatchText), ...untrackedArtifacts], + }; + } + + return { + async materialize(args: { target: ReviewTarget; config: ReviewRunConfig }): Promise { + if (args.target.mode === "lane_diff") { + return materializeLaneDiff({ + target: args.target, + config: args.config, + }); + } + if (args.target.mode === "commit_range") { + return materializeCommitRange({ target: args.target }); + } + return materializeWorkingTree({ target: args.target }); + }, + }; +} diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index d5d0aa728..51b768f89 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -2959,6 +2959,69 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { db.run("create index if not exists idx_budget_usage_records_week on budget_usage_records(week_key)"); db.run("create index if not exists idx_budget_usage_records_provider_week on budget_usage_records(provider, week_key)"); + // Local review history for Review tab runs. + db.run(` + create table if not exists review_runs ( + id text primary key, + project_id text not null, + lane_id text not null, + target_json text not null, + config_json text not null, + target_label text not null, + compare_target_json text, + status text not null, + summary text, + error_message text, + finding_count integer not null default 0, + severity_summary_json text, + chat_session_id text, + created_at text not null, + started_at text not null, + ended_at text, + updated_at text not null, + foreign key(project_id) references projects(id), + foreign key(lane_id) references lanes(id) + ) + `); + db.run("create index if not exists idx_review_runs_project_created on review_runs(project_id, created_at desc)"); + db.run("create index if not exists idx_review_runs_lane_created on review_runs(lane_id, created_at desc)"); + db.run("create index if not exists idx_review_runs_project_status on review_runs(project_id, status)"); + + db.run(` + create table if not exists review_findings ( + id text primary key, + run_id text not null, + title text not null, + severity text not null, + body text not null, + confidence real not null default 0.5, + evidence_json text, + file_path text, + line integer, + anchor_state text not null, + source_pass text not null, + publication_state text not null, + foreign key(run_id) references review_runs(id) on delete cascade + ) + `); + db.run("create index if not exists idx_review_findings_run on review_findings(run_id)"); + db.run("create index if not exists idx_review_findings_run_file on review_findings(run_id, file_path, line)"); + + db.run(` + create table if not exists review_run_artifacts ( + id text primary key, + run_id text not null, + artifact_type text not null, + title text not null, + mime_type text not null, + content_text text, + metadata_json text, + created_at text not null, + foreign key(run_id) references review_runs(id) on delete cascade + ) + `); + db.run("create index if not exists idx_review_run_artifacts_run on review_run_artifacts(run_id, created_at)"); + // PR convergence loop: issue inventory tracking db.run(` create table if not exists pr_issue_inventory ( diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index e09139fd0..0c88aef62 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -106,6 +106,12 @@ import type { AutomationSaveDraftResult, AutomationSimulateRequest, AutomationSimulateResult, + ReviewEventPayload, + ReviewLaunchContext, + ReviewListRunsArgs, + ReviewRun, + ReviewRunDetail, + ReviewStartRunArgs, UsageSnapshot, BudgetCheckResult, BudgetCapScope, @@ -744,6 +750,14 @@ declare global { ) => Promise; onEvent: (cb: (ev: AutomationsEventPayload) => void) => () => void; }; + review: { + listLaunchContext: () => Promise; + listRuns: (args?: ReviewListRunsArgs) => Promise; + getRunDetail: (runId: string) => Promise; + startRun: (args: ReviewStartRunArgs) => Promise; + rerun: (runId: string) => Promise; + onEvent: (cb: (ev: ReviewEventPayload) => void) => () => void; + }; usage: { getSnapshot: () => Promise; refresh: () => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 0c3a87db2..fee272729 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -76,4 +76,51 @@ describe("preload OAuth bridge", () => { unsubscribe(); expect(removeListener).toHaveBeenCalledWith(IPC.lanesOAuthEvent, listener); }); + + it("exposes review IPC methods and cleans up listeners", async () => { + const invoke = vi.fn(async () => undefined); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await bridge.review.listLaunchContext(); + await bridge.review.listRuns({ laneId: "lane-1", limit: 5 }); + await bridge.review.getRunDetail("run-1"); + await bridge.review.startRun({ target: { mode: "lane_diff", laneId: "lane-1" } }); + await bridge.review.rerun("run-1"); + + expect(invoke).toHaveBeenCalledWith(IPC.reviewListLaunchContext); + expect(invoke).toHaveBeenCalledWith(IPC.reviewListRuns, { laneId: "lane-1", limit: 5 }); + expect(invoke).toHaveBeenCalledWith(IPC.reviewGetRunDetail, { runId: "run-1" }); + expect(invoke).toHaveBeenCalledWith(IPC.reviewStartRun, { target: { mode: "lane_diff", laneId: "lane-1" } }); + expect(invoke).toHaveBeenCalledWith(IPC.reviewRerun, { runId: "run-1" }); + + const callback = vi.fn(); + const unsubscribe = bridge.review.onEvent(callback); + expect(on).toHaveBeenCalledWith(IPC.reviewEvent, expect.any(Function)); + + const listener = on.mock.calls.at(-1)?.[1]; + expect(typeof listener).toBe("function"); + listener({}, { type: "runs-updated", runId: "run-1", status: "completed" }); + expect(callback).toHaveBeenCalledWith({ type: "runs-updated", runId: "run-1", status: "completed" }); + + unsubscribe(); + expect(removeListener).toHaveBeenCalledWith(IPC.reviewEvent, listener); + }); }); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c33a8ad83..058392e53 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -32,6 +32,12 @@ import type { AutomationSaveDraftResult, AutomationSimulateRequest, AutomationSimulateResult, + ReviewEventPayload, + ReviewLaunchContext, + ReviewListRunsArgs, + ReviewRun, + ReviewRunDetail, + ReviewStartRunArgs, AiApiKeyVerificationResult, AiConfig, AiSettingsStatus, @@ -855,6 +861,23 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.automationsEvent, listener); }, }, + review: { + listLaunchContext: async (): Promise => + ipcRenderer.invoke(IPC.reviewListLaunchContext), + listRuns: async (args: ReviewListRunsArgs = {}): Promise => + ipcRenderer.invoke(IPC.reviewListRuns, args), + getRunDetail: async (runId: string): Promise => + ipcRenderer.invoke(IPC.reviewGetRunDetail, { runId }), + startRun: async (args: ReviewStartRunArgs): Promise => + ipcRenderer.invoke(IPC.reviewStartRun, args), + rerun: async (runId: string): Promise => + ipcRenderer.invoke(IPC.reviewRerun, { runId }), + onEvent: (cb: (ev: ReviewEventPayload) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: ReviewEventPayload) => cb(payload); + ipcRenderer.on(IPC.reviewEvent, listener); + return () => ipcRenderer.removeListener(IPC.reviewEvent, listener); + } + }, usage: { getSnapshot: async (): Promise => ipcRenderer.invoke(IPC.usageGetSnapshot), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index c6006d92d..13a31611b 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2000,6 +2000,218 @@ if (typeof window !== "undefined" && !(window as any).ade) { }), onEvent: noop, }, + review: { + listLaunchContext: resolved({ + defaultLaneId: MOCK_LANES[1]?.id ?? MOCK_LANES[0]?.id ?? null, + defaultBranchName: "main", + lanes: MOCK_LANES.map((lane) => ({ + id: lane.id, + name: lane.name, + laneType: lane.laneType, + branchRef: lane.branchRef, + baseRef: lane.baseRef, + color: lane.color ?? null, + })), + recentCommitsByLane: Object.fromEntries( + MOCK_LANES.map((lane) => [lane.id, [ + { + sha: "abc1234567890", + shortSha: "abc1234", + subject: `Recent work on ${lane.name}`, + authoredAt: now, + pushed: false, + }, + { + sha: "def4567890123", + shortSha: "def4567", + subject: `Follow-up fix on ${lane.name}`, + authoredAt: yesterday, + pushed: true, + }, + ]]), + ), + recommendedModelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, + }), + listRuns: resolvedArg([ + { + id: "review-run-1", + projectId: MOCK_PROJECT.id, + laneId: MOCK_LANES[1]?.id ?? "lane-auth", + target: { mode: "lane_diff", laneId: MOCK_LANES[1]?.id ?? "lane-auth" }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, + reasoningEffort: "medium", + budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + publishBehavior: "local_only", + }, + targetLabel: "feature/auth-flow vs main", + compareTarget: { kind: "default_branch", label: "main", ref: "main", laneId: null, branchRef: "main" }, + status: "completed", + summary: "Found two actionable risks in the auth flow changes.", + errorMessage: null, + findingCount: 2, + severitySummary: { critical: 0, high: 1, medium: 1, low: 0, info: 0 }, + chatSessionId: "chat-review-1", + createdAt: yesterday, + startedAt: yesterday, + endedAt: now, + updatedAt: now, + }, + ]), + getRunDetail: resolvedArg({ + id: "review-run-1", + projectId: MOCK_PROJECT.id, + laneId: MOCK_LANES[1]?.id ?? "lane-auth", + target: { mode: "lane_diff", laneId: MOCK_LANES[1]?.id ?? "lane-auth" }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, + reasoningEffort: "medium", + budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + publishBehavior: "local_only", + }, + targetLabel: "feature/auth-flow vs main", + compareTarget: { kind: "default_branch", label: "main", ref: "main", laneId: null, branchRef: "main" }, + status: "completed", + summary: "Found two actionable risks in the auth flow changes.", + errorMessage: null, + findingCount: 2, + severitySummary: { critical: 0, high: 1, medium: 1, low: 0, info: 0 }, + chatSessionId: "chat-review-1", + createdAt: yesterday, + startedAt: yesterday, + endedAt: now, + updatedAt: now, + findings: [ + { + id: "finding-1", + runId: "review-run-1", + title: "Missing rollback when PKCE token exchange fails", + severity: "high", + body: "The new auth path persists session state before the token exchange completes, which can leave the lane in a partially authenticated state after a failed callback.", + confidence: 0.83, + evidence: [ + { + kind: "diff_hunk", + summary: "Session write happens before token exchange success is confirmed.", + filePath: "src/auth/oauth.ts", + line: 128, + quote: "saveSession(session);", + artifactId: null, + }, + ], + filePath: "src/auth/oauth.ts", + line: 128, + anchorState: "anchored", + sourcePass: "single_pass", + publicationState: "local_only", + }, + { + id: "finding-2", + runId: "review-run-1", + title: "Callback route still lacks regression coverage", + severity: "medium", + body: "The diff updates the callback branching logic but does not add coverage for the rejected-code path, so the new behavior can regress without detection.", + confidence: 0.68, + evidence: [], + filePath: "src/auth/oauth.test.ts", + line: null, + anchorState: "file_only", + sourcePass: "single_pass", + publicationState: "local_only", + }, + ], + artifacts: [ + { + id: "artifact-review-diff-1", + runId: "review-run-1", + artifactType: "diff_bundle", + title: "Diff bundle", + mimeType: "text/plain", + contentText: "diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts\n@@ ...", + metadata: null, + createdAt: now, + }, + ], + chatSession: { + sessionId: "chat-review-1", + laneId: MOCK_LANES[1]?.id ?? "lane-auth", + provider: "codex", + model: "GPT-5.4 Codex", + modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, + title: "Review: feature/auth-flow vs main", + surface: "automation", + automationId: null, + automationRunId: null, + status: "idle", + startedAt: yesterday, + endedAt: now, + lastActivityAt: now, + lastOutputPreview: "Found two actionable risks in the auth flow changes.", + summary: "Saved review transcript for local diff review.", + }, + }), + startRun: resolvedArg({ + id: "review-run-queued", + projectId: MOCK_PROJECT.id, + laneId: MOCK_LANES[1]?.id ?? "lane-auth", + target: { mode: "lane_diff", laneId: MOCK_LANES[1]?.id ?? "lane-auth" }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, + reasoningEffort: "medium", + budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + publishBehavior: "local_only", + }, + targetLabel: "feature/auth-flow review", + compareTarget: null, + status: "queued", + summary: null, + errorMessage: null, + findingCount: 0, + severitySummary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, + chatSessionId: null, + createdAt: now, + startedAt: now, + endedAt: null, + updatedAt: now, + }), + rerun: resolvedArg({ + id: "review-run-rerun", + projectId: MOCK_PROJECT.id, + laneId: MOCK_LANES[1]?.id ?? "lane-auth", + target: { mode: "lane_diff", laneId: MOCK_LANES[1]?.id ?? "lane-auth" }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: DEFAULT_BROWSER_MOCK_CODEX_MODEL, + reasoningEffort: "medium", + budgets: { maxFiles: 60, maxDiffChars: 180000, maxPromptChars: 220000, maxFindings: 12 }, + publishBehavior: "local_only", + }, + targetLabel: "feature/auth-flow review", + compareTarget: null, + status: "queued", + summary: null, + errorMessage: null, + findingCount: 0, + severitySummary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, + chatSessionId: null, + createdAt: now, + startedAt: now, + endedAt: null, + updatedAt: now, + }), + onEvent: noop, + }, missions: { list: resolved([]), get: resolvedArg(null), diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 172f9cd47..3bae1627b 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -30,6 +30,9 @@ const TerminalsPage = React.lazy(() => const PRsPage = React.lazy(() => import("../prs/PRsPage").then((m) => ({ default: m.PRsPage })) ); +const ReviewPage = React.lazy(() => + import("../review/ReviewPage").then((m) => ({ default: m.ReviewPage })) +); const HistoryPage = React.lazy(() => import("../history/HistoryPage").then((m) => ({ default: m.HistoryPage })) ); @@ -213,6 +216,7 @@ export function App() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx new file mode 100644 index 000000000..1d87ce3d2 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -0,0 +1,60 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { TabNav } from "./TabNav"; +import { useAppStore } from "../../state/appStore"; + +function resetStore() { + useAppStore.setState({ + project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, + projectHydrated: true, + showWelcome: false, + selectedLaneId: "lane-1", + runLaneId: null, + focusedSessionId: null, + lanes: [], + laneInspectorTabs: {}, + terminalAttention: { + runningCount: 0, + activeCount: 0, + needsAttentionCount: 0, + indicator: "none", + byLaneId: {}, + }, + workViewByProject: {}, + laneWorkViewByScope: {}, + }); +} + +describe("TabNav", () => { + const originalAde = globalThis.window.ade; + + beforeEach(() => { + resetStore(); + globalThis.window.ade = { + app: { + revealPath: async () => undefined, + }, + } as any; + }); + + afterEach(() => { + globalThis.window.ade = originalAde; + }); + + it("places Review directly below PRs in the sidebar", () => { + render( + + + , + ); + + const prs = screen.getByRole("link", { name: "PRs" }); + const review = screen.getByRole("link", { name: "Review" }); + expect(prs.nextElementSibling).toBe(review); + }); +}); + diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index c78daacb0..1a494b5cb 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -7,6 +7,7 @@ import { Terminal, Graph, GitPullRequest, + MagnifyingGlass, ClockCounterClockwise, Robot, Strategy, @@ -25,6 +26,7 @@ const mainItems = [ { to: "/project", label: "Run", icon: PlayCircle }, { to: "/graph", label: "Graph", icon: Graph }, { to: "/prs", label: "PRs", icon: GitPullRequest }, + { to: "/review", label: "Review", icon: MagnifyingGlass }, { to: "/history", label: "History", icon: ClockCounterClockwise }, { to: "/automations", label: "Automations", icon: Robot }, { to: "/missions", label: "Missions", icon: Strategy }, diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx new file mode 100644 index 000000000..1c69b8d10 --- /dev/null +++ b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx @@ -0,0 +1,337 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { ReviewPage } from "./ReviewPage"; +import { useAppStore } from "../../state/appStore"; +import { AgentChatPane } from "../chat/AgentChatPane"; + +vi.mock("../ui/PaneTilingLayout", () => ({ + PaneTilingLayout: ({ panes }: { panes: Record }) => ( +
+ {Object.entries(panes).map(([id, pane]) => ( +
+ {pane.children} +
+ ))} +
+ ), +})); + +vi.mock("../chat/AgentChatPane", () => ({ + AgentChatPane: vi.fn(() =>
), +})); + +function LocationProbe() { + const location = useLocation(); + return
{location.search}
; +} + +function FilesProbe() { + const location = useLocation(); + const state = (location.state ?? null) as { openFilePath?: string; laneId?: string } | null; + return ( +
+ {location.pathname}|{state?.laneId ?? "no-lane"}|{state?.openFilePath ?? "no-file"} +
+ ); +} + +function resetStore() { + useAppStore.setState({ + project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, + projectHydrated: true, + showWelcome: false, + selectedLaneId: "lane-review", + runLaneId: null, + focusedSessionId: null, + lanes: [ + { id: "lane-review", name: "feature/review-tab", branchRef: "refs/heads/feature/review-tab", baseRef: "main", laneType: "worktree", color: null, worktreePath: "/Users/arul/ADE", status: {} as any }, + { id: "lane-bugfix", name: "bugfix/review-engine", branchRef: "refs/heads/bugfix/review-engine", baseRef: "main", laneType: "worktree", color: null, worktreePath: "/Users/arul/ADE-bugfix", status: {} as any }, + ] as any, + laneInspectorTabs: {}, + terminalAttention: { + runningCount: 0, + activeCount: 0, + needsAttentionCount: 0, + indicator: "none", + byLaneId: {}, + }, + workViewByProject: {}, + laneWorkViewByScope: {}, + }); +} + +describe("ReviewPage", () => { + const originalAde = globalThis.window.ade; + + beforeEach(() => { + resetStore(); + (AgentChatPane as unknown as { mockClear?: () => void }).mockClear?.(); + const run1 = { + id: "run-1", + projectId: "project-1", + laneId: "lane-review", + status: "completed", + targetLabel: "feature/review-tab vs main", + compareTarget: { kind: "default_branch", label: "main", ref: "main", laneId: null, branchRef: "main" }, + target: { + mode: "lane_diff", + laneId: "lane-review", + }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: "openai/gpt-5.4-codex", + reasoningEffort: "medium", + budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8 }, + publishBehavior: "local_only", + }, + summary: "Reviewed against default branch", + errorMessage: null, + findingCount: 1, + severitySummary: { critical: 0, high: 0, medium: 1, low: 0, info: 0 }, + chatSessionId: "session-1", + createdAt: "2026-04-02T12:00:00.000Z", + startedAt: "2026-04-02T12:01:00.000Z", + endedAt: "2026-04-02T12:05:00.000Z", + updatedAt: "2026-04-02T12:05:00.000Z", + } as const; + const run2 = { + id: "run-2", + projectId: "project-1", + laneId: "lane-bugfix", + status: "completed", + targetLabel: "bugfix/review-engine vs feature/review-tab", + compareTarget: { kind: "lane", laneId: "lane-review", label: "feature/review-tab", ref: "feature/review-tab", branchRef: "feature/review-tab" }, + target: { + mode: "lane_diff", + laneId: "lane-bugfix", + }, + config: { + compareAgainst: { kind: "lane", laneId: "lane-review" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: "openai/gpt-5.4-codex", + reasoningEffort: "high", + budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8 }, + publishBehavior: "local_only", + }, + summary: "Reviewed lane-to-lane diff", + errorMessage: null, + findingCount: 2, + severitySummary: { critical: 0, high: 1, medium: 1, low: 0, info: 0 }, + chatSessionId: "session-2", + createdAt: "2026-04-03T12:00:00.000Z", + startedAt: "2026-04-03T12:01:00.000Z", + endedAt: "2026-04-03T12:05:00.000Z", + updatedAt: "2026-04-03T12:05:00.000Z", + } as const; + const run3 = { + id: "run-3", + projectId: "project-1", + laneId: "lane-review", + status: "queued", + targetLabel: "feature/review-tab vs main", + compareTarget: { kind: "default_branch", label: "main", ref: "main", laneId: null, branchRef: "main" }, + target: { + mode: "lane_diff", + laneId: "lane-review", + }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: "openai/gpt-5.4-codex", + reasoningEffort: "medium", + budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8 }, + publishBehavior: "local_only", + }, + summary: null, + errorMessage: null, + findingCount: 0, + severitySummary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, + chatSessionId: null, + createdAt: "2026-04-04T12:00:00.000Z", + startedAt: "2026-04-04T12:00:00.000Z", + endedAt: null, + updatedAt: "2026-04-04T12:00:00.000Z", + } as const; + let runs: any[] = [run1, run2]; + const details = new Map([ + ["run-1", { + ...run1, + findings: [], + artifacts: [], + chatSession: { + sessionId: "session-1", + laneId: "lane-review", + provider: "codex", + model: "gpt-5.4-codex", + status: "active", + startedAt: "2026-04-02T12:01:00.000Z", + endedAt: null, + lastActivityAt: "2026-04-02T12:05:00.000Z", + lastOutputPreview: "Review transcript", + summary: "Review transcript", + title: "Review transcript", + }, + }], + ["run-2", { + ...run2, + findings: [ + { + id: "finding-1", + runId: "run-2", + title: "Missing guard on empty result", + severity: "high", + body: "The branch can surface a blank state instead of the expected fallback.", + confidence: 0.92, + evidence: [{ kind: "diff_hunk", summary: "@@ -12,6 +12,8 @@", filePath: "src/review/run.ts", line: 42, quote: "+ return null;", artifactId: null }], + filePath: "src/review/run.ts", + line: 42, + anchorState: "anchored", + sourcePass: "single_pass", + publicationState: "local_only", + }, + ], + artifacts: [ + { id: "artifact-1", runId: "run-2", artifactType: "diff_bundle", title: "Captured diff", mimeType: "text/plain", contentText: "diff --git a/src/review/run.ts b/src/review/run.ts", metadata: null, createdAt: "2026-04-03T12:03:00.000Z" }, + ], + chatSession: { + sessionId: "session-2", + laneId: "lane-bugfix", + provider: "codex", + model: "gpt-5.4-codex", + status: "active", + startedAt: "2026-04-03T12:01:00.000Z", + endedAt: null, + lastActivityAt: "2026-04-03T12:05:00.000Z", + lastOutputPreview: "Review transcript", + summary: "Review transcript", + title: "Review transcript", + }, + }], + ["run-3", { + ...run3, + findings: [], + artifacts: [], + chatSession: null, + }], + ]); + globalThis.window.ade = { + app: { + openPathInEditor: vi.fn(async () => undefined), + }, + review: { + listLaunchContext: vi.fn(async () => ({ + lanes: [ + { id: "lane-review", name: "feature/review-tab", branchRef: "refs/heads/feature/review-tab", baseRef: "main", laneType: "worktree", color: null }, + { id: "lane-bugfix", name: "bugfix/review-engine", branchRef: "refs/heads/bugfix/review-engine", baseRef: "main", laneType: "worktree", color: null }, + ], + defaultLaneId: "lane-review", + defaultBranchName: "main", + recentCommitsByLane: { + "lane-review": [ + { sha: "abc123def4567890", shortSha: "abc123d", subject: "First commit", authoredAt: "2026-04-01T12:00:00.000Z", pushed: true }, + { sha: "def456abc1237890", shortSha: "def456a", subject: "Second commit", authoredAt: "2026-04-02T12:00:00.000Z", pushed: true }, + ], + }, + recommendedModelId: "openai/gpt-5.4-codex", + })), + listRuns: vi.fn(async () => runs), + getRunDetail: vi.fn(async (runId: string) => details.get(runId) ?? null), + startRun: vi.fn(async () => { + runs = [run3, ...runs.filter((run) => run.id !== "run-3")]; + return { runId: "run-3" }; + }), + rerun: vi.fn(async () => { + runs = [run3, ...runs.filter((run) => run.id !== "run-3")]; + return { runId: "run-3" }; + }), + onEvent: vi.fn(() => () => undefined), + }, + } as any; + }); + + afterEach(() => { + cleanup(); + globalThis.window.ade = originalAde; + }); + + it("loads a saved run from the query param and reruns it", async () => { + render( + + + } /> + } /> + + , + ); + + await waitFor(() => expect(screen.getByTestId("location-search").textContent).toContain("runId=run-2")); + expect(await screen.findByText("Reviewed lane-to-lane diff")).toBeTruthy(); + expect(await screen.findByText("Missing guard on empty result")).toBeTruthy(); + expect(AgentChatPane).toHaveBeenCalled(); + expect(AgentChatPane).toHaveBeenLastCalledWith( + expect.objectContaining({ + lockSessionId: "session-2", + presentation: expect.objectContaining({ mode: "resolver", assistantLabel: "Review" }), + }), + expect.anything(), + ); + + fireEvent.click(screen.getByRole("button", { name: /rerun/i })); + + await waitFor(() => expect((window.ade.review as any).rerun).toHaveBeenCalledWith("run-2")); + await waitFor(() => expect(screen.getByTestId("location-search").textContent).toContain("runId=run-3")); + }); + + it("opens findings in ADE files first and keeps the editor handoff secondary", async () => { + render( + + + } /> + } /> + + , + ); + + expect(await screen.findByText("Missing guard on empty result")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /open editor/i })); + await waitFor(() => expect(window.ade.app.openPathInEditor).toHaveBeenCalledWith({ + rootPath: "/Users/arul/ADE-bugfix", + target: "src/review/run.ts", + })); + + fireEvent.click(screen.getByRole("button", { name: /open in files/i })); + await waitFor(() => expect(screen.getByTestId("files-probe").textContent).toBe("/files|lane-bugfix|src/review/run.ts")); + }); + + it("starts a lane diff review against the default branch", async () => { + render( + + + } /> + + , + ); + + await waitFor(() => expect(screen.getAllByText("Launch review").length).toBeGreaterThan(0)); + fireEvent.click(screen.getByRole("button", { name: /start review/i })); + + await waitFor(() => expect((window.ade.review as any).startRun).toHaveBeenCalled()); + const [{ target, config }] = (window.ade.review as any).startRun.mock.calls[0]; + expect(target).toEqual({ mode: "lane_diff", laneId: "lane-review" }); + expect(config).toMatchObject({ + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + publishBehavior: "local_only", + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.tsx new file mode 100644 index 000000000..9289138d7 --- /dev/null +++ b/apps/desktop/src/renderer/components/review/ReviewPage.tsx @@ -0,0 +1,1092 @@ +import React from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { + ArrowsClockwise, + CaretDown, + ClockCounterClockwise, + GitBranch, + MagnifyingGlass, + Play, + Sparkle, + ArrowClockwise, + ArrowSquareOut, + FileText, +} from "@phosphor-icons/react"; +import { getDefaultModelDescriptor } from "../../../shared/modelRegistry"; +import type { LaneSummary } from "../../../shared/types"; +import { useAppStore } from "../../state/appStore"; +import { Button } from "../ui/Button"; +import { Chip } from "../ui/Chip"; +import { cn } from "../ui/cn"; +import { EmptyState } from "../ui/EmptyState"; +import { PaneTilingLayout, type PaneConfig, type PaneSplit } from "../ui/PaneTilingLayout"; +import { AgentChatPane } from "../chat/AgentChatPane"; +import { + listReviewLaunchContext, + listReviewRuns, + getReviewRunDetail, + onReviewEvent, + rerunReview, + startReviewRun, +} from "./reviewApi"; +import type { + ReviewArtifact, + ReviewEvidenceEntry, + ReviewFinding, + ReviewLaunchContext, + ReviewRun, + ReviewRunConfig, + ReviewRunDetail, + ReviewRunStatus, + ReviewTarget, + ReviewTargetMode, +} from "./reviewTypes"; +import { buildReviewSearch, readReviewRunId } from "./reviewRouteState"; + +const REVIEW_TILING_TREE: PaneSplit = { + type: "split", + direction: "horizontal", + children: [ + { node: { type: "pane", id: "launch" }, defaultSize: 36, minSize: 28 }, + { node: { type: "pane", id: "detail" }, defaultSize: 64, minSize: 28 }, + ], +}; + +type LaunchDraft = { + laneId: string; + targetMode: ReviewTargetMode; + compareKind: "default_branch" | "lane"; + compareLaneId: string; + baseCommit: string; + headCommit: string; + modelId: string; + reasoningEffort: string; + maxFiles: number; + maxDiffChars: number; + maxPromptChars: number; + maxFindings: number; +}; + +type NormalizedRun = ReviewRun; +type NormalizedDetail = ReviewRunDetail; + +function toReviewStatusTone(status: ReviewRunStatus): string { + switch (status) { + case "completed": + return "border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-300"; + case "running": + case "queued": + return "border-amber-400/20 bg-amber-400/[0.08] text-amber-300"; + case "failed": + return "border-red-400/20 bg-red-400/[0.08] text-red-300"; + case "cancelled": + return "border-zinc-500/20 bg-zinc-500/[0.08] text-zinc-300"; + default: + return "border-slate-400/20 bg-slate-400/[0.08] text-slate-300"; + } +} + +function toSeverityTone(severity: string): string { + const normalized = severity.toLowerCase(); + if (normalized.includes("crit")) return "border-red-400/25 bg-red-400/[0.10] text-red-200"; + if (normalized.includes("high")) return "border-orange-400/25 bg-orange-400/[0.10] text-orange-200"; + if (normalized.includes("medium")) return "border-amber-400/25 bg-amber-400/[0.10] text-amber-200"; + if (normalized.includes("low")) return "border-sky-400/25 bg-sky-400/[0.10] text-sky-200"; + return "border-zinc-400/20 bg-zinc-400/[0.08] text-zinc-200"; +} + +function formatTime(value: string | null | undefined): string { + if (!value) return "—"; + const ts = Date.parse(value); + if (Number.isNaN(ts)) return value; + return new Date(ts).toLocaleString(); +} + +function formatConfidence(value: number | string): string { + if (typeof value === "number") { + if (value <= 1) return `${Math.round(value * 100)}%`; + return `${Math.round(value)}%`; + } + return value; +} + +function normalizeEvidence(evidence: ReviewFinding["evidence"] | null | undefined): ReviewEvidenceEntry[] { + if (!evidence) return []; + return evidence.map((entry) => entry as ReviewEvidenceEntry); +} + +function normalizeRun(run: ReviewRun | Record): NormalizedRun { + const value = run as Record; + const nested = value.run && typeof value.run === "object" ? (value.run as Record) : null; + const target = (value.target ?? nested?.target) as ReviewTarget; + const config = (value.config ?? nested?.config) as ReviewRunConfig; + const severitySummary = value.severitySummary ?? nested?.severitySummary ?? nested?.severityCounts ?? null; + return { + id: String(value.id ?? nested?.id ?? ""), + projectId: String(value.projectId ?? nested?.projectId ?? ""), + laneId: String(value.laneId ?? nested?.laneId ?? target?.laneId ?? ""), + status: String(value.status ?? nested?.status ?? "queued") as ReviewRunStatus, + target, + config, + targetLabel: String(value.targetLabel ?? nested?.targetLabel ?? ""), + compareTarget: (value.compareTarget ?? nested?.compareTarget ?? null) as NormalizedRun["compareTarget"], + summary: (value.summary ?? nested?.summary ?? null) as string | null, + errorMessage: (value.errorMessage ?? value.error ?? nested?.errorMessage ?? nested?.error ?? null) as string | null, + findingCount: Number(value.findingCount ?? value.findingsCount ?? nested?.findingCount ?? nested?.findingsCount ?? 0), + severitySummary: (severitySummary ?? { critical: 0, high: 0, medium: 0, low: 0, info: 0 }) as NormalizedRun["severitySummary"], + chatSessionId: (value.chatSessionId ?? nested?.chatSessionId ?? null) as string | null, + createdAt: String(value.createdAt ?? nested?.createdAt ?? value.startedAt ?? nested?.startedAt ?? new Date().toISOString()), + startedAt: String(value.startedAt ?? nested?.startedAt ?? value.createdAt ?? nested?.createdAt ?? new Date().toISOString()), + endedAt: (value.endedAt ?? nested?.endedAt ?? value.completedAt ?? nested?.completedAt ?? null) as string | null, + updatedAt: String(value.updatedAt ?? nested?.updatedAt ?? value.endedAt ?? nested?.endedAt ?? value.createdAt ?? new Date().toISOString()), + }; +} + +function normalizeDetail(detail: ReviewRunDetail | Record): NormalizedDetail { + const value = detail as Record; + const run = normalizeRun(value); + const nested = value.run && typeof value.run === "object" ? (value.run as Record) : null; + const findings = (value.findings ?? nested?.findings ?? []) as ReviewFinding[]; + const artifacts = (value.artifacts ?? nested?.artifacts ?? []) as ReviewArtifact[]; + const chatSession = (value.chatSession ?? nested?.chatSession ?? null) as ReviewRunDetail["chatSession"]; + return { + ...run, + findings, + artifacts, + chatSession, + }; +} + +function laneDisplayName(lane: LaneSummary | null | undefined): string { + if (!lane) return "Unknown lane"; + return lane.name?.trim().length ? lane.name : lane.id; +} + +function MetaCard({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function SectionCard({ + title, + icon: Icon, + children, + action, +}: { + title: string; + icon: React.ElementType; + children: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+
+
+
+ +
+
+
{title}
+
+
+ {action} +
+
{children}
+
+ ); +} + +function formatTargetSummary(target: ReviewTarget, compareLabel?: string | null): string { + if (target.mode === "lane_diff") { + return compareLabel ? `Lane diff against ${compareLabel}` : "Lane diff against upstream / default branch"; + } + if (target.mode === "commit_range") { + return `Commit range ${target.baseCommit.slice(0, 7)}..${target.headCommit.slice(0, 7)}`; + } + return "Dirty working tree"; +} + +function describeRunTarget(run: Pick): string { + return run.targetLabel?.trim() || formatTargetSummary(run.target, run.compareTarget?.label ?? null); +} + +function isLaunchDraftComplete(draft: LaunchDraft): boolean { + if (!draft.laneId.trim()) return false; + if (draft.targetMode === "lane_diff" && draft.compareKind === "lane" && !draft.compareLaneId.trim()) return false; + if (draft.targetMode === "commit_range" && (!draft.baseCommit.trim() || !draft.headCommit.trim())) return false; + return true; +} + +function buildTargetConfig( + targetMode: ReviewTargetMode, + draft: LaunchDraft, +): { target: ReviewTarget; config: ReviewRunConfig } { + if (targetMode === "lane_diff") { + const compareAgainst: ReviewRunConfig["compareAgainst"] = draft.compareKind === "lane" + ? { kind: "lane", laneId: draft.compareLaneId || draft.laneId } + : { kind: "default_branch" }; + return { + target: { mode: "lane_diff", laneId: draft.laneId }, + config: { + compareAgainst, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: draft.modelId.trim(), + reasoningEffort: draft.reasoningEffort.trim() || null, + budgets: { + maxFiles: draft.maxFiles, + maxDiffChars: draft.maxDiffChars, + maxPromptChars: draft.maxPromptChars, + maxFindings: draft.maxFindings, + }, + publishBehavior: "local_only", + }, + }; + } + + if (targetMode === "commit_range") { + return { + target: { mode: "commit_range", laneId: draft.laneId, baseCommit: draft.baseCommit.trim(), headCommit: draft.headCommit.trim() }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "selected_commits", + dirtyOnly: false, + modelId: draft.modelId.trim(), + reasoningEffort: draft.reasoningEffort.trim() || null, + budgets: { + maxFiles: draft.maxFiles, + maxDiffChars: draft.maxDiffChars, + maxPromptChars: draft.maxPromptChars, + maxFindings: draft.maxFindings, + }, + publishBehavior: "local_only", + }, + }; + } + + return { + target: { mode: "working_tree", laneId: draft.laneId }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "dirty_only", + dirtyOnly: true, + modelId: draft.modelId.trim(), + reasoningEffort: draft.reasoningEffort.trim() || null, + budgets: { + maxFiles: draft.maxFiles, + maxDiffChars: draft.maxDiffChars, + maxPromptChars: draft.maxPromptChars, + maxFindings: draft.maxFindings, + }, + publishBehavior: "local_only", + }, + }; +} + +export function ReviewPage() { + const navigate = useNavigate(); + const location = useLocation(); + const [, setSearchParams] = useSearchParams(); + const lanes = useAppStore((s) => s.lanes ?? []); + const selectedLaneId = useAppStore((s) => s.selectedLaneId); + + const laneOptions = React.useMemo(() => lanes.filter((lane) => Boolean(lane?.id)), [lanes]); + const laneById = React.useMemo(() => new Map(laneOptions.map((lane) => [lane.id, lane])), [laneOptions]); + const defaultLaneId = selectedLaneId && laneById.has(selectedLaneId) ? selectedLaneId : laneOptions[0]?.id ?? null; + + const [launchContext, setLaunchContext] = React.useState(null); + const [runs, setRuns] = React.useState([]); + const [detail, setDetail] = React.useState(null); + const [loadingRuns, setLoadingRuns] = React.useState(false); + const [loadingDetail, setLoadingDetail] = React.useState(false); + const [loadingLaunch, setLoadingLaunch] = React.useState(false); + const [error, setError] = React.useState(null); + const [selectedRunId, setSelectedRunId] = React.useState(readReviewRunId(location.search)); + const [launching, setLaunching] = React.useState(false); + const [launchDraft, setLaunchDraft] = React.useState(() => ({ + laneId: defaultLaneId ?? "", + targetMode: "lane_diff", + compareKind: "default_branch", + compareLaneId: "", + baseCommit: "", + headCommit: "", + modelId: getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4-codex", + reasoningEffort: "medium", + maxFiles: 25, + maxDiffChars: 120_000, + maxPromptChars: 60_000, + maxFindings: 8, + })); + + const selectedLane = laneById.get(launchDraft.laneId) ?? laneById.get(defaultLaneId ?? "") ?? null; + const selectedDetail = React.useMemo( + () => (detail && detail.id === selectedRunId ? detail : null), + [detail, selectedRunId], + ); + const selectedRun = React.useMemo( + () => selectedDetail ?? (selectedRunId ? runs.find((run) => run.id === selectedRunId) ?? null : runs[0] ?? null), + [runs, selectedDetail, selectedRunId], + ); + const selectedRunLane = React.useMemo( + () => (selectedRun ? laneById.get(selectedRun.laneId) ?? null : null), + [laneById, selectedRun], + ); + + React.useEffect(() => { + if (!launchDraft.laneId && defaultLaneId) { + setLaunchDraft((prev) => ({ ...prev, laneId: defaultLaneId })); + } + }, [defaultLaneId, launchDraft.laneId]); + + React.useEffect(() => { + const nextRunId = readReviewRunId(location.search); + setSelectedRunId((current) => current === nextRunId ? current : nextRunId); + }, [location.search]); + + React.useEffect(() => { + if (selectedRunId === null && runs.length > 0) { + setSelectedRunId(runs[0]?.id ?? null); + } + }, [runs, selectedRunId]); + + React.useEffect(() => { + if (!selectedRunId || selectedDetail || runs.length === 0) return; + if (runs.some((run) => run.id === selectedRunId)) return; + setSelectedRunId(runs[0]?.id ?? null); + }, [runs, selectedDetail, selectedRunId]); + + React.useEffect(() => { + const nextSearch = buildReviewSearch(selectedRunId); + if (location.search === nextSearch) return; + void navigate({ pathname: location.pathname, search: nextSearch }, { replace: true }); + }, [location.pathname, location.search, navigate, selectedRunId]); + + const refreshLaunchContext = React.useCallback(async () => { + setLoadingLaunch(true); + try { + const next = await listReviewLaunchContext(); + setLaunchContext(next); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingLaunch(false); + } + }, []); + + const refreshRuns = React.useCallback(async (laneId?: string | null) => { + setLoadingRuns(true); + try { + const next = await listReviewRuns({ laneId: laneId ?? null, limit: 120 }); + const normalized = next.map((run) => normalizeRun(run)); + setRuns(normalized); + if (selectedRunId && normalized.some((run) => run.id === selectedRunId)) { + return normalized; + } + if (!selectedRunId && normalized.length > 0) { + setSelectedRunId(normalized[0]?.id ?? null); + } + return normalized; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return []; + } finally { + setLoadingRuns(false); + } + }, [selectedRunId]); + + const loadDetail = React.useCallback(async (runId: string | null) => { + if (!runId) { + setDetail(null); + return; + } + setLoadingDetail(true); + try { + const next = await getReviewRunDetail(runId); + setDetail(next ? normalizeDetail(next) : null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingDetail(false); + } + }, []); + + React.useEffect(() => { + void refreshLaunchContext(); + }, [refreshLaunchContext]); + + React.useEffect(() => { + void refreshRuns(); + }, [refreshRuns]); + + React.useEffect(() => { + void loadDetail(selectedRunId); + }, [loadDetail, selectedRunId]); + + React.useEffect(() => { + const unsub = onReviewEvent((event) => { + void refreshRuns(); + if (event.runId === selectedRunId) { + void loadDetail(selectedRunId); + } + }); + return () => { + try { + unsub(); + } catch { + // ignore bridge teardown issues + } + }; + }, [loadDetail, refreshRuns, selectedRunId]); + + React.useEffect(() => { + if (!launchContext?.defaultLaneId || launchDraft.laneId) return; + setLaunchDraft((prev) => ({ ...prev, laneId: launchContext.defaultLaneId ?? defaultLaneId ?? "" })); + }, [defaultLaneId, launchContext?.defaultLaneId, launchDraft.laneId]); + + React.useEffect(() => { + const laneCommits = launchContext?.recentCommitsByLane?.[launchDraft.laneId] ?? []; + if (!laneCommits.length) return; + setLaunchDraft((prev) => { + if (prev.targetMode !== "commit_range") return prev; + if (prev.baseCommit || prev.headCommit) return prev; + const [head, base] = laneCommits; + return { + ...prev, + baseCommit: base?.sha ?? head?.sha ?? "", + headCommit: head?.sha ?? "", + }; + }); + }, [launchContext?.recentCommitsByLane, launchDraft.laneId, launchDraft.targetMode]); + + const activeRuns = runs.filter((run) => run.status === "running" || run.status === "queued").length; + const totalFindings = runs.reduce((sum, run) => sum + (run.findingCount ?? 0), 0); + const launchReady = isLaunchDraftComplete(launchDraft); + + const handleSelectRun = React.useCallback((runId: string) => { + setSelectedRunId(runId); + setSearchParams((prev) => { + if (runId) prev.set("runId", runId); + else prev.delete("runId"); + return prev; + }); + }, [setSearchParams]); + + const handleLaunch = React.useCallback(async () => { + const lane = laneById.get(launchDraft.laneId) ?? null; + if (!lane) { + setError("Choose a lane before launching a review."); + return; + } + if (launchDraft.targetMode === "lane_diff" && launchDraft.compareKind === "lane" && !launchDraft.compareLaneId.trim()) { + setError("Choose another lane to compare against."); + return; + } + if (launchDraft.targetMode === "commit_range" && (!launchDraft.baseCommit.trim() || !launchDraft.headCommit.trim())) { + setError("Enter both the base and head commit for a commit-range review."); + return; + } + const { target, config } = buildTargetConfig(launchDraft.targetMode, launchDraft); + setLaunching(true); + setError(null); + try { + const result = await startReviewRun({ target, config }); + if (!result.runId) { + setError("Review launch did not return a run id."); + return; + } + const nextRunId = result.runId; + await refreshRuns(); + setSelectedRunId(nextRunId); + setSearchParams((prev) => { + prev.set("runId", nextRunId); + return prev; + }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLaunching(false); + } + }, [launchDraft, laneById, refreshRuns, setSearchParams]); + + const handleRerun = React.useCallback(async (run: NormalizedRun | null) => { + if (!run) return; + setLaunching(true); + setError(null); + try { + const result = await rerunReview(run.id); + if (!result.runId) { + setError("Review rerun did not return a new run id."); + return; + } + const nextRunId = result.runId; + await refreshRuns(); + setSelectedRunId(nextRunId); + setSearchParams((prev) => { + prev.set("runId", nextRunId); + return prev; + }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLaunching(false); + } + }, [refreshRuns, setSearchParams]); + + const updateDraft = React.useCallback((key: K, value: LaunchDraft[K]) => { + setLaunchDraft((prev) => ({ ...prev, [key]: value })); + }, []); + + const resolveFindingTarget = React.useCallback((finding: ReviewFinding): { laneId: string; target: string; rootPath: string | null } | null => { + const path = finding.filePath?.trim(); + const laneId = selectedRun?.laneId ?? launchDraft.laneId; + if (!path || !laneId) return null; + const lane = laneById.get(laneId) ?? null; + const rootPath = lane?.worktreePath ?? null; + const target = rootPath && path.startsWith(rootPath) + ? path.slice(rootPath.length).replace(/^\/+/, "") + : path.startsWith("/") ? path.replace(/^\//, "") : path; + return { laneId, target, rootPath }; + }, [laneById, launchDraft.laneId, selectedRun?.laneId]); + + const handleOpenFindingInFiles = React.useCallback((finding: ReviewFinding) => { + const resolved = resolveFindingTarget(finding); + if (!resolved) return; + void navigate("/files", { + state: { + openFilePath: resolved.target, + laneId: resolved.laneId, + }, + }); + }, [navigate, resolveFindingTarget]); + + const handleOpenFindingInEditor = React.useCallback((finding: ReviewFinding) => { + const resolved = resolveFindingTarget(finding); + if (!resolved?.rootPath) return; + const appBridge = (window as Window & { ade?: { app?: { openPathInEditor?: (arg: { rootPath: string; target: string }) => Promise } } }).ade?.app; + void (appBridge?.openPathInEditor?.({ rootPath: resolved.rootPath, target: resolved.target }) ?? Promise.resolve()).catch(() => {}); + }, [resolveFindingTarget]); + + const launchPane = ( +
+ void handleLaunch()} disabled={launching || !launchReady}> + + {launching ? "Launching" : "Start review"} + + )} + > +
+ + + + + {launchDraft.targetMode === "lane_diff" ? ( +
+
+ Default compares this lane against its upstream / default branch. You can switch to another lane when you want a lane-to-lane review. +
+ + {launchDraft.compareKind === "lane" ? ( + + ) : null} +
+ ) : null} + + {launchDraft.targetMode === "commit_range" ? ( +
+
+ Review only the commits between the base and head revision. The base commit is excluded; the head commit is included. +
+
+ + +
+
+ ) : null} + +
+ + +
+ +
+ + + + +
+ +
+ Ticket 1 uses local-only publishing. The review run is persisted first, and the transcript is attached as supporting context. +
+
+
+ + void refreshRuns()} disabled={loadingRuns}> + + Refresh + + )} + > +
+ {runs.length === 0 ? ( +
+ No saved review runs yet in this workspace. +
+ ) : runs.map((run) => { + const active = run.id === selectedRunId; + const runLane = laneById.get(run.laneId) ?? null; + return ( + + ); + })} +
+
+
+ ); + + const detailPane = ( +
+ {selectedRun ? ( +
+
+
+
+
+ {selectedRun.status} + {selectedRun.target.mode} + {selectedRun.config.selectionMode} + {selectedRun.config.modelId} +
+
+ {describeRunTarget(selectedRun)} +
+
+ {selectedRun.summary ?? "No summary has been recorded yet."} +
+ {selectedRun.errorMessage ? ( +
{selectedRun.errorMessage}
+ ) : null} +
+ +
+
+ +
+ + + + + + + + +
+ + +
+ + + + + + + + +
+
+ + +
+ {selectedDetail?.findings?.length ? selectedDetail.findings.map((finding, index) => { + const evidence = normalizeEvidence(finding.evidence); + return ( +
+
+
+
+ {finding.severity} +
{finding.title}
+
+
{finding.body}
+
+
+ confidence {formatConfidence(finding.confidence)} + {finding.anchorState} · {finding.sourcePass} +
+
+
+ {finding.publicationState} + {finding.filePath ? {finding.filePath}{finding.line ? `:${finding.line}` : ""} : null} + {finding.filePath ? ( + + ) : null} + {finding.filePath ? ( + + ) : null} +
+ {evidence.length > 0 ? ( +
+ {evidence.map((entry, evidenceIndex) => ( +
+
+ {entry.kind ?? "evidence"} + {entry.summary ? {entry.summary} : null} + {entry.filePath ? {entry.filePath}{entry.line ? `:${entry.line}` : ""} : null} + {entry.artifactId ? {entry.artifactId} : null} +
+ {entry.quote ?
{entry.quote}
: null} +
+ ))} +
+ ) : null} +
+ ); + }) : ( +
+ No findings were saved for this run. +
+ )} +
+
+ + +
+ {selectedDetail?.artifacts?.length ? selectedDetail.artifacts.map((artifact) => ( +
+
+ {artifact.artifactType} +
{artifact.title}
+ {artifact.mimeType} +
+ {artifact.contentText ?
{artifact.contentText}
: null} + {artifact.metadata ? ( +
+                      {JSON.stringify(artifact.metadata, null, 2)}
+                    
+ ) : null} +
+ )) : ( +
+ No artifacts were captured for this run. +
+ )} +
+
+ + + {selectedDetail?.chatSession ? ( +
+ +
+ ) : ( +
+ No transcript session was linked to this run. +
+ )} +
+
+ ) : loadingDetail ? ( +
Loading review detail…
+ ) : ( + + )} +
+ ); + + const paneConfigs: Record = { + launch: { + title: "Launch and history", + icon: Sparkle, + bodyClassName: "flex flex-col min-h-0", + children: launchPane, + }, + detail: { + title: "Run detail", + icon: MagnifyingGlass, + bodyClassName: "flex flex-col min-h-0", + children: detailPane, + }, + }; + + if (error && runs.length === 0 && !loadingRuns) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+
+ +
+
+
Review
+
Local review runs, findings, evidence, and transcript history.
+
+
+ +
+ {runs.length} saved + {activeRuns} active + {totalFindings} findings + {selectedLane ? selectedLane.name : "No lane selected"} + {launchContext?.defaultBranchName ?? "default branch"} + {loadingLaunch ? loading context : null} +
+ +
+ + +
+
+ +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/review/reviewApi.ts b/apps/desktop/src/renderer/components/review/reviewApi.ts new file mode 100644 index 000000000..6cfb4b4d2 --- /dev/null +++ b/apps/desktop/src/renderer/components/review/reviewApi.ts @@ -0,0 +1,70 @@ +import type { + ReviewEventPayload, + ReviewLaunchContext, + ReviewListRunsArgs, + ReviewRun, + ReviewRunDetail, + ReviewStartRunArgs, +} from "./reviewTypes"; + +type ReviewBridge = { + listRuns: (args?: ReviewListRunsArgs) => Promise; + getRunDetail: (runId: string) => Promise; + startRun: (args: ReviewStartRunArgs) => Promise<{ runId?: string; id?: string } | ReviewRunDetail | string | null>; + rerun: (runId: string) => Promise<{ runId?: string; id?: string } | ReviewRunDetail | string | null>; + listLaunchContext: () => Promise; + onEvent: (listener: (event: ReviewEventPayload) => void) => () => void; +}; + +function getReviewBridge(): ReviewBridge | null { + const bridge = (window as Window & { ade?: { review?: ReviewBridge } }).ade?.review ?? null; + return bridge ?? null; +} + +export async function listReviewRuns(args?: ReviewListRunsArgs): Promise { + const bridge = getReviewBridge(); + if (!bridge) return []; + return bridge.listRuns(args).catch(() => []); +} + +export async function getReviewRunDetail(runId: string): Promise { + const bridge = getReviewBridge(); + if (!bridge) return null; + return bridge.getRunDetail(runId).catch(() => null); +} + +export async function startReviewRun(args: ReviewStartRunArgs): Promise<{ runId: string | null }> { + const bridge = getReviewBridge(); + if (!bridge) return { runId: null }; + const result = await bridge.startRun(args).catch(() => null); + if (typeof result === "string") return { runId: result }; + if (result && typeof result === "object") { + const maybe = result as { runId?: string; id?: string }; + return { runId: maybe.runId ?? maybe.id ?? null }; + } + return { runId: null }; +} + +export async function rerunReview(runId: string): Promise<{ runId: string | null }> { + const bridge = getReviewBridge(); + if (!bridge) return { runId: null }; + const result = await bridge.rerun(runId).catch(() => null); + if (typeof result === "string") return { runId: result }; + if (result && typeof result === "object") { + const maybe = result as { runId?: string; id?: string }; + return { runId: maybe.runId ?? maybe.id ?? null }; + } + return { runId: null }; +} + +export async function listReviewLaunchContext(): Promise { + const bridge = getReviewBridge(); + if (!bridge) return null; + return bridge.listLaunchContext().catch(() => null); +} + +export function onReviewEvent(listener: (event: ReviewEventPayload) => void): () => void { + const bridge = getReviewBridge(); + if (!bridge) return () => {}; + return bridge.onEvent(listener); +} diff --git a/apps/desktop/src/renderer/components/review/reviewRouteState.ts b/apps/desktop/src/renderer/components/review/reviewRouteState.ts new file mode 100644 index 000000000..55f70e431 --- /dev/null +++ b/apps/desktop/src/renderer/components/review/reviewRouteState.ts @@ -0,0 +1,13 @@ +export function readReviewRunId(search: string): string | null { + const params = new URLSearchParams(search); + const runId = params.get("runId")?.trim(); + return runId && runId.length > 0 ? runId : null; +} + +export function buildReviewSearch(runId: string | null): string { + const params = new URLSearchParams(); + if (runId) params.set("runId", runId); + const next = params.toString(); + return next.length ? `?${next}` : ""; +} + diff --git a/apps/desktop/src/renderer/components/review/reviewTypes.ts b/apps/desktop/src/renderer/components/review/reviewTypes.ts new file mode 100644 index 000000000..57425c0f4 --- /dev/null +++ b/apps/desktop/src/renderer/components/review/reviewTypes.ts @@ -0,0 +1,33 @@ +import type { ReviewEvidence, ReviewRunArtifact } from "../../../shared/types"; + +export type { + ReviewAnchorState, + ReviewArtifactType, + ReviewCompareAgainstTarget, + ReviewEvidence, + ReviewEventPayload, + ReviewFinding, + ReviewLaunchCommit, + ReviewLaunchContext, + ReviewLaunchLane, + ReviewListRunsArgs, + ReviewPublicationState, + ReviewResolvedCompareTarget, + ReviewRun, + ReviewRunArtifact, + ReviewRunBudgetConfig, + ReviewRunConfig, + ReviewRunDetail, + ReviewRunStatus, + ReviewSelectionMode, + ReviewSeverity, + ReviewSeveritySummary, + ReviewSourcePass, + ReviewStartRunArgs, + ReviewTarget, + ReviewTargetMode, +} from "../../../shared/types"; + +export type ReviewCompareKind = "default_branch" | "lane"; +export type ReviewArtifact = ReviewRunArtifact; +export type ReviewEvidenceEntry = ReviewEvidence; diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 9217a7bbc..493f7fac9 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -227,6 +227,12 @@ export const IPC = { automationsSaveDraft: "ade.automations.saveDraft", automationsSimulate: "ade.automations.simulate", automationsEvent: "ade.automations.event", + reviewListRuns: "ade.review.listRuns", + reviewGetRunDetail: "ade.review.getRunDetail", + reviewStartRun: "ade.review.startRun", + reviewRerun: "ade.review.rerun", + reviewListLaunchContext: "ade.review.listLaunchContext", + reviewEvent: "ade.review.event", missionsList: "ade.missions.list", missionsGet: "ade.missions.get", missionsCreate: "ade.missions.create", diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index 95a640232..4ea61c167 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -20,6 +20,7 @@ export * from "./missions"; export * from "./orchestrator"; export * from "./config"; export * from "./automations"; +export * from "./review"; export * from "./packs"; export * from "./agents"; export * from "./budget"; diff --git a/apps/desktop/src/shared/types/review.ts b/apps/desktop/src/shared/types/review.ts new file mode 100644 index 000000000..0a91cf6be --- /dev/null +++ b/apps/desktop/src/shared/types/review.ts @@ -0,0 +1,188 @@ +import type { AgentChatSessionSummary } from "./chat"; + +// --------------------------------------------------------------------------- +// Review types +// --------------------------------------------------------------------------- + +export type ReviewTargetMode = "lane_diff" | "commit_range" | "working_tree"; +export type ReviewRunStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; +export type ReviewSeverity = "critical" | "high" | "medium" | "low" | "info"; +export type ReviewAnchorState = "anchored" | "file_only" | "missing"; +export type ReviewPublicationState = "local_only" | "published"; +export type ReviewSourcePass = "single_pass" | "adjudicated"; +export type ReviewSelectionMode = "full_diff" | "selected_commits" | "dirty_only"; +export type ReviewArtifactType = "prompt" | "diff_bundle" | "review_output" | "untracked_snapshot"; + +export type ReviewCompareAgainstTarget = + | { + kind: "default_branch"; + } + | { + kind: "lane"; + laneId: string; + }; + +export type ReviewResolvedCompareTarget = { + kind: "default_branch" | "lane"; + label: string; + ref: string | null; + laneId: string | null; + branchRef: string | null; +}; + +export type ReviewRunBudgetConfig = { + maxFiles: number; + maxDiffChars: number; + maxPromptChars: number; + maxFindings: number; +}; + +export type ReviewRunConfig = { + compareAgainst: ReviewCompareAgainstTarget; + selectionMode: ReviewSelectionMode; + dirtyOnly: boolean; + modelId: string; + reasoningEffort: string | null; + budgets: ReviewRunBudgetConfig; + publishBehavior: "local_only"; +}; + +export type ReviewTarget = + | { + mode: "lane_diff"; + laneId: string; + } + | { + mode: "commit_range"; + laneId: string; + baseCommit: string; + headCommit: string; + } + | { + mode: "working_tree"; + laneId: string; + }; + +export type ReviewEvidence = { + kind: "quote" | "diff_hunk" | "artifact" | "file_snapshot"; + summary: string; + filePath: string | null; + line: number | null; + quote: string | null; + artifactId: string | null; +}; + +export type ReviewFinding = { + id: string; + runId: string; + title: string; + severity: ReviewSeverity; + body: string; + confidence: number; + evidence: ReviewEvidence[]; + filePath: string | null; + line: number | null; + anchorState: ReviewAnchorState; + sourcePass: ReviewSourcePass; + publicationState: ReviewPublicationState; +}; + +export type ReviewSeveritySummary = { + critical: number; + high: number; + medium: number; + low: number; + info: number; +}; + +export type ReviewRun = { + id: string; + projectId: string; + laneId: string; + target: ReviewTarget; + config: ReviewRunConfig; + targetLabel: string; + compareTarget: ReviewResolvedCompareTarget | null; + status: ReviewRunStatus; + summary: string | null; + errorMessage: string | null; + findingCount: number; + severitySummary: ReviewSeveritySummary; + chatSessionId: string | null; + createdAt: string; + startedAt: string; + endedAt: string | null; + updatedAt: string; +}; + +export type ReviewRunArtifact = { + id: string; + runId: string; + artifactType: ReviewArtifactType; + title: string; + mimeType: string; + contentText: string | null; + metadata: Record | null; + createdAt: string; +}; + +export type ReviewLaunchLane = { + id: string; + name: string; + laneType: string; + branchRef: string; + baseRef: string; + color: string | null; +}; + +export type ReviewLaunchCommit = { + sha: string; + shortSha: string; + subject: string; + authoredAt: string; + pushed: boolean; +}; + +export type ReviewLaunchContext = { + defaultLaneId: string | null; + defaultBranchName: string | null; + lanes: ReviewLaunchLane[]; + recentCommitsByLane: Record; + recommendedModelId: string | null; +}; + +export type ReviewRunDetail = ReviewRun & { + findings: ReviewFinding[]; + artifacts: ReviewRunArtifact[]; + chatSession: AgentChatSessionSummary | null; +}; + +export type ReviewListRunsArgs = { + laneId?: string | null; + limit?: number; + status?: ReviewRunStatus | "all"; +}; + +export type ReviewStartRunArgs = { + target: ReviewTarget; + config?: Partial | null; +}; + +export type ReviewEventPayload = + | { + type: "runs-updated"; + runId?: string; + laneId?: string; + status?: ReviewRunStatus; + } + | { + type: "run-started"; + runId: string; + laneId: string; + } + | { + type: "run-completed"; + runId: string; + laneId: string; + status: ReviewRunStatus; + }; From 9283aeceeb340b42a2d7701f562a507e79595281 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Apr 2026 03:58:58 -0400 Subject: [PATCH 02/16] Implement PR review publishing and Ticket 3 review flow fixes --- apps/desktop/src/main/main.ts | 8 +- .../src/main/services/lanes/laneService.ts | 1 + .../prs/prService.reviewPublication.test.ts | 205 ++++++++++++++ .../src/main/services/prs/prService.ts | 264 +++++++++++++++++- .../services/review/reviewService.test.ts | 193 +++++++++++++ .../src/main/services/review/reviewService.ts | 262 ++++++++++++++++- .../review/reviewTargetMaterializer.test.ts | 126 +++++++-- .../review/reviewTargetMaterializer.ts | 195 +++++++++++-- apps/desktop/src/main/services/state/kvDb.ts | 21 ++ apps/desktop/src/preload/global.d.ts | 2 +- apps/desktop/src/preload/preload.test.ts | 1 + apps/desktop/src/preload/preload.ts | 35 +-- apps/desktop/src/renderer/browserMock.ts | 9 +- .../PrDetailPane.issueResolver.test.tsx | 48 ++++ .../components/prs/detail/PrDetailPane.tsx | 43 ++- .../components/review/ReviewPage.test.tsx | 102 +++++++ .../renderer/components/review/ReviewPage.tsx | 101 +++++-- .../renderer/components/review/reviewApi.ts | 10 +- .../review/reviewRouteState.test.ts | 14 + .../components/review/reviewRouteState.ts | 4 +- .../renderer/components/review/reviewTypes.ts | 5 + apps/desktop/src/shared/types/prs.ts | 19 ++ apps/desktop/src/shared/types/review.ts | 55 +++- apps/ios/ADE/Resources/DatabaseBootstrap.sql | 82 ++++++ 24 files changed, 1669 insertions(+), 136 deletions(-) create mode 100644 apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts create mode 100644 apps/desktop/src/renderer/components/review/reviewRouteState.test.ts diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index a0e9283a0..68d498e74 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2006,11 +2006,12 @@ app.whenReady().then(async () => { logger, projectId, projectRoot, - projectDefaultBranch: null, + projectDefaultBranch: baseRef, laneService, gitService, agentChatService, sessionService, + prService, onEvent: (event) => emitProjectEvent(projectRoot, IPC.reviewEvent, event), }); const automationIngressService = createAutomationIngressService({ @@ -3178,6 +3179,11 @@ app.whenReady().then(async () => { } catch { // ignore } + try { + ctx.reviewService?.dispose?.(); + } catch { + // ignore + } try { ctx.usageTrackingService?.dispose(); } catch { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 21776d0f0..48ea059f1 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -2501,6 +2501,7 @@ export function createLaneService({ db.run("delete from pr_pipeline_settings where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from pr_issue_inventory where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from pull_requests where lane_id = ? and project_id = ?", [laneId, projectId]); + db.run("delete from review_run_publications where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from review_run_artifacts where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from review_runs where lane_id = ? and project_id = ?", [laneId, projectId]); diff --git a/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts b/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts new file mode 100644 index 000000000..0f6c9c90f --- /dev/null +++ b/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPrService } from "./prService"; + +function makeLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any; +} + +function makeMockDb() { + return { + get: vi.fn((sql: string) => { + if (!sql.includes("from pull_requests")) return null; + return { + id: "pr-80", + lane_id: "lane-1", + project_id: "proj-1", + repo_owner: "test-owner", + repo_name: "test-repo", + github_pr_number: 80, + github_url: "https://github.com/test-owner/test-repo/pull/80", + github_node_id: "PR_kwDOExample", + title: "Review publication", + state: "open", + base_branch: "main", + head_branch: "feature/pr-80", + checks_status: "passing", + review_status: "commented", + additions: 2, + deletions: 0, + last_synced_at: "2026-04-06T10:00:00.000Z", + created_at: "2026-04-06T09:55:00.000Z", + updated_at: "2026-04-06T10:00:00.000Z", + }; + }), + all: vi.fn(() => []), + run: vi.fn(), + } as any; +} + +function makeLaneService() { + return { + list: vi.fn(async () => []), + getLaneBaseAndBranch: vi.fn(), + } as any; +} + +describe("prService.publishReviewPublication", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("posts anchored findings inline and routes unanchored findings into the review summary", async () => { + const apiRequest = vi.fn(async ({ method, path, body }: { method: string; path: string; body?: Record }) => { + if (method === "GET" && path === "/repos/test-owner/test-repo/pulls/80") { + return { + data: { + number: 80, + html_url: "https://github.com/test-owner/test-repo/pull/80", + node_id: "PR_kwDOExample", + title: "Review publication", + state: "open", + draft: false, + merged_at: null, + head: { ref: "feature/pr-80", sha: "def456789012" }, + base: { ref: "main", sha: "abc123456789" }, + additions: 2, + deletions: 0, + }, + }; + } + if (method === "GET" && path === "/repos/test-owner/test-repo/pulls/80/files") { + return { + data: [ + { + filename: "src/review.ts", + status: "modified", + additions: 2, + deletions: 0, + patch: "@@ -10,1 +10,3 @@\n context\n+anchored\n+summary only\n", + previous_filename: null, + }, + ], + }; + } + if (method === "GET" && path === "/repos/test-owner/test-repo/commits/def456789012/status") { + return { data: { state: "success", statuses: [] } }; + } + if (method === "GET" && path === "/repos/test-owner/test-repo/commits/def456789012/check-runs") { + return { data: { check_runs: [] } }; + } + if (method === "GET" && path === "/repos/test-owner/test-repo/pulls/80/reviews") { + return { data: [] }; + } + if (method === "POST" && path === "/repos/test-owner/test-repo/pulls/80/reviews") { + return { + data: { + id: 123, + html_url: "https://github.com/test-owner/test-repo/pull/80#pullrequestreview-123", + }, + }; + } + throw new Error(`Unexpected request: ${method} ${path} ${JSON.stringify(body ?? {})}`); + }); + + const service = createPrService({ + db: makeMockDb(), + logger: makeLogger(), + projectId: "proj-1", + projectRoot: "/tmp/test-project", + laneService: makeLaneService(), + operationService: {} as any, + githubService: { + apiRequest, + getRepoOrThrow: vi.fn(), + getStatus: vi.fn(), + setToken: vi.fn(), + clearToken: vi.fn(), + getTokenOrThrow: vi.fn(() => "ghp_mock"), + } as any, + projectConfigService: { get: vi.fn(() => ({ effective: { ai: {} } })) } as any, + openExternal: vi.fn(async () => undefined), + }); + + const publication = await service.publishReviewPublication({ + runId: "run-1", + destination: { + kind: "github_pr_review", + prId: "pr-80", + repoOwner: "test-owner", + repoName: "test-repo", + prNumber: 80, + githubUrl: "https://github.com/test-owner/test-repo/pull/80", + }, + targetLabel: "PR #80 feature/pr-80 -> main", + summary: "One finding can anchor, one cannot.", + findings: [ + { + id: "finding-inline", + runId: "run-1", + title: "Anchored finding", + severity: "high", + body: "This should post inline.", + confidence: 0.9, + evidence: [], + filePath: "src/review.ts", + line: 11, + anchorState: "anchored", + sourcePass: "single_pass", + publicationState: "local_only", + }, + { + id: "finding-summary", + runId: "run-1", + title: "Summary finding", + severity: "medium", + body: "This should stay in the top-level review body.", + confidence: 0.6, + evidence: [], + filePath: "src/review.ts", + line: 200, + anchorState: "file_only", + sourcePass: "single_pass", + publicationState: "local_only", + }, + ], + changedFiles: [ + { + filePath: "src/review.ts", + diffPositionsByLine: { 11: 2 }, + }, + ], + }); + + expect(publication.status).toBe("published"); + expect(publication.inlineComments).toEqual([ + expect.objectContaining({ + findingId: "finding-inline", + path: "src/review.ts", + line: 11, + position: 2, + }), + ]); + expect(publication.summaryFindingIds).toEqual(["finding-summary"]); + + const postCall = apiRequest.mock.calls.find( + ([request]: [{ method: string; path: string }]) => request.method === "POST" && request.path.endsWith("/reviews"), + )?.[0]; + expect(postCall?.body).toEqual(expect.objectContaining({ + event: "COMMENT", + commit_id: "def456789012", + comments: [ + expect.objectContaining({ + path: "src/review.ts", + position: 2, + }), + ], + })); + expect(String(postCall?.body?.body ?? "")).toContain("Summary finding"); + expect(String(postCall?.body?.body ?? "")).toContain("Anchored inline comments posted: 1."); + }); +}); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index a15d1c346..327f59669 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -66,6 +66,7 @@ import type { SetPrLabelsArgs, RequestPrReviewersArgs, SubmitPrReviewArgs, + SubmitPrReviewResult, ClosePrArgs, ReopenPrArgs, RerunPrChecksArgs, @@ -75,6 +76,7 @@ import type { GitHubPrSnapshot, PrDetail, PrFile, + PrReviewSnapshot, PrActionRun, PrActionJob, PrActionStep, @@ -92,6 +94,10 @@ import type { SetPrReviewThreadResolvedResult, ReactToPrCommentArgs, PrReactionContent, + ReviewFinding, + ReviewPublication, + ReviewPublicationDestination, + ReviewPublicationInlineComment, } from "../../../shared/types"; import type { AdeDb } from "../state/kvDb"; import type { Logger } from "../logging/logger"; @@ -217,6 +223,64 @@ function createEmptyIntegrationResolutionState(integrationLaneId: string, update }; } +function buildPublishedReviewCommentBody(finding: ReviewFinding): string { + const sections = [ + `[${finding.severity.toUpperCase()}] ${finding.title}`, + "", + finding.body, + "", + `Confidence: ${Math.round(finding.confidence * 100)}%`, + ]; + const quote = finding.evidence.find((entry) => typeof entry.quote === "string" && entry.quote.trim().length > 0)?.quote?.trim() ?? null; + if (quote) { + sections.push("", "```", quote.slice(0, 1_200), "```"); + } + return sections.join("\n"); +} + +function formatPublishedFindingLocation(finding: ReviewFinding): string { + if (finding.filePath && finding.line != null) return `${finding.filePath}:${finding.line}`; + if (finding.filePath) return finding.filePath; + return "general"; +} + +function buildPublishedReviewSummaryBody(args: { + targetLabel: string; + summary: string | null; + inlineFindings: ReviewFinding[]; + summaryFindings: ReviewFinding[]; +}): string { + const lines = [ + "## ADE review", + "", + `Target: ${args.targetLabel}`, + "", + args.summary?.trim() || (args.inlineFindings.length + args.summaryFindings.length > 0 + ? `ADE found ${args.inlineFindings.length + args.summaryFindings.length} actionable finding(s).` + : "ADE found no actionable findings."), + ]; + + if (args.inlineFindings.length > 0) { + lines.push("", `Anchored inline comments posted: ${args.inlineFindings.length}.`); + } + + if (args.summaryFindings.length > 0) { + lines.push("", "### Findings kept in the summary"); + for (const finding of args.summaryFindings) { + lines.push( + `- [${finding.severity}] ${finding.title} (${formatPublishedFindingLocation(finding)})`, + ` ${finding.body}`, + ); + } + } + + if (args.inlineFindings.length === 0 && args.summaryFindings.length === 0) { + lines.push("", "No actionable findings."); + } + + return lines.join("\n").trim(); +} + async function readIntegrationLaneSnapshot(worktreePath: string): Promise { if (!worktreePath || !fs.existsSync(worktreePath)) return null; const [headRes, statusRes] = await Promise.all([ @@ -1788,6 +1852,85 @@ export function createPrService({ })); }; + const getReviewSnapshot = async (prId: string): Promise => { + const row = requireRow(prId); + const repo = repoFromRow(row); + const [pr, files] = await Promise.all([ + fetchPr(repo, Number(row.github_pr_number)), + getFilesSnapshot(prId), + ]); + + return { + ...rowToSummary(row), + baseBranch: asString(pr?.base?.ref) || row.base_branch, + headBranch: asString(pr?.head?.ref) || row.head_branch, + githubUrl: asString(pr?.html_url) || row.github_url, + githubNodeId: asString(pr?.node_id) || row.github_node_id, + title: asString(pr?.title) || row.title || "", + additions: Number(pr?.additions ?? row.additions ?? 0), + deletions: Number(pr?.deletions ?? row.deletions ?? 0), + baseSha: asString(pr?.base?.sha) || null, + headSha: asString(pr?.head?.sha) || null, + files, + }; + }; + + const submitReviewRequest = async ( + args: SubmitPrReviewArgs, + options: { + commitSha?: string | null; + } = {}, + ): Promise => { + const row = requireRow(args.prId); + const repo = repoFromRow(row); + const inlineComments = Array.isArray(args.comments) + ? args.comments.filter((comment) => { + const path = asString(comment?.path).trim(); + const body = asString(comment?.body).trim(); + const position = Number(comment?.position); + return path.length > 0 && body.length > 0 && Number.isFinite(position) && position > 0; + }) + : []; + + let commitSha = asString(options.commitSha).trim() || null; + if (inlineComments.length > 0 && !commitSha) { + commitSha = (await getReviewSnapshot(args.prId)).headSha; + } + if (inlineComments.length > 0 && !commitSha) { + throw new Error("PR head commit is unavailable for inline review comments."); + } + + const { data } = await githubService.apiRequest({ + method: "POST", + path: `/repos/${repo.owner}/${repo.name}/pulls/${Number(row.github_pr_number)}/reviews`, + body: { + event: args.event, + body: args.body ?? "", + ...(commitSha ? { commit_id: commitSha } : {}), + ...(inlineComments.length > 0 + ? { + comments: inlineComments.map((comment) => ({ + path: comment.path, + body: comment.body, + position: comment.position, + })), + } + : {}), + }, + }); + + markHotRefresh([args.prId]); + await refreshOne(args.prId); + + return { + id: Number.isFinite(Number(data?.id)) ? String(Number(data.id)) : null, + nodeId: asString(data?.node_id) || null, + htmlUrl: asString(data?.html_url) || null, + state: asString(data?.state) || null, + submittedAt: asString(data?.submitted_at) || null, + }; + }; + const refreshSnapshotData = async (prId: string): Promise => { const [detail, status, checks, reviews, comments, files] = await Promise.all([ getDetailSnapshot(prId).catch(() => null), @@ -4340,6 +4483,8 @@ export function createPrService({ return listRows().map(rowToSummary); }, + getReviewSnapshot, + async refresh(args: { prId?: string; prIds?: string[] } = {}): Promise { const requestedPrIds = [ ...(args.prId ? [args.prId] : []), @@ -5158,16 +5303,117 @@ export function createPrService({ await refreshOne(args.prId); }, - async submitReview(args: SubmitPrReviewArgs): Promise { - const row = requireRow(args.prId); - const repo = repoFromRow(row); - await githubService.apiRequest({ - method: "POST", - path: `/repos/${repo.owner}/${repo.name}/pulls/${Number(row.github_pr_number)}/reviews`, - body: { event: args.event, body: args.body ?? "" } + async submitReview(args: SubmitPrReviewArgs): Promise { + return await submitReviewRequest(args); + }, + + async publishReviewPublication(args: { + runId: string; + destination: ReviewPublicationDestination; + targetLabel: string; + summary: string | null; + findings: ReviewFinding[]; + changedFiles: Array<{ filePath: string; diffPositionsByLine: Record }>; + }): Promise { + if (args.destination.kind !== "github_pr_review") { + throw new Error(`Unsupported review publication destination: ${args.destination.kind}`); + } + + const snapshot = await getReviewSnapshot(args.destination.prId); + const requestedAt = nowIso(); + const changedFilesByPath = new Map( + args.changedFiles.map((file) => [file.filePath, file.diffPositionsByLine] as const), + ); + const inlineFindings: ReviewFinding[] = []; + const summaryFindings: ReviewFinding[] = []; + const inlineComments: ReviewPublicationInlineComment[] = []; + + for (const finding of args.findings) { + const linePositions = finding.filePath ? changedFilesByPath.get(finding.filePath) : null; + const diffPosition = finding.line != null && linePositions + ? Number(linePositions[finding.line] ?? NaN) + : Number.NaN; + + if ( + finding.anchorState === "anchored" + && finding.filePath + && finding.line != null + && Number.isFinite(diffPosition) + && diffPosition > 0 + ) { + inlineFindings.push(finding); + inlineComments.push({ + findingId: finding.id, + path: finding.filePath, + line: finding.line, + position: diffPosition, + body: buildPublishedReviewCommentBody(finding), + }); + continue; + } + summaryFindings.push(finding); + } + + const summaryBody = buildPublishedReviewSummaryBody({ + targetLabel: args.targetLabel, + summary: args.summary, + inlineFindings, + summaryFindings, }); - markHotRefresh([args.prId]); - await refreshOne(args.prId); + + try { + const result = await submitReviewRequest( + { + prId: args.destination.prId, + event: "COMMENT", + body: summaryBody, + comments: inlineComments.map((comment) => ({ + path: comment.path, + position: comment.position, + body: comment.body, + })), + }, + { + commitSha: snapshot.headSha, + }, + ); + + const completedAt = nowIso(); + return { + id: randomUUID(), + runId: args.runId, + destination: args.destination, + reviewEvent: "COMMENT", + status: "published", + reviewUrl: result.htmlUrl, + remoteReviewId: result.id || result.nodeId, + summaryBody, + inlineComments, + summaryFindingIds: summaryFindings.map((finding) => finding.id), + errorMessage: null, + createdAt: requestedAt, + updatedAt: completedAt, + completedAt, + }; + } catch (error) { + const completedAt = nowIso(); + return { + id: randomUUID(), + runId: args.runId, + destination: args.destination, + reviewEvent: "COMMENT", + status: "failed", + reviewUrl: null, + remoteReviewId: null, + summaryBody, + inlineComments, + summaryFindingIds: summaryFindings.map((finding) => finding.id), + errorMessage: getErrorMessage(error), + createdAt: requestedAt, + updatedAt: completedAt, + completedAt, + }; + } }, async closePr(args: ClosePrArgs): Promise { diff --git a/apps/desktop/src/main/services/review/reviewService.test.ts b/apps/desktop/src/main/services/review/reviewService.test.ts index 8bde7ab21..7b7288205 100644 --- a/apps/desktop/src/main/services/review/reviewService.test.ts +++ b/apps/desktop/src/main/services/review/reviewService.test.ts @@ -97,6 +97,24 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { created_at text not null ) `); + raw.run(` + create table review_run_publications( + id text primary key, + run_id text not null, + destination_json text not null, + review_event text not null, + status text not null, + review_url text, + remote_review_id text, + summary_body text not null, + inline_comments_json text not null default '[]', + summary_finding_ids_json text not null default '[]', + error_message text, + created_at text not null, + updated_at text not null, + completed_at text + ) + `); const run = (sql: string, params: SqlValue[] = []) => raw.run(sql, params); const all = = Record>(sql: string, params: SqlValue[] = []): T[] => @@ -133,12 +151,14 @@ describe("reviewService", () => { laneId: null, branchRef: "main", }, + publicationTarget: null, fullPatchText: "diff --git a/src/review.ts b/src/review.ts\n@@ -1,1 +1,2 @@\n+return null;\n", changedFiles: [ { filePath: "src/review.ts", excerpt: "@@ -1,1 +1,2 @@\n+return null;", lineNumbers: [2], + diffPositionsByLine: { 2: 1 }, }, ], artifacts: [ @@ -272,6 +292,7 @@ describe("reviewService", () => { expect(detail?.findings[0]?.anchorState).toBe("anchored"); expect(detail?.artifacts.some((artifact) => artifact.artifactType === "prompt")).toBe(true); expect(detail?.artifacts.some((artifact) => artifact.artifactType === "review_output")).toBe(true); + expect(detail?.publications).toEqual([]); expect(detail?.chatSession?.sessionId).toBe("session-review-1"); expect(events.some((event) => event.type === "run-completed" && event.runId === run.id && event.status === "completed")).toBe(true); @@ -291,6 +312,7 @@ describe("reviewService", () => { laneId: null, branchRef: "main", }, + publicationTarget: null, fullPatchText: "", changedFiles: [], artifacts: [ @@ -383,4 +405,175 @@ describe("reviewService", () => { expect(rerunRecord?.config.selectionMode).toBe("selected_commits"); expect(rerunRecord?.config.reasoningEffort).toBe("high"); }); + + it("publishes PR-backed review runs, preserves summary findings, and reruns with the same publication flow", async () => { + const { db } = createInMemoryAdeDb(); + mockMaterializer.materialize.mockResolvedValue({ + targetLabel: "PR #80 feature/pr-80 -> main", + compareTarget: { + kind: "default_branch", + label: "main", + ref: "main", + laneId: null, + branchRef: "main", + }, + publicationTarget: { + kind: "github_pr_review", + prId: "pr-80", + repoOwner: "ade-dev", + repoName: "ade", + prNumber: 80, + githubUrl: "https://github.com/ade-dev/ade/pull/80", + }, + fullPatchText: "diff --git a/src/review.ts b/src/review.ts\n@@ -10,2 +10,4 @@\n context\n+anchored\n+summary only\n", + changedFiles: [ + { + filePath: "src/review.ts", + excerpt: "@@ -10,2 +10,4 @@\n context\n+anchored\n+summary only", + lineNumbers: [10, 11, 12], + diffPositionsByLine: { 10: 1, 11: 2, 12: 3 }, + }, + ], + artifacts: [ + { + artifactType: "diff_bundle", + title: "Diff bundle", + mimeType: "text/plain", + contentText: "diff --git a/src/review.ts b/src/review.ts\n@@ -10,2 +10,4 @@\n context\n+anchored\n+summary only\n", + metadata: null, + }, + ], + }); + + const publishReviewPublication = vi.fn(async (args: any) => ({ + id: `publication-${args.runId}`, + runId: args.runId, + destination: args.destination, + reviewEvent: "COMMENT", + status: "published", + reviewUrl: "https://github.com/ade-dev/ade/pull/80#pullrequestreview-1", + remoteReviewId: "1", + summaryBody: "Summary body", + inlineComments: [ + { + findingId: args.findings[0].id, + path: "src/review.ts", + line: 11, + position: 2, + body: "Inline comment", + }, + ], + summaryFindingIds: [args.findings[1].id], + errorMessage: null, + createdAt: "2026-04-06T10:00:00.000Z", + updatedAt: "2026-04-06T10:00:02.000Z", + completedAt: "2026-04-06T10:00:02.000Z", + })); + + const service = createReviewService({ + db: db as any, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any, + projectId: "project-1", + projectRoot: "/tmp/ade", + projectDefaultBranch: "main", + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/pr-80", + worktreePath: "/tmp/ade/lane", + laneType: "worktree", + }), + list: vi.fn(async () => []), + } as any, + gitService: { + listRecentCommits: vi.fn(async () => []), + } as any, + agentChatService: { + createSession: vi.fn(async () => ({ + id: "session-pr-review", + laneId: "lane-review", + provider: "codex", + model: "gpt-5.4-codex", + modelId: "openai/gpt-5.4-codex", + })), + getSessionSummary: vi.fn(async () => null), + runSessionTurn: vi.fn(async () => ({ + sessionId: "session-pr-review", + provider: "codex", + model: "gpt-5.4-codex", + modelId: "openai/gpt-5.4-codex", + outputText: JSON.stringify({ + summary: "Two findings on the PR.", + findings: [ + { + title: "Anchored finding", + severity: "high", + body: "This should post inline.", + confidence: 0.92, + filePath: "src/review.ts", + line: 11, + }, + { + title: "Summary finding", + severity: "medium", + body: "This should stay in the summary body.", + confidence: 0.64, + filePath: "src/review.ts", + line: 200, + }, + ], + }), + })), + } as any, + sessionService: { + updateMeta: vi.fn(), + } as any, + prService: { + getReviewSnapshot: vi.fn(), + publishReviewPublication, + } as any, + }); + + const first = await service.startRun({ + target: { mode: "pr", laneId: "lane-review", prId: "pr-80" }, + config: { publishBehavior: "auto_publish" }, + }); + + await waitFor( + () => service.listRuns(), + (runs) => runs.some((run) => run.id === first.id && run.status === "completed"), + ); + + const detail = await service.getRunDetail({ runId: first.id }); + expect(publishReviewPublication).toHaveBeenCalledWith(expect.objectContaining({ + runId: first.id, + targetLabel: "PR #80 feature/pr-80 -> main", + })); + expect(detail?.publications).toHaveLength(1); + expect(detail?.publications[0]?.status).toBe("published"); + expect(detail?.publications[0]?.summaryFindingIds).toHaveLength(1); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "publication_request")).toBe(true); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "publication_result")).toBe(true); + expect(detail?.findings.every((finding) => finding.publicationState === "published")).toBe(true); + + const savedRuns = await service.listRuns(); + const savedPrRun = savedRuns.find((run) => run.id === first.id); + expect(savedPrRun?.target).toEqual({ mode: "pr", laneId: "lane-review", prId: "pr-80" }); + expect(savedPrRun?.config.publishBehavior).toBe("auto_publish"); + + const rerun = await service.rerun(first.id); + await waitFor( + () => service.listRuns(), + (runs) => runs.some((run) => run.id === rerun.id && run.status === "completed"), + ); + + expect(publishReviewPublication).toHaveBeenCalledTimes(2); + const rerunDetail = await service.getRunDetail({ runId: rerun.id }); + expect(rerunDetail?.publications).toHaveLength(1); + }); }); diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index 391de2c69..480ed3551 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -4,6 +4,9 @@ import type { ReviewEventPayload, ReviewEvidence, ReviewFinding, + ReviewPublication, + ReviewPublicationDestination, + ReviewPublicationInlineComment, ReviewPublicationState, ReviewResolvedCompareTarget, ReviewRun, @@ -29,6 +32,7 @@ import type { createLaneService } from "../lanes/laneService"; import type { createGitOperationsService } from "../git/gitOperationsService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createSessionService } from "../sessions/sessionService"; +import type { createPrService } from "../prs/prService"; import { createReviewTargetMaterializer } from "./reviewTargetMaterializer"; type ReviewRunRow = { @@ -77,10 +81,43 @@ type ReviewRunArtifactRow = { created_at: string; }; -const DEFAULT_REVIEW_MODEL_ID = - getDefaultModelDescriptor("codex")?.id - ?? getDefaultModelDescriptor("unified")?.id - ?? "openai/gpt-5.4-codex"; +type ReviewRunPublicationRow = { + id: string; + run_id: string; + destination_json: string; + review_event: string; + status: string; + review_url: string | null; + remote_review_id: string | null; + summary_body: string; + inline_comments_json: string; + summary_finding_ids_json: string; + error_message: string | null; + created_at: string; + updated_at: string; + completed_at: string | null; +}; + +const REVIEW_MODEL_FALLBACK_ID = "openai/gpt-5.4-codex"; + +function resolveBuiltinReviewModelId(): string { + const candidates = [ + getDefaultModelDescriptor("codex")?.id ?? null, + getDefaultModelDescriptor("unified")?.id ?? null, + REVIEW_MODEL_FALLBACK_ID, + getDefaultModelDescriptor("claude")?.id ?? null, + getDefaultModelDescriptor("cursor")?.id ?? null, + ].filter((modelId): modelId is string => Boolean(modelId?.trim())); + + for (const modelId of candidates) { + const descriptor = getModelById(modelId); + if (descriptor) return descriptor.id; + } + + return REVIEW_MODEL_FALLBACK_ID; +} + +const DEFAULT_REVIEW_MODEL_ID = resolveBuiltinReviewModelId(); const DEFAULT_BUDGETS: ReviewRunConfig["budgets"] = { maxFiles: 60, @@ -406,6 +443,32 @@ function mapArtifactRow(row: ReviewRunArtifactRow): ReviewRunArtifact { }; } +function mapPublicationRow(row: ReviewRunPublicationRow): ReviewPublication { + return { + id: row.id, + runId: row.run_id, + destination: safeJsonParse(row.destination_json, { + kind: "github_pr_review", + prId: "", + repoOwner: "", + repoName: "", + prNumber: 0, + githubUrl: null, + }), + reviewEvent: row.review_event === "COMMENT" ? "COMMENT" : "COMMENT", + status: row.status === "published" ? "published" : "failed", + reviewUrl: row.review_url, + remoteReviewId: row.remote_review_id, + summaryBody: row.summary_body, + inlineComments: safeJsonParse(row.inline_comments_json, []), + summaryFindingIds: safeJsonParse(row.summary_finding_ids_json, []), + errorMessage: row.error_message, + createdAt: row.created_at, + updatedAt: row.updated_at, + completedAt: row.completed_at, + }; +} + export function createReviewService({ db, logger, @@ -416,6 +479,7 @@ export function createReviewService({ gitService, agentChatService, sessionService, + prService, onEvent, }: { db: AdeDb; @@ -427,12 +491,33 @@ export function createReviewService({ gitService: Pick, "listRecentCommits">; agentChatService: Pick, "createSession" | "getSessionSummary" | "runSessionTurn">; sessionService: Pick, "updateMeta">; + prService?: Pick, "getReviewSnapshot" | "publishReviewPublication">; onEvent?: (event: ReviewEventPayload) => void; }) { - const materializer = createReviewTargetMaterializer({ laneService }); + const materializer = createReviewTargetMaterializer({ laneService, prService }); const activeRuns = new Set(); + let disposed = false; + const configuredDefaultModelId = + getDefaultModelDescriptor("codex")?.id + ?? getDefaultModelDescriptor("unified")?.id + ?? REVIEW_MODEL_FALLBACK_ID; + const defaultReviewModelId = getModelById(configuredDefaultModelId)?.id ?? DEFAULT_REVIEW_MODEL_ID; + + if (defaultReviewModelId !== configuredDefaultModelId) { + logger.warn("review.default_model_fallback_selected", { + requestedModelId: configuredDefaultModelId, + resolvedModelId: defaultReviewModelId, + }); + } + + function assertNotDisposed(): void { + if (disposed) { + throw new Error("Review service is disposed."); + } + } function emit(event: ReviewEventPayload): void { + if (disposed) return; onEvent?.(event); } @@ -534,6 +619,43 @@ export function createReviewService({ ); } + function insertPublication(publication: ReviewPublication): void { + db.run( + `insert into review_run_publications ( + id, + run_id, + destination_json, + review_event, + status, + review_url, + remote_review_id, + summary_body, + inline_comments_json, + summary_finding_ids_json, + error_message, + created_at, + updated_at, + completed_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + publication.id, + publication.runId, + JSON.stringify(publication.destination), + publication.reviewEvent, + publication.status, + publication.reviewUrl, + publication.remoteReviewId, + publication.summaryBody, + JSON.stringify(publication.inlineComments), + JSON.stringify(publication.summaryFindingIds), + publication.errorMessage, + publication.createdAt, + publication.updatedAt, + publication.completedAt, + ], + ); + } + function insertFinding(finding: ReviewFinding): void { db.run( `insert into review_findings ( @@ -567,7 +689,15 @@ export function createReviewService({ ); } + function updateFindingPublicationState(runId: string, findingId: string, publicationState: ReviewPublicationState): void { + db.run( + "update review_findings set publication_state = ? where id = ? and run_id = ?", + [publicationState, findingId, runId], + ); + } + async function listLaunchContext(): Promise { + assertNotDisposed(); const lanes = await laneService.list(); const laneSummaries = lanes.map(mapLaunchLane); const recentCommitsByLane = Object.fromEntries(await Promise.all( @@ -581,7 +711,7 @@ export function createReviewService({ defaultBranchName: projectDefaultBranch ?? laneSummaries.find((lane) => lane.laneType === "primary")?.branchRef ?? null, lanes: laneSummaries, recentCommitsByLane, - recommendedModelId: DEFAULT_REVIEW_MODEL_ID, + recommendedModelId: defaultReviewModelId, }; } @@ -595,7 +725,7 @@ export function createReviewService({ ? "dirty_only" : "full_diff"), dirtyOnly: partial?.dirtyOnly ?? target.mode === "working_tree", - modelId: partial?.modelId?.trim() || DEFAULT_REVIEW_MODEL_ID, + modelId: partial?.modelId?.trim() || defaultReviewModelId, reasoningEffort: partial?.reasoningEffort?.trim() || null, budgets: { maxFiles: clampNumber(Number(partial?.budgets?.maxFiles ?? DEFAULT_BUDGETS.maxFiles), 1, 500), @@ -603,17 +733,84 @@ export function createReviewService({ maxPromptChars: clampNumber(Number(partial?.budgets?.maxPromptChars ?? DEFAULT_BUDGETS.maxPromptChars), 4_000, 1_000_000), maxFindings: clampNumber(Number(partial?.budgets?.maxFindings ?? DEFAULT_BUDGETS.maxFindings), 1, 50), }, - publishBehavior: "local_only", + publishBehavior: target.mode === "pr" && partial?.publishBehavior === "auto_publish" + ? "auto_publish" + : "local_only", }; } + async function publishRun(args: { + runId: string; + targetLabel: string; + summary: string | null; + config: ReviewRunConfig; + findings: ReviewFinding[]; + publicationTarget: ReviewPublicationDestination | null; + changedFiles: Array<{ filePath: string; diffPositionsByLine: Record }>; + }): Promise { + if (args.config.publishBehavior !== "auto_publish" || !args.publicationTarget || !prService) { + return null; + } + + insertArtifact(args.runId, { + artifactType: "publication_request", + title: "Review publication request", + mimeType: "application/json", + contentText: JSON.stringify({ + destination: args.publicationTarget, + targetLabel: args.targetLabel, + summary: args.summary, + findingIds: args.findings.map((finding) => finding.id), + changedFiles: args.changedFiles, + }, null, 2), + metadata: { + publishBehavior: args.config.publishBehavior, + }, + }); + + const publication = await prService.publishReviewPublication({ + runId: args.runId, + destination: args.publicationTarget, + targetLabel: args.targetLabel, + summary: args.summary, + findings: args.findings, + changedFiles: args.changedFiles, + }); + insertPublication(publication); + insertArtifact(args.runId, { + artifactType: "publication_result", + title: "Review publication result", + mimeType: "application/json", + contentText: JSON.stringify(publication, null, 2), + metadata: { + status: publication.status, + destinationKind: publication.destination.kind, + }, + }); + + if (publication.status === "published") { + const publishedFindingIds = new Set([ + ...publication.inlineComments.map((comment) => comment.findingId), + ...publication.summaryFindingIds, + ]); + for (const finding of args.findings) { + if (!publishedFindingIds.has(finding.id)) continue; + updateFindingPublicationState(args.runId, finding.id, "published"); + } + } + + return publication; + } + async function executeRun(runId: string): Promise { - if (activeRuns.has(runId)) return; + if (disposed || activeRuns.has(runId)) return; activeRuns.add(runId); try { + if (disposed) return; const row = getRunRow(runId); if (!row) return; const run = mapRunRow(row); + if (disposed) return; updateRun(runId, { status: "running", updated_at: nowIso(), @@ -624,6 +821,7 @@ export function createReviewService({ target: run.target, config: run.config, }); + if (disposed) return; updateRun(runId, { target_label: materialized.targetLabel, @@ -632,9 +830,11 @@ export function createReviewService({ }); for (const artifact of materialized.artifacts) { + if (disposed) return; insertArtifact(runId, artifact); } + if (disposed) return; if (!materialized.fullPatchText.trim()) { const endedAt = nowIso(); updateRun(runId, { @@ -666,6 +866,7 @@ export function createReviewService({ sessionProfile: "workflow", surface: "automation", }); + if (disposed) return; const sessionTitle = `Review: ${materialized.targetLabel}`; sessionService.updateMeta({ sessionId: session.id, @@ -703,6 +904,7 @@ export function createReviewService({ reasoningEffort: run.config.reasoningEffort, timeoutMs: 15 * 60 * 1000, }); + if (disposed) return; insertArtifact(runId, { artifactType: "review_output", title: "Reviewer output", @@ -717,7 +919,11 @@ export function createReviewService({ const changedFilesByPath = new Map(materialized.changedFiles.map((entry) => [ entry.filePath, - { excerpt: entry.excerpt, lineNumbers: new Set(entry.lineNumbers) }, + { + excerpt: entry.excerpt, + lineNumbers: new Set(entry.lineNumbers), + diffPositionsByLine: entry.diffPositionsByLine, + }, ])); const parsed = extractJsonObject(result.outputText); const normalized = normalizeParsedFindings({ @@ -727,8 +933,23 @@ export function createReviewService({ }); const findings = normalized.findings.slice(0, run.config.budgets.maxFindings); for (const finding of findings) { + if (disposed) return; insertFinding(finding); } + if (disposed) return; + await publishRun({ + runId, + targetLabel: materialized.targetLabel, + summary: normalized.summary, + config: run.config, + findings, + publicationTarget: materialized.publicationTarget, + changedFiles: materialized.changedFiles.map((entry) => ({ + filePath: entry.filePath, + diffPositionsByLine: entry.diffPositionsByLine, + })), + }); + if (disposed) return; const severitySummary = tallySeveritySummary(findings); const endedAt = nowIso(); updateRun(runId, { @@ -743,6 +964,7 @@ export function createReviewService({ emit({ type: "run-completed", runId, laneId: run.laneId, status: "completed" }); emit({ type: "runs-updated", runId, laneId: run.laneId, status: "completed" }); } catch (error) { + if (disposed) return; const endedAt = nowIso(); updateRun(runId, { status: "failed", @@ -762,15 +984,19 @@ export function createReviewService({ laneId: row?.lane_id ?? "", status: "failed", }); - emit({ type: "runs-updated", runId, laneId: row?.lane_id, status: "failed" }); + emit({ type: "runs-updated", runId, laneId: row?.lane_id ?? "", status: "failed" }); } finally { activeRuns.delete(runId); } } async function startRun(args: ReviewStartRunArgs): Promise { + assertNotDisposed(); const laneId = resolveTargetLaneId(args.target); laneService.getLaneBaseAndBranch(laneId); + if (args.target.mode === "pr" && !prService) { + throw new Error("PR-backed review runs are not available in this workspace."); + } const config = resolveConfig(args.target, args.config); const startedAt = nowIso(); const run: ReviewRun = { @@ -781,6 +1007,8 @@ export function createReviewService({ config, targetLabel: args.target.mode === "commit_range" ? `${laneId} ${args.target.baseCommit.slice(0, 7)}..${args.target.headCommit.slice(0, 7)}` + : args.target.mode === "pr" + ? `PR ${args.target.prId}` : args.target.mode === "working_tree" ? `${laneId} working tree` : `${laneId} review`, @@ -804,6 +1032,7 @@ export function createReviewService({ } async function rerun(runId: string): Promise { + assertNotDisposed(); const row = getRunRow(runId); if (!row) throw new Error(`Review run '${runId}' was not found.`); const existing = mapRunRow(row); @@ -814,6 +1043,7 @@ export function createReviewService({ } async function listRuns(args: ReviewListRunsArgs = {}): Promise { + assertNotDisposed(); const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 50))); const sql = [ "select * from review_runs where project_id = ?", @@ -829,6 +1059,7 @@ export function createReviewService({ } async function getRunDetail(args: { runId: string }): Promise { + assertNotDisposed(); const row = getRunRow(args.runId); if (!row) return null; const run = mapRunRow(row); @@ -852,6 +1083,10 @@ export function createReviewService({ "select * from review_run_artifacts where run_id = ? order by created_at asc", [args.runId], ).map(mapArtifactRow); + const publications = db.all( + "select * from review_run_publications where run_id = ? order by created_at asc", + [args.runId], + ).map(mapPublicationRow); const chatSession = run.chatSessionId ? await agentChatService.getSessionSummary(run.chatSessionId).catch(() => null) : null; @@ -859,6 +1094,7 @@ export function createReviewService({ ...run, findings, artifacts, + publications, chatSession, }; } @@ -869,5 +1105,9 @@ export function createReviewService({ rerun, listRuns, getRunDetail, + dispose() { + disposed = true; + activeRuns.clear(); + }, }; } diff --git a/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts b/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts index 330e4d24f..8941e708d 100644 --- a/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts +++ b/apps/desktop/src/main/services/review/reviewTargetMaterializer.test.ts @@ -147,48 +147,112 @@ describe("reviewTargetMaterializer", () => { it("materializes staged, unstaged, and untracked working tree changes", async () => { const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-review-working-tree-")); - fs.mkdirSync(path.join(worktreePath, "src"), { recursive: true }); - fs.writeFileSync(path.join(worktreePath, "src", "new-file.ts"), "export const untracked = true;\n", "utf8"); + try { + fs.mkdirSync(path.join(worktreePath, "src"), { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "src", "new-file.ts"), "export const untracked = true;\n", "utf8"); + const laneService = { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/review-tab", + worktreePath, + laneType: "worktree", + }), + list: vi.fn(), + } as any; + mockGit.runGitOrThrow + .mockResolvedValueOnce("diff --git a/src/staged.ts b/src/staged.ts\n@@ -1,1 +1,2 @@\n+staged change\n") + .mockResolvedValueOnce("diff --git a/src/unstaged.ts b/src/unstaged.ts\n@@ -1,1 +1,2 @@\n+unstaged change\n") + .mockResolvedValueOnce("M src/staged.ts\n M src/unstaged.ts\n?? src/new-file.ts\n"); + + const materializer = createReviewTargetMaterializer({ laneService }); + const result = await materializer.materialize({ + target: { mode: "working_tree", laneId: "lane-review" }, + config: makeConfig({ + selectionMode: "dirty_only", + dirtyOnly: true, + }), + }); + + expect(result.fullPatchText).toContain("## Staged changes"); + expect(result.fullPatchText).toContain("## Unstaged changes"); + expect(result.fullPatchText).toContain("## Untracked files"); + expect(result.artifacts.some((artifact) => artifact.artifactType === "untracked_snapshot")).toBe(true); + expect(result.changedFiles).toContainEqual(expect.objectContaining({ + filePath: "src/new-file.ts", + lineNumbers: [1], + })); + expect(result.changedFiles.find((file) => file.filePath === "src/new-file.ts")?.excerpt).toContain("new file mode 100644"); + } finally { + fs.rmSync(worktreePath, { recursive: true, force: true }); + } + }); + + it("materializes a PR target and prepares GitHub publication metadata", async () => { const laneService = { getLaneBaseAndBranch: vi.fn().mockReturnValue({ baseRef: "main", - branchRef: "feature/review-tab", - worktreePath, + branchRef: "feature/pr-80", + worktreePath: "/tmp/lane-review", laneType: "worktree", }), list: vi.fn(), } as any; - mockGit.runGit - .mockResolvedValueOnce({ - exitCode: 0, - stdout: "diff --git a/src/staged.ts b/src/staged.ts\n@@ -1,1 +1,2 @@\n+staged change\n", - stderr: "", - }) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: "diff --git a/src/unstaged.ts b/src/unstaged.ts\n@@ -1,1 +1,2 @@\n+unstaged change\n", - stderr: "", - }) - .mockResolvedValueOnce({ - exitCode: 0, - stdout: "M src/staged.ts\n M src/unstaged.ts\n?? src/new-file.ts\n", - stderr: "", - }); + const prService = { + getReviewSnapshot: vi.fn(async () => ({ + id: "pr-80", + laneId: "lane-review", + projectId: "project-1", + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 80, + githubUrl: "https://github.com/ade-dev/ade/pull/80", + githubNodeId: "PR_kwDOExample", + title: "Review publication", + state: "open", + baseBranch: "main", + headBranch: "feature/pr-80", + checksStatus: "passing", + reviewStatus: "commented", + additions: 2, + deletions: 0, + lastSyncedAt: "2026-04-06T10:00:00.000Z", + createdAt: "2026-04-06T09:55:00.000Z", + updatedAt: "2026-04-06T10:00:00.000Z", + baseSha: "abc123456789", + headSha: "def456789012", + files: [ + { + filename: "src/review.ts", + status: "modified", + additions: 2, + deletions: 0, + patch: "@@ -10,1 +10,3 @@\n context\n+anchored\n+summary only\n", + previousFilename: null, + }, + ], + })), + } as any; + mockGit.runGitOrThrow.mockResolvedValueOnce( + "diff --git a/src/review.ts b/src/review.ts\n@@ -10,1 +10,3 @@\n context\n+anchored\n+summary only\n", + ); - const materializer = createReviewTargetMaterializer({ laneService }); + const materializer = createReviewTargetMaterializer({ laneService, prService }); const result = await materializer.materialize({ - target: { mode: "working_tree", laneId: "lane-review" }, - config: makeConfig({ - selectionMode: "dirty_only", - dirtyOnly: true, - }), + target: { mode: "pr", laneId: "lane-review", prId: "pr-80" }, + config: makeConfig({ publishBehavior: "auto_publish" }), }); - expect(result.fullPatchText).toContain("## Staged changes"); - expect(result.fullPatchText).toContain("## Unstaged changes"); - expect(result.fullPatchText).toContain("## Untracked files"); - expect(result.artifacts.some((artifact) => artifact.artifactType === "untracked_snapshot")).toBe(true); - expect(result.changedFiles.some((file) => file.filePath === "src/new-file.ts")).toBe(true); + expect(prService.getReviewSnapshot).toHaveBeenCalledWith("pr-80"); + expect(result.targetLabel).toBe("PR #80 feature/pr-80 -> main"); + expect(result.publicationTarget).toEqual({ + kind: "github_pr_review", + prId: "pr-80", + repoOwner: "ade-dev", + repoName: "ade", + prNumber: 80, + githubUrl: "https://github.com/ade-dev/ade/pull/80", + }); + expect(result.changedFiles[0]?.diffPositionsByLine).toEqual({ 10: 1, 11: 2, 12: 3 }); }); }); diff --git a/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts b/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts index 733a49bb3..915b215b5 100644 --- a/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts +++ b/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts @@ -1,23 +1,29 @@ import fs from "node:fs"; import path from "node:path"; import type { + PrFile, + PrReviewSnapshot, + ReviewPublicationDestination, ReviewResolvedCompareTarget, ReviewRunArtifact, ReviewRunConfig, ReviewTarget, } from "../../../shared/types"; import type { createLaneService } from "../lanes/laneService"; +import type { createPrService } from "../prs/prService"; import { runGit, runGitOrThrow } from "../git/git"; type ReviewMaterializedFile = { filePath: string; excerpt: string; lineNumbers: number[]; + diffPositionsByLine: Record; }; export type ReviewMaterializedTarget = { targetLabel: string; compareTarget: ReviewResolvedCompareTarget | null; + publicationTarget: ReviewPublicationDestination | null; fullPatchText: string; changedFiles: ReviewMaterializedFile[]; artifacts: Array>; @@ -38,12 +44,20 @@ function readTextFileSafe(absPath: string, maxBytes: number): { exists: boolean; try { const stat = fs.statSync(absPath); if (!stat.isFile()) return { exists: false, text: "", isBinary: false }; - const buffer = fs.readFileSync(absPath); - if (buffer.includes(0)) { - return { exists: true, text: "", isBinary: true }; + const fd = fs.openSync(absPath, "r"); + try { + const bytesToRead = Math.max(1, Math.min(stat.size, maxBytes)); + const buffer = Buffer.alloc(bytesToRead); + const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, 0); + const slice = buffer.subarray(0, bytesRead); + if (slice.includes(0)) { + return { exists: true, text: "", isBinary: true }; + } + const text = slice.toString("utf8"); + return { exists: true, text: truncateText(text, maxBytes), isBinary: false }; + } finally { + fs.closeSync(fd); } - const text = buffer.toString("utf8"); - return { exists: true, text: truncateText(text, maxBytes), isBinary: false }; } catch { return { exists: false, text: "", isBinary: false }; } @@ -65,21 +79,30 @@ function parseNameStatus(stdout: string): string[] { } function parseDiffFiles(patchText: string, fallbackPaths: string[]): ReviewMaterializedFile[] { - const byPath = new Map }>(); + const byPath = new Map; diffPositionsByLine: Map }>(); for (const fallbackPath of fallbackPaths) { if (!byPath.has(fallbackPath)) { - byPath.set(fallbackPath, { lines: [], lineNumbers: new Set() }); + byPath.set(fallbackPath, { + lines: [], + lineNumbers: new Set(), + diffPositionsByLine: new Map(), + }); } } const lines = patchText.split(/\r?\n/); let currentPath: string | null = null; let currentNewLine: number | null = null; + let currentDiffPosition = 0; const ensureEntry = (filePath: string) => { const existing = byPath.get(filePath); if (existing) return existing; - const created = { lines: [] as string[], lineNumbers: new Set() }; + const created = { + lines: [] as string[], + lineNumbers: new Set(), + diffPositionsByLine: new Map(), + }; byPath.set(filePath, created); return created; }; @@ -91,6 +114,7 @@ function parseDiffFiles(patchText: string, fallbackPaths: string[]): ReviewMater const newPath = diffMatch[2] ?? ""; currentPath = newPath === "/dev/null" ? oldPath : newPath; currentNewLine = null; + currentDiffPosition = 0; continue; } if (!currentPath) continue; @@ -103,25 +127,30 @@ function parseDiffFiles(patchText: string, fallbackPaths: string[]): ReviewMater continue; } if (currentNewLine == null) continue; + if (line.length === 0) continue; + if (line.startsWith("\\")) continue; + + currentDiffPosition += 1; if (line.startsWith("+") && !line.startsWith("+++")) { entry.lineNumbers.add(currentNewLine); + entry.diffPositionsByLine.set(currentNewLine, currentDiffPosition); currentNewLine += 1; continue; } if (line.startsWith("-") && !line.startsWith("---")) { continue; } - if (!line.startsWith("\\")) { - entry.lineNumbers.add(currentNewLine); - currentNewLine += 1; - } + entry.lineNumbers.add(currentNewLine); + entry.diffPositionsByLine.set(currentNewLine, currentDiffPosition); + currentNewLine += 1; } return Array.from(byPath.entries()).map(([filePath, entry]) => ({ filePath, excerpt: truncateText(entry.lines.join("\n").trim(), 4_000), lineNumbers: Array.from(entry.lineNumbers).sort((a, b) => a - b), + diffPositionsByLine: Object.fromEntries(entry.diffPositionsByLine.entries()), })); } @@ -167,20 +196,69 @@ async function resolveDefaultCompareTarget(lane: LaneInfo): Promise { +function buildDiffArtifact( + contentText: string, + metadata: Record | null = null, +): Omit { return { artifactType: "diff_bundle", title: "Diff bundle", mimeType: "text/plain", contentText, - metadata: null, + metadata, + }; +} + +function buildUntrackedFileDiff(filePath: string, contentText: string): string { + const normalized = contentText.replace(/\r\n/g, "\n"); + const hasTrailingNewline = normalized.endsWith("\n"); + const rawLines = normalized.length > 0 ? normalized.split("\n") : []; + const lines = hasTrailingNewline ? rawLines.slice(0, -1) : rawLines; + const hunkSize = lines.length; + return [ + `diff --git a/${filePath} b/${filePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${filePath}`, + `@@ -0,0 +1,${hunkSize} @@`, + ...lines.map((line) => `+${line}`), + ].join("\n"); +} + +function buildPrFileDiff(file: PrFile): string { + const diffPath = file.previousFilename ?? file.filename; + const oldPath = file.status === "added" ? "/dev/null" : `a/${file.previousFilename ?? file.filename}`; + const newPath = file.status === "removed" ? "/dev/null" : `b/${file.filename}`; + const patchBody = file.patch?.trim() ?? ""; + return [ + `diff --git a/${diffPath} b/${file.filename}`, + `--- ${oldPath}`, + `+++ ${newPath}`, + patchBody, + ].join("\n"); +} + +function buildPrFullPatch(files: PrFile[]): string { + return files.map(buildPrFileDiff).join("\n\n").trim(); +} + +function buildPrPublicationTarget(summary: Pick): ReviewPublicationDestination { + return { + kind: "github_pr_review", + prId: summary.id, + repoOwner: summary.repoOwner, + repoName: summary.repoName, + prNumber: summary.githubPrNumber, + githubUrl: summary.githubUrl, }; } export function createReviewTargetMaterializer({ laneService, + prService, }: { laneService: Pick, "getLaneBaseAndBranch" | "list">; + prService?: Pick, "getReviewSnapshot">; }) { async function materializeLaneDiff(args: { target: Extract; @@ -219,6 +297,7 @@ export function createReviewTargetMaterializer({ return { targetLabel: `${sourceRef} vs ${compareTarget.label}`, compareTarget, + publicationTarget: null, fullPatchText: patchText, changedFiles, artifacts: [buildDiffArtifact(patchText)], @@ -245,6 +324,7 @@ export function createReviewTargetMaterializer({ return { targetLabel: `${normalizeBranchRef(lane.branchRef)} ${args.target.baseCommit.slice(0, 7)}..${args.target.headCommit.slice(0, 7)}`, compareTarget: null, + publicationTarget: null, fullPatchText: patchText, changedFiles, artifacts: [buildDiffArtifact(patchText)], @@ -256,26 +336,26 @@ export function createReviewTargetMaterializer({ }): Promise { const lane = laneService.getLaneBaseAndBranch(args.target.laneId); const branchRef = normalizeBranchRef(lane.branchRef); - const stagedPatch = await runGit(["diff", "--cached", "--no-color", "--find-renames"], { + const stagedPatch = await runGitOrThrow(["diff", "--cached", "--no-color", "--find-renames"], { cwd: lane.worktreePath, timeoutMs: 30_000, maxOutputBytes: 8 * 1024 * 1024, }); - const unstagedPatch = await runGit(["diff", "--no-color", "--find-renames"], { + const unstagedPatch = await runGitOrThrow(["diff", "--no-color", "--find-renames"], { cwd: lane.worktreePath, timeoutMs: 30_000, maxOutputBytes: 8 * 1024 * 1024, }); - const statusResult = await runGit(["status", "--porcelain=v1"], { + const statusResult = await runGitOrThrow(["status", "--porcelain=v1"], { cwd: lane.worktreePath, timeoutMs: 10_000, maxOutputBytes: 2 * 1024 * 1024, }); const untrackedArtifacts: Array> = []; - const untrackedSections: string[] = []; + const untrackedPatchSections: string[] = []; const fallbackPaths = new Set(); - const statusLines = statusResult.stdout + const statusLines = statusResult .split(/\r?\n/) .map((line) => line.trimEnd()) .filter(Boolean); @@ -292,7 +372,7 @@ export function createReviewTargetMaterializer({ const file = readTextFileSafe(absPath, 128_000); if (file.exists && !file.isBinary) { const contentText = file.text; - untrackedSections.push(`### Untracked file: ${normalizedPath}\n${contentText}`); + untrackedPatchSections.push(buildUntrackedFileDiff(normalizedPath, contentText)); untrackedArtifacts.push({ artifactType: "untracked_snapshot", title: `Untracked: ${normalizedPath}`, @@ -305,14 +385,14 @@ export function createReviewTargetMaterializer({ } const sections: string[] = []; - if (stagedPatch.stdout.trim()) { - sections.push(`## Staged changes\n${stagedPatch.stdout.trim()}`); + if (stagedPatch.trim()) { + sections.push(`## Staged changes\n${stagedPatch.trim()}`); } - if (unstagedPatch.stdout.trim()) { - sections.push(`## Unstaged changes\n${unstagedPatch.stdout.trim()}`); + if (unstagedPatch.trim()) { + sections.push(`## Unstaged changes\n${unstagedPatch.trim()}`); } - if (untrackedSections.length > 0) { - sections.push(`## Untracked files\n${untrackedSections.join("\n\n")}`); + if (untrackedPatchSections.length > 0) { + sections.push(`## Untracked files\n${untrackedPatchSections.join("\n\n")}`); } const fullPatchText = sections.join("\n\n").trim(); const changedFiles = parseDiffFiles(fullPatchText, Array.from(fallbackPaths)); @@ -320,12 +400,72 @@ export function createReviewTargetMaterializer({ return { targetLabel: `${branchRef} working tree`, compareTarget: null, + publicationTarget: null, fullPatchText, changedFiles, artifacts: [buildDiffArtifact(fullPatchText), ...untrackedArtifacts], }; } + async function materializePullRequest(args: { + target: Extract; + }): Promise { + if (!prService) { + throw new Error("PR review target is not available in this workspace."); + } + + const lane = laneService.getLaneBaseAndBranch(args.target.laneId); + const snapshot = await prService.getReviewSnapshot(args.target.prId); + const range = snapshot.baseSha && snapshot.headSha ? `${snapshot.baseSha}..${snapshot.headSha}` : null; + + let patchText = ""; + if (range) { + try { + patchText = await runGitOrThrow(["diff", "--no-color", "--find-renames", range], { + cwd: lane.worktreePath, + timeoutMs: 30_000, + maxOutputBytes: 8 * 1024 * 1024, + }); + } catch { + patchText = ""; + } + } + if (!patchText.trim()) { + patchText = buildPrFullPatch(snapshot.files); + } + + const changedFiles = parseDiffFiles( + patchText, + snapshot.files.map((file) => file.filename), + ); + const compareTarget: ReviewResolvedCompareTarget = { + kind: "default_branch", + label: snapshot.baseBranch, + ref: snapshot.baseSha ?? snapshot.baseBranch, + laneId: null, + branchRef: snapshot.baseBranch, + }; + + return { + targetLabel: `PR #${snapshot.githubPrNumber} ${snapshot.headBranch} -> ${snapshot.baseBranch}`, + compareTarget, + publicationTarget: buildPrPublicationTarget(snapshot), + fullPatchText: patchText, + changedFiles, + artifacts: [ + buildDiffArtifact(patchText, { + targetMode: "pr", + prId: snapshot.id, + githubPrNumber: snapshot.githubPrNumber, + repoOwner: snapshot.repoOwner, + repoName: snapshot.repoName, + baseSha: snapshot.baseSha, + headSha: snapshot.headSha, + }), + ], + }; + } + return { async materialize(args: { target: ReviewTarget; config: ReviewRunConfig }): Promise { if (args.target.mode === "lane_diff") { @@ -337,6 +477,9 @@ export function createReviewTargetMaterializer({ if (args.target.mode === "commit_range") { return materializeCommitRange({ target: args.target }); } + if (args.target.mode === "pr") { + return materializePullRequest({ target: args.target }); + } return materializeWorkingTree({ target: args.target }); }, }; diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 51b768f89..d0caa87fb 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -3007,6 +3007,27 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { db.run("create index if not exists idx_review_findings_run on review_findings(run_id)"); db.run("create index if not exists idx_review_findings_run_file on review_findings(run_id, file_path, line)"); + db.run(` + create table if not exists review_run_publications ( + id text primary key, + run_id text not null, + destination_json text not null, + review_event text not null, + status text not null, + review_url text, + remote_review_id text, + summary_body text not null, + inline_comments_json text not null default '[]', + summary_finding_ids_json text not null default '[]', + error_message text, + created_at text not null, + updated_at text not null, + completed_at text, + foreign key(run_id) references review_runs(id) on delete cascade + ) + `); + db.run("create index if not exists idx_review_run_publications_run on review_run_publications(run_id, created_at)"); + db.run(` create table if not exists review_run_artifacts ( id text primary key, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 0c88aef62..9f49096ed 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1426,7 +1426,7 @@ declare global { updateBody: (args: UpdatePrBodyArgs) => Promise; setLabels: (args: SetPrLabelsArgs) => Promise; requestReviewers: (args: RequestPrReviewersArgs) => Promise; - submitReview: (args: SubmitPrReviewArgs) => Promise; + submitReview: (args: SubmitPrReviewArgs) => Promise; close: (args: ClosePrArgs) => Promise; reopen: (args: ReopenPrArgs) => Promise; rerunChecks: (args: RerunPrChecksArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index fee272729..a33756404 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -99,6 +99,7 @@ describe("preload OAuth bridge", () => { await import("./preload"); const bridge = (globalThis as any).__adeBridge; + expect(bridge.review).toBeTruthy(); await bridge.review.listLaunchContext(); await bridge.review.listRuns({ laneId: "lane-1", limit: 5 }); await bridge.review.getRunDetail("run-1"); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 058392e53..1506d75bc 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -221,6 +221,7 @@ import type { SetPrLabelsArgs, RequestPrReviewersArgs, SubmitPrReviewArgs, + SubmitPrReviewResult, ClosePrArgs, ReopenPrArgs, RerunPrChecksArgs, @@ -2099,29 +2100,17 @@ contextBridge.exposeInMainWorld("ade", { args: ReplyToPrReviewThreadArgs, ): Promise => ipcRenderer.invoke(IPC.prsReplyToReviewThread, args), - resolveReviewThread: async ( - args: ResolvePrReviewThreadArgs, - ): Promise => ipcRenderer.invoke(IPC.prsResolveReviewThread, args), - updateTitle: async (args: UpdatePrTitleArgs): Promise => - ipcRenderer.invoke(IPC.prsUpdateTitle, args), - updateBody: async (args: UpdatePrBodyArgs): Promise => - ipcRenderer.invoke(IPC.prsUpdateBody, args), - setLabels: async (args: SetPrLabelsArgs): Promise => - ipcRenderer.invoke(IPC.prsSetLabels, args), - requestReviewers: async (args: RequestPrReviewersArgs): Promise => - ipcRenderer.invoke(IPC.prsRequestReviewers, args), - submitReview: async (args: SubmitPrReviewArgs): Promise => - ipcRenderer.invoke(IPC.prsSubmitReview, args), - close: async (args: ClosePrArgs): Promise => - ipcRenderer.invoke(IPC.prsClose, args), - reopen: async (args: ReopenPrArgs): Promise => - ipcRenderer.invoke(IPC.prsReopen, args), - rerunChecks: async (args: RerunPrChecksArgs): Promise => - ipcRenderer.invoke(IPC.prsRerunChecks, args), - aiReviewSummary: async ( - args: AiReviewSummaryArgs, - ): Promise => - ipcRenderer.invoke(IPC.prsAiReviewSummary, args), + resolveReviewThread: async (args: ResolvePrReviewThreadArgs): Promise => + ipcRenderer.invoke(IPC.prsResolveReviewThread, args), + updateTitle: async (args: UpdatePrTitleArgs): Promise => ipcRenderer.invoke(IPC.prsUpdateTitle, args), + updateBody: async (args: UpdatePrBodyArgs): Promise => ipcRenderer.invoke(IPC.prsUpdateBody, args), + setLabels: async (args: SetPrLabelsArgs): Promise => ipcRenderer.invoke(IPC.prsSetLabels, args), + requestReviewers: async (args: RequestPrReviewersArgs): Promise => ipcRenderer.invoke(IPC.prsRequestReviewers, args), + submitReview: async (args: SubmitPrReviewArgs): Promise => ipcRenderer.invoke(IPC.prsSubmitReview, args), + close: async (args: ClosePrArgs): Promise => ipcRenderer.invoke(IPC.prsClose, args), + reopen: async (args: ReopenPrArgs): Promise => ipcRenderer.invoke(IPC.prsReopen, args), + rerunChecks: async (args: RerunPrChecksArgs): Promise => ipcRenderer.invoke(IPC.prsRerunChecks, args), + aiReviewSummary: async (args: AiReviewSummaryArgs): Promise => ipcRenderer.invoke(IPC.prsAiReviewSummary, args), issueInventorySync: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsIssueInventorySync, { prId }), issueInventoryGet: async (prId: string): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 13a31611b..d86bf8cd9 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2138,6 +2138,7 @@ if (typeof window !== "undefined" && !(window as any).ade) { createdAt: now, }, ], + publications: [], chatSession: { sessionId: "chat-review-1", laneId: MOCK_LANES[1]?.id ?? "lane-auth", @@ -3196,7 +3197,13 @@ if (typeof window !== "undefined" && !(window as any).ade) { updateBody: resolvedArg(undefined), setLabels: resolvedArg(undefined), requestReviewers: resolvedArg(undefined), - submitReview: resolvedArg(undefined), + submitReview: resolvedArg({ + id: "pr-review-1", + nodeId: "PRR_mock_1", + htmlUrl: "https://github.com/mock/repo/pull/1#pullrequestreview-1", + state: "COMMENTED", + submittedAt: now, + }), close: resolvedArg(undefined), reopen: resolvedArg(undefined), rerunChecks: resolvedArg(undefined), diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index 21c18dfa3..b0e9bf578 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -9,6 +9,7 @@ import type { GitUpstreamSyncStatus, IssueInventoryItem, LaneSummary, + LandResult, PrActivityEvent, PrAiResolutionEventPayload, PrCheck, @@ -319,6 +320,7 @@ function renderPane(args: { laneArchived: false, error: null, }); + const startReviewRun = vi.fn().mockResolvedValue({ runId: "review-run-1" }); const onRefresh = vi.fn().mockResolvedValue(undefined); let currentConvergence: PrConvergenceState | null = args.convergenceState ?? null; const loadConvergenceState = vi.fn().mockImplementation(async () => currentConvergence); @@ -383,6 +385,9 @@ function renderPane(args: { land, openInGitHub: vi.fn().mockResolvedValue(undefined), }, + review: { + startRun: startReviewRun, + }, lanes: { list: vi.fn().mockResolvedValue(laneList), }, @@ -423,6 +428,7 @@ function renderPane(args: { saveConvergenceState, resetConvergenceState, writeClipboardText, + startReviewRun, land, onRefresh, ...render( @@ -474,6 +480,48 @@ describe("PrDetailPane issue resolver CTA", () => { }); }); + it("launches a PR-backed review run and navigates to the saved Review history entry", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const { startReviewRun } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + onNavigate, + }); + + await user.click(screen.getByRole("button", { name: /run ade review/i })); + + await waitFor(() => { + expect(startReviewRun).toHaveBeenCalledWith({ + target: { mode: "pr", laneId: "lane-1", prId: "pr-80" }, + config: { publishBehavior: "auto_publish" }, + }); + expect(onNavigate).toHaveBeenCalledWith("/review?runId=review-run-1"); + }); + }); + + it("keeps the ADE review label stable while another action holds the shared busy state", async () => { + const user = userEvent.setup(); + const mergePromise = new Promise(() => {}); + const { land } = renderPane({ + checks: [makeCheck({ conclusion: "success" })], + reviewThreads: [], + statusOverrides: { + checksStatus: "passing", + reviewStatus: "approved", + isMergeable: true, + mergeConflicts: false, + }, + }); + land.mockReturnValueOnce(mergePromise); + + await user.click(screen.getByRole("button", { name: /merge pull request/i })); + + await waitFor(() => expect(land).toHaveBeenCalled()); + expect(screen.getByRole("button", { name: /run ade review/i }).hasAttribute("disabled")).toBe(true); + expect(screen.queryByText("Launching...")).toBeNull(); + }); + it("shows the resolve action in the checks tab when issues are actionable", async () => { const user = userEvent.setup(); renderPane({ diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 8481bf3fc..ee9039d6c 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -518,6 +518,7 @@ export function PrDetailPane({ }, [prsTimelineRailsEnabled, activeTab]); const [aiSummary, setAiSummary] = React.useState(null); const [aiSummaryBusy, setAiSummaryBusy] = React.useState(false); + const [adeReviewBusy, setAdeReviewBusy] = React.useState(false); const [showIssueResolverModal, setShowIssueResolverModal] = React.useState(false); const [issueResolverBusy, setIssueResolverBusy] = React.useState(false); const [issueResolverCopyBusy, setIssueResolverCopyBusy] = React.useState(false); @@ -855,6 +856,37 @@ export function PrDetailPane({ } finally { setAiSummaryBusy(false); } }; + const handleRunAdeReview = async () => { + setAdeReviewBusy(true); + try { + await runAction(async () => { + const reviewBridge = window.ade.review; + if (!reviewBridge) { + throw new Error("Review bridge is unavailable."); + } + const result = await reviewBridge.startRun({ + target: { mode: "pr", laneId: pr.laneId, prId: pr.id }, + config: { publishBehavior: "auto_publish" }, + }); + const nextRunId = typeof result === "string" + ? result + : result && typeof result === "object" + ? ("runId" in result && typeof result.runId === "string" + ? result.runId + : "id" in result && typeof result.id === "string" + ? result.id + : null) + : null; + if (!nextRunId) { + throw new Error("Review launch did not return a run id."); + } + onNavigate(`/review?runId=${encodeURIComponent(nextRunId)}`); + }); + } finally { + setAdeReviewBusy(false); + } + }; + const laneForPr = React.useMemo( () => lanes.find((lane) => lane.id === pr.laneId && !lane.archivedAt) ?? null, [lanes, pr.laneId], @@ -1999,7 +2031,7 @@ export function PrDetailPane({ {activeTab === "overview" && !prsTimelineRailsEnabled && ( void; onReopen: () => void; onAiSummary: () => void; + onRunAdeReview: () => void; onNavigate: (path: string) => void; onOpenRebaseTab?: (laneId?: string) => void; matchingRebaseItemId: string | null; @@ -2383,7 +2418,7 @@ type OverviewTabProps = { }; function OverviewTab(props: OverviewTabProps) { - const { pr, detail, status, checks, actionRuns, reviews, comments, aiSummary, aiSummaryBusy, actionBusy, mergeMethod, activity, lanes } = props; + const { pr, detail, status, checks, actionRuns, reviews, comments, aiSummary, aiSummaryBusy, adeReviewBusy, actionBusy, mergeMethod, activity, lanes } = props; const [checksExpanded, setChecksExpanded] = React.useState(false); const [localMergeMethod, setLocalMergeMethod] = React.useState(mergeMethod); const [allowBlockedMerge, setAllowBlockedMerge] = React.useState(false); @@ -2972,6 +3007,10 @@ function OverviewTab(props: OverviewTabProps) {
{/* Quick actions */}
+
+ {launchDraft.laneId && selectedLaneCommits.length < 2 ? ( +
+ At least two recent commits are needed to auto-fill this range. Enter the base and head SHAs manually or choose a lane with more history. +
+ ) : null}
) : null} @@ -767,7 +798,7 @@ export function ReviewPage() {
- Ticket 1 uses local-only publishing. The review run is persisted first, and the transcript is attached as supporting context. + Reviews are saved locally. The transcript is attached as supporting context.
@@ -880,6 +911,40 @@ export function ReviewPage() { + {selectedDetail?.publications?.length ? ( + +
+ {selectedDetail.publications.map((publication) => ( +
+
+ {publication.destination.kind} + + {publication.status} + +
+ {publication.destination.repoOwner}/{publication.destination.repoName} #{publication.destination.prNumber} +
+
+
+ + + + + + +
+ {publication.errorMessage ? ( +
{publication.errorMessage}
+ ) : null} +
+                      {publication.summaryBody}
+                    
+
+ ))} +
+
+ ) : null} +
{selectedDetail?.findings?.length ? selectedDetail.findings.map((finding, index) => { @@ -1018,18 +1083,6 @@ export function ReviewPage() { }, }; - if (error && runs.length === 0 && !loadingRuns) { - return ( -
- -
- ); - } - return (
Review
-
Local review runs, findings, evidence, and transcript history.
+
Saved review runs, findings, publication records, and transcript history.
@@ -1079,6 +1132,12 @@ export function ReviewPage() {
+ {error && !loadingRuns ? ( +
+ {error} +
+ ) : null} +
{ const bridge = getReviewBridge(); if (!bridge) return []; - return bridge.listRuns(args).catch(() => []); + return bridge.listRuns(args); } export async function getReviewRunDetail(runId: string): Promise { const bridge = getReviewBridge(); if (!bridge) return null; - return bridge.getRunDetail(runId).catch(() => null); + return bridge.getRunDetail(runId); } export async function startReviewRun(args: ReviewStartRunArgs): Promise<{ runId: string | null }> { const bridge = getReviewBridge(); if (!bridge) return { runId: null }; - const result = await bridge.startRun(args).catch(() => null); + const result = await bridge.startRun(args); if (typeof result === "string") return { runId: result }; if (result && typeof result === "object") { const maybe = result as { runId?: string; id?: string }; @@ -48,7 +48,7 @@ export async function startReviewRun(args: ReviewStartRunArgs): Promise<{ runId: export async function rerunReview(runId: string): Promise<{ runId: string | null }> { const bridge = getReviewBridge(); if (!bridge) return { runId: null }; - const result = await bridge.rerun(runId).catch(() => null); + const result = await bridge.rerun(runId); if (typeof result === "string") return { runId: result }; if (result && typeof result === "object") { const maybe = result as { runId?: string; id?: string }; @@ -60,7 +60,7 @@ export async function rerunReview(runId: string): Promise<{ runId: string | null export async function listReviewLaunchContext(): Promise { const bridge = getReviewBridge(); if (!bridge) return null; - return bridge.listLaunchContext().catch(() => null); + return bridge.listLaunchContext(); } export function onReviewEvent(listener: (event: ReviewEventPayload) => void): () => void { diff --git a/apps/desktop/src/renderer/components/review/reviewRouteState.test.ts b/apps/desktop/src/renderer/components/review/reviewRouteState.test.ts new file mode 100644 index 000000000..5581f07c1 --- /dev/null +++ b/apps/desktop/src/renderer/components/review/reviewRouteState.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { buildReviewSearch, readReviewRunId } from "./reviewRouteState"; + +describe("reviewRouteState", () => { + it("omits whitespace-only run ids from the search string", () => { + expect(buildReviewSearch(" ")).toBe(""); + }); + + it("round-trips a trimmed run id", () => { + const search = buildReviewSearch(" run-123 "); + expect(search).toBe("?runId=run-123"); + expect(readReviewRunId(search)).toBe("run-123"); + }); +}); diff --git a/apps/desktop/src/renderer/components/review/reviewRouteState.ts b/apps/desktop/src/renderer/components/review/reviewRouteState.ts index 55f70e431..e75b2c50a 100644 --- a/apps/desktop/src/renderer/components/review/reviewRouteState.ts +++ b/apps/desktop/src/renderer/components/review/reviewRouteState.ts @@ -6,8 +6,8 @@ export function readReviewRunId(search: string): string | null { export function buildReviewSearch(runId: string | null): string { const params = new URLSearchParams(); - if (runId) params.set("runId", runId); + const trimmedRunId = runId?.trim(); + if (trimmedRunId) params.set("runId", trimmedRunId); const next = params.toString(); return next.length ? `?${next}` : ""; } - diff --git a/apps/desktop/src/renderer/components/review/reviewTypes.ts b/apps/desktop/src/renderer/components/review/reviewTypes.ts index 57425c0f4..115e2a39c 100644 --- a/apps/desktop/src/renderer/components/review/reviewTypes.ts +++ b/apps/desktop/src/renderer/components/review/reviewTypes.ts @@ -11,7 +11,12 @@ export type { ReviewLaunchContext, ReviewLaunchLane, ReviewListRunsArgs, + ReviewPublication, + ReviewPublicationDestination, + ReviewPublicationInlineComment, ReviewPublicationState, + ReviewPublicationStatus, + ReviewPublishBehavior, ReviewResolvedCompareTarget, ReviewRun, ReviewRunArtifact, diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index 343c16a5c..30f4671c5 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -941,6 +941,12 @@ export type PrFile = { previousFilename: string | null; }; +export type PrReviewSnapshot = PrSummary & { + baseSha: string | null; + headSha: string | null; + files: PrFile[]; +}; + /** GitHub Actions workflow run. */ export type PrActionRun = { id: number; @@ -1016,6 +1022,19 @@ export type SubmitPrReviewArgs = { prId: string; event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; + comments?: Array<{ + path: string; + body: string; + position: number; + }>; +}; + +export type SubmitPrReviewResult = { + id: string | null; + nodeId: string | null; + htmlUrl: string | null; + state: string | null; + submittedAt: string | null; }; export type ClosePrArgs = { diff --git a/apps/desktop/src/shared/types/review.ts b/apps/desktop/src/shared/types/review.ts index 0a91cf6be..a1e7661e5 100644 --- a/apps/desktop/src/shared/types/review.ts +++ b/apps/desktop/src/shared/types/review.ts @@ -4,14 +4,57 @@ import type { AgentChatSessionSummary } from "./chat"; // Review types // --------------------------------------------------------------------------- -export type ReviewTargetMode = "lane_diff" | "commit_range" | "working_tree"; +export type ReviewTargetMode = "lane_diff" | "commit_range" | "working_tree" | "pr"; export type ReviewRunStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; export type ReviewSeverity = "critical" | "high" | "medium" | "low" | "info"; export type ReviewAnchorState = "anchored" | "file_only" | "missing"; export type ReviewPublicationState = "local_only" | "published"; export type ReviewSourcePass = "single_pass" | "adjudicated"; export type ReviewSelectionMode = "full_diff" | "selected_commits" | "dirty_only"; -export type ReviewArtifactType = "prompt" | "diff_bundle" | "review_output" | "untracked_snapshot"; +export type ReviewPublishBehavior = "local_only" | "auto_publish"; +export type ReviewPublicationStatus = "published" | "failed"; +export type ReviewArtifactType = + | "prompt" + | "diff_bundle" + | "review_output" + | "untracked_snapshot" + | "publication_request" + | "publication_result"; + +export type ReviewPublicationDestination = + | { + kind: "github_pr_review"; + prId: string; + repoOwner: string; + repoName: string; + prNumber: number; + githubUrl: string | null; + }; + +export type ReviewPublicationInlineComment = { + findingId: string; + path: string; + line: number; + position: number; + body: string; +}; + +export type ReviewPublication = { + id: string; + runId: string; + destination: ReviewPublicationDestination; + reviewEvent: "COMMENT"; + status: ReviewPublicationStatus; + reviewUrl: string | null; + remoteReviewId: string | null; + summaryBody: string; + inlineComments: ReviewPublicationInlineComment[]; + summaryFindingIds: string[]; + errorMessage: string | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; +}; export type ReviewCompareAgainstTarget = | { @@ -44,7 +87,7 @@ export type ReviewRunConfig = { modelId: string; reasoningEffort: string | null; budgets: ReviewRunBudgetConfig; - publishBehavior: "local_only"; + publishBehavior: ReviewPublishBehavior; }; export type ReviewTarget = @@ -61,6 +104,11 @@ export type ReviewTarget = | { mode: "working_tree"; laneId: string; + } + | { + mode: "pr"; + laneId: string; + prId: string; }; export type ReviewEvidence = { @@ -154,6 +202,7 @@ export type ReviewLaunchContext = { export type ReviewRunDetail = ReviewRun & { findings: ReviewFinding[]; artifacts: ReviewRunArtifact[]; + publications: ReviewPublication[]; chatSession: AgentChatSessionSummary | null; }; diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 5bff154f5..c4c7ff96b 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -2323,6 +2323,88 @@ create index if not exists idx_budget_usage_records_week on budget_usage_records create index if not exists idx_budget_usage_records_provider_week on budget_usage_records(provider, week_key); +create table if not exists review_runs ( + id text primary key, + project_id text not null, + lane_id text not null, + target_json text not null, + config_json text not null, + target_label text not null, + compare_target_json text, + status text not null, + summary text, + error_message text, + finding_count integer not null default 0, + severity_summary_json text, + chat_session_id text, + created_at text not null, + started_at text not null, + ended_at text, + updated_at text not null, + foreign key(project_id) references projects(id), + foreign key(lane_id) references lanes(id) + ); + +create index if not exists idx_review_runs_project_created on review_runs(project_id, created_at desc); + +create index if not exists idx_review_runs_lane_created on review_runs(lane_id, created_at desc); + +create index if not exists idx_review_runs_project_status on review_runs(project_id, status); + +create table if not exists review_findings ( + id text primary key, + run_id text not null, + title text not null, + severity text not null, + body text not null, + confidence real not null default 0.5, + evidence_json text, + file_path text, + line integer, + anchor_state text not null, + source_pass text not null, + publication_state text not null, + foreign key(run_id) references review_runs(id) on delete cascade + ); + +create index if not exists idx_review_findings_run on review_findings(run_id); + +create index if not exists idx_review_findings_run_file on review_findings(run_id, file_path, line); + +create table if not exists review_run_publications ( + id text primary key, + run_id text not null, + destination_json text not null, + review_event text not null, + status text not null, + review_url text, + remote_review_id text, + summary_body text not null, + inline_comments_json text not null default '[]', + summary_finding_ids_json text not null default '[]', + error_message text, + created_at text not null, + updated_at text not null, + completed_at text, + foreign key(run_id) references review_runs(id) on delete cascade + ); + +create index if not exists idx_review_run_publications_run on review_run_publications(run_id, created_at); + +create table if not exists review_run_artifacts ( + id text primary key, + run_id text not null, + artifact_type text not null, + title text not null, + mime_type text not null, + content_text text, + metadata_json text, + created_at text not null, + foreign key(run_id) references review_runs(id) on delete cascade + ); + +create index if not exists idx_review_run_artifacts_run on review_run_artifacts(run_id, created_at); + create table if not exists pr_issue_inventory ( id text primary key, pr_id text not null, From 1af97129b9fdf1aeb4f207dbaede7b32b573c808 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 9 Apr 2026 03:02:41 -0400 Subject: [PATCH 03/16] feat: multi-pass review engine with adjudication, context builder, and rule registry Adds multi-pass review pipeline (diff-risk, cross-file-impact, checks-and-tests) with finding deduplication and adjudication scoring. Introduces reviewContextBuilder for structured prompt assembly, reviewRuleRegistry for pass-specific rule overlays, and publication budget controls. Extends review findings schema with finding_class, originating_passes, and adjudication metadata. Updates ReviewPage UI with expanded findings display and PrDetailPane issue resolver improvements. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/main/main.ts | 3 + .../prs/prService.reviewPublication.test.ts | 20 +- .../review/reviewContextBuilder.test.ts | 540 +++++++ .../services/review/reviewContextBuilder.ts | 848 +++++++++++ .../services/review/reviewRuleRegistry.ts | 255 ++++ .../services/review/reviewService.test.ts | 1257 ++++++++++++----- .../src/main/services/review/reviewService.ts | 1155 +++++++++++++-- apps/desktop/src/main/services/state/kvDb.ts | 6 + .../PrDetailPane.issueResolver.test.tsx | 7 +- .../components/prs/detail/PrDetailPane.tsx | 31 +- .../components/review/ReviewPage.test.tsx | 168 ++- .../renderer/components/review/ReviewPage.tsx | 873 ++++++++++-- .../renderer/components/review/reviewTypes.ts | 3 + .../shared/ReviewLaunchModelControls.tsx | 67 + apps/desktop/src/shared/types/review.ts | 23 + apps/ios/ADE/Resources/DatabaseBootstrap.sql | 9 + 16 files changed, 4645 insertions(+), 620 deletions(-) create mode 100644 apps/desktop/src/main/services/review/reviewContextBuilder.test.ts create mode 100644 apps/desktop/src/main/services/review/reviewContextBuilder.ts create mode 100644 apps/desktop/src/main/services/review/reviewRuleRegistry.ts create mode 100644 apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 68d498e74..a7c8c0555 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2011,6 +2011,9 @@ app.whenReady().then(async () => { gitService, agentChatService, sessionService, + sessionDeltaService, + testService, + issueInventoryService, prService, onEvent: (event) => emitProjectEvent(projectRoot, IPC.reviewEvent, event), }); diff --git a/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts b/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts index 0f6c9c90f..261e1f6f7 100644 --- a/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts +++ b/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts @@ -149,8 +149,16 @@ describe("prService.publishReviewPublication", () => { filePath: "src/review.ts", line: 11, anchorState: "anchored", - sourcePass: "single_pass", + sourcePass: "adjudicated", publicationState: "local_only", + originatingPasses: ["diff-risk", "cross-file-impact"], + adjudication: { + score: 8.2, + candidateCount: 2, + mergedFindingIds: ["raw-1", "raw-2"], + rationale: "Merged overlapping findings from diff-risk and cross-file-impact.", + publicationEligible: true, + }, }, { id: "finding-summary", @@ -163,8 +171,16 @@ describe("prService.publishReviewPublication", () => { filePath: "src/review.ts", line: 200, anchorState: "file_only", - sourcePass: "single_pass", + sourcePass: "adjudicated", publicationState: "local_only", + originatingPasses: ["checks-and-tests"], + adjudication: { + score: 5.7, + candidateCount: 1, + mergedFindingIds: ["raw-3"], + rationale: "Accepted because the finding carried concrete evidence and cleared the adjudication threshold.", + publicationEligible: true, + }, }, ], changedFiles: [ diff --git a/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts b/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts new file mode 100644 index 000000000..24b74119b --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewContextBuilder.test.ts @@ -0,0 +1,540 @@ +import path from "node:path"; +import { createRequire } from "node:module"; +import initSqlJs from "sql.js"; +import type { Database, SqlJsStatic } from "sql.js"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { createReviewContextBuilder } from "./reviewContextBuilder"; + +type SqlValue = string | number | null | Uint8Array; +type AdeDb = { + run: (sql: string, params?: SqlValue[]) => void; + get: = Record>(sql: string, params?: SqlValue[]) => T | null; + all: = Record>(sql: string, params?: SqlValue[]) => T[]; +}; + +function mapExecRows(rows: { columns: string[]; values: unknown[][] }[]): Record[] { + const first = rows[0]; + if (!first) return []; + return first.values.map((row) => { + const out: Record = {}; + first.columns.forEach((column, index) => { + out[column] = row[index]; + }); + return out; + }); +} + +let SQL: SqlJsStatic; + +beforeAll(async () => { + const require = createRequire(import.meta.url); + const wasmPath = require.resolve("sql.js/dist/sql-wasm.wasm"); + const wasmDir = path.dirname(wasmPath); + SQL = await initSqlJs({ + locateFile: (file) => path.join(wasmDir, file), + }); +}); + +function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { + const raw = new SQL.Database(); + raw.run(` + create table lanes( + id text primary key, + project_id text not null, + mission_id text + ); + `); + raw.run(` + create table missions( + id text primary key, + project_id text not null, + title text, + prompt text, + status text, + outcome_summary text, + updated_at text + ); + `); + raw.run(` + create table orchestrator_worker_digests( + id text primary key, + project_id text not null, + mission_id text not null, + run_id text not null, + step_id text not null, + attempt_id text not null, + lane_id text, + session_id text, + step_key text, + status text not null, + summary text not null, + files_changed_json text, + tests_run_json text, + warnings_json text, + tokens_json text, + cost_usd real, + suggested_next_actions_json text, + created_at text not null + ); + `); + raw.run(` + create table pull_requests( + id text primary key, + project_id text not null, + lane_id text not null, + repo_owner text not null, + repo_name text not null, + github_pr_number integer not null, + github_url text not null, + title text, + state text not null, + updated_at text not null + ); + `); + raw.run(` + create table review_runs( + id text primary key, + project_id text not null, + lane_id text not null, + summary text, + status text not null, + finding_count integer not null default 0, + created_at text not null + ); + `); + raw.run(` + create table review_findings( + id text primary key, + run_id text not null, + file_path text + ); + `); + raw.run(` + create table review_run_publications( + id text primary key, + run_id text not null + ); + `); + + const run = (sql: string, params: SqlValue[] = []) => raw.run(sql, params); + const all = = Record>(sql: string, params: SqlValue[] = []): T[] => + mapExecRows(raw.exec(sql, params)) as T[]; + const get = = Record>(sql: string, params: SqlValue[] = []): T | null => + all(sql, params)[0] ?? null; + return { raw, db: { run, all, get } }; +} + +function makeRun() { + return { + id: "run-current", + projectId: "project-1", + laneId: "lane-review", + target: { mode: "lane_diff", laneId: "lane-review" }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: "openai/gpt-5.4-codex", + reasoningEffort: "medium", + budgets: { + maxFiles: 60, + maxDiffChars: 180_000, + maxPromptChars: 220_000, + maxFindings: 12, + maxFindingsPerPass: 6, + maxPublishedFindings: 6, + }, + publishBehavior: "local_only", + }, + targetLabel: "lane-review vs main", + compareTarget: null, + status: "running", + summary: null, + errorMessage: null, + findingCount: 0, + severitySummary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, + chatSessionId: null, + createdAt: "2026-04-06T10:00:00.000Z", + startedAt: "2026-04-06T10:00:00.000Z", + endedAt: null, + updatedAt: "2026-04-06T10:00:00.000Z", + } as const; +} + +function makeMaterialized(changedPaths: string[]) { + return { + targetLabel: "lane-review vs main", + compareTarget: null, + publicationTarget: null, + fullPatchText: "diff --git a/file b/file", + changedFiles: changedPaths.map((filePath) => ({ + filePath, + excerpt: `@@ -1 +1 @@\n+${filePath}`, + lineNumbers: [1], + diffPositionsByLine: { 1: 1 }, + })), + artifacts: [], + }; +} + +describe("reviewContextBuilder", () => { + it("builds a bounded compact packet from ADE-native provenance, rules, and validation signals", async () => { + const { db } = createInMemoryAdeDb(); + db.run("insert into lanes(id, project_id, mission_id) values (?, ?, ?)", ["lane-review", "project-1", "mission-1"]); + db.run( + "insert into missions(id, project_id, title, prompt, status, outcome_summary, updated_at) values (?, ?, ?, ?, ?, ?, ?)", + [ + "mission-1", + "project-1", + "Keep preload and renderer aligned", + "This is a very long mission prompt that should be clipped in the compact provenance packet because raw prompt bloat is not review-safe and should never be copied wholesale into prompts or artifacts.", + "running", + "Workers are still converging on the bridge rollout.", + "2026-04-06T09:59:00.000Z", + ], + ); + for (let index = 0; index < 4; index += 1) { + db.run( + ` + insert into orchestrator_worker_digests( + id, project_id, mission_id, run_id, step_id, attempt_id, lane_id, session_id, step_key, status, + summary, files_changed_json, tests_run_json, warnings_json, tokens_json, cost_usd, suggested_next_actions_json, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + `digest-${index}`, + "project-1", + "mission-1", + "orchestrator-run-1", + `step-${index}`, + `attempt-${index}`, + "lane-review", + `session-${index}`, + `step-${index}`, + index === 0 ? "failed" : "succeeded", + `Worker digest ${index} repeated text ${"x".repeat(80)}`, + JSON.stringify(["apps/desktop/src/preload/reviewBridge.ts"]), + JSON.stringify({ passed: 0, failed: 1, skipped: 0, summary: "bridge unit failed" }), + JSON.stringify(["warning one", "warning two"]), + null, + null, + JSON.stringify(["fix the bridge"]), + `2026-04-06T10:0${index}:00.000Z`, + ], + ); + } + db.run( + ` + insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, title, state, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "pr-1", + "project-1", + "lane-review", + "ade-dev", + "ade", + 26, + "https://github.com/ade-dev/ade/pull/26", + "Bridge rollout", + "open", + "2026-04-06T10:05:00.000Z", + ], + ); + db.run( + "insert into review_runs(id, project_id, lane_id, summary, status, finding_count, created_at) values (?, ?, ?, ?, ?, ?, ?)", + ["run-prior", "project-1", "lane-review", "Prior ADE review on the same bridge path", "completed", 1, "2026-04-05T15:00:00.000Z"], + ); + db.run("insert into review_findings(id, run_id, file_path) values (?, ?, ?)", [ + "finding-prior", + "run-prior", + "apps/desktop/src/preload/reviewBridge.ts", + ]); + db.run("insert into review_run_publications(id, run_id) values (?, ?)", ["publication-prior", "run-prior"]); + + const builder = createReviewContextBuilder({ + db: db as any, + projectId: "project-1", + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any, + laneService: { + getStateSnapshot: vi.fn().mockReturnValue({ + laneId: "lane-review", + agentSummary: { summary: "Recent ADE chat distilled the bridge rollout intent." }, + missionSummary: { summary: "Finish the preload bridge rollout cleanly." }, + updatedAt: "2026-04-06T09:58:00.000Z", + }), + } as any, + sessionDeltaService: { + listRecentLaneSessionDeltas: vi.fn().mockReturnValue([ + { + sessionId: "delta-1", + laneId: "lane-review", + startedAt: "2026-04-06T09:40:00.000Z", + endedAt: "2026-04-06T09:50:00.000Z", + headShaStart: null, + headShaEnd: null, + filesChanged: 1, + insertions: 10, + deletions: 2, + touchedFiles: ["apps/desktop/src/preload/reviewBridge.ts"], + failureLines: ["AssertionError: bridge mismatch", "Traceback: renderer bridge failed"], + computedAt: "2026-04-06T09:50:10.000Z", + }, + { + sessionId: "delta-2", + laneId: "lane-review", + startedAt: "2026-04-06T09:20:00.000Z", + endedAt: "2026-04-06T09:25:00.000Z", + headShaStart: null, + headShaEnd: null, + filesChanged: 2, + insertions: 5, + deletions: 1, + touchedFiles: ["apps/desktop/src/shared/ipc.ts"], + failureLines: ["Error: shared IPC shape drifted"], + computedAt: "2026-04-06T09:25:10.000Z", + }, + { + sessionId: "delta-3", + laneId: "lane-review", + startedAt: "2026-04-06T09:00:00.000Z", + endedAt: "2026-04-06T09:05:00.000Z", + headShaStart: null, + headShaEnd: null, + filesChanged: 1, + insertions: 3, + deletions: 0, + touchedFiles: ["apps/desktop/src/renderer/review/ReviewPanel.tsx"], + failureLines: ["Error: renderer still expects old bridge"], + computedAt: "2026-04-06T09:05:10.000Z", + }, + { + sessionId: "delta-4", + laneId: "lane-review", + startedAt: "2026-04-06T08:00:00.000Z", + endedAt: "2026-04-06T08:05:00.000Z", + headShaStart: null, + headShaEnd: null, + filesChanged: 1, + insertions: 1, + deletions: 1, + touchedFiles: ["unrelated.ts"], + failureLines: ["Error: unrelated"], + computedAt: "2026-04-06T08:05:10.000Z", + }, + ]), + } as any, + testService: { + listSuites: vi.fn().mockReturnValue([{ id: "unit" }, { id: "lint" }, { id: "e2e" }]), + listRuns: vi.fn().mockReturnValue([ + { + id: "test-run-1", + suiteId: "unit", + suiteName: "Unit", + laneId: "lane-review", + status: "failed", + exitCode: 1, + durationMs: 1200, + startedAt: "2026-04-06T10:00:00.000Z", + endedAt: "2026-04-06T10:02:00.000Z", + logPath: "/tmp/test-run-1.log", + }, + ]), + getLogTail: vi.fn().mockReturnValue( + `${"noise ".repeat(200)}\nAssertionError: bridge mismatch repeated ${"y".repeat(200)}\n`, + ), + } as any, + issueInventoryService: { + getInventory: vi.fn().mockReturnValue({ + prId: "pr-1", + items: Array.from({ length: 6 }, (_, index) => ({ + id: `inventory-${index}`, + prId: "pr-1", + source: "human", + type: "review_thread", + externalId: `thread-${index}`, + state: "new", + round: 1, + filePath: index === 0 ? "apps/desktop/src/preload/reviewBridge.ts" : "apps/desktop/src/renderer/review/ReviewPanel.tsx", + line: 20 + index, + severity: "major", + headline: `Review feedback ${index}`, + body: `Feedback body ${index}`, + author: "reviewer", + url: null, + dismissReason: null, + agentSessionId: null, + createdAt: "2026-04-06T10:01:00.000Z", + updatedAt: `2026-04-06T10:0${index}:00.000Z`, + })), + convergence: { currentRound: 1 }, + runtime: { currentRound: 1 }, + }), + } as any, + prService: { + getChecks: vi.fn().mockResolvedValue([ + { + name: "unit-tests", + status: "completed", + conclusion: "failure", + detailsUrl: "https://ci.example/unit-tests", + startedAt: "2026-04-06T10:00:00.000Z", + completedAt: "2026-04-06T10:03:00.000Z", + }, + { + name: "lint", + status: "completed", + conclusion: "success", + detailsUrl: null, + startedAt: "2026-04-06T10:04:00.000Z", + completedAt: "2026-04-06T10:05:00.000Z", + }, + ]), + getReviewSnapshot: vi.fn().mockResolvedValue({ + id: "pr-1", + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 26, + githubUrl: "https://github.com/ade-dev/ade/pull/26", + baseBranch: "main", + headBranch: "feature/bridge", + baseSha: "abc123", + headSha: "def456", + files: [{ filename: "apps/desktop/src/preload/reviewBridge.ts" }], + }), + } as any, + }); + + const packet = await builder.buildContext({ + run: makeRun() as any, + materialized: makeMaterialized([ + "apps/desktop/src/preload/reviewBridge.ts", + "apps/desktop/src/shared/ipc.ts", + ]) as any, + }); + + expect(packet.provenance.payload.workerDigests).toHaveLength(3); + expect(packet.provenance.payload.sessionDeltas).toHaveLength(3); + expect(packet.validation.payload.issueInventory).toHaveLength(5); + expect(packet.validation.payload.signals.length).toBeLessThanOrEqual(5); + expect(packet.provenance.payload.missions[0]?.intentSummary?.length ?? 0).toBeLessThanOrEqual(220); + expect(packet.validation.payload.testRuns[0]?.logExcerpt?.length ?? 0).toBeLessThanOrEqual(220); + expect(packet.validation.prompt).not.toContain("noise noise noise noise noise noise"); + expect(packet.rules.metadata.matchedRuleIds).toContain("preload-bridge"); + expect(packet.rules.metadata.matchedRuleIds).toContain("shared-contract"); + }); + + it("emits late-stage signals for validation failures, reviewer feedback, and prior review overlap", async () => { + const { db } = createInMemoryAdeDb(); + db.run("insert into lanes(id, project_id, mission_id) values (?, ?, ?)", ["lane-review", "project-1", null]); + db.run( + ` + insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, title, state, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ["pr-1", "project-1", "lane-review", "ade-dev", "ade", 26, "https://github.com/ade-dev/ade/pull/26", "Bridge rollout", "open", "2026-04-06T10:05:00.000Z"], + ); + db.run( + "insert into review_runs(id, project_id, lane_id, summary, status, finding_count, created_at) values (?, ?, ?, ?, ?, ?, ?)", + ["run-prior", "project-1", "lane-review", "Prior review flagged the bridge", "completed", 1, "2026-04-05T15:00:00.000Z"], + ); + db.run("insert into review_findings(id, run_id, file_path) values (?, ?, ?)", [ + "finding-prior", + "run-prior", + "apps/desktop/src/preload/reviewBridge.ts", + ]); + + const builder = createReviewContextBuilder({ + db: db as any, + projectId: "project-1", + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + laneService: { + getStateSnapshot: vi.fn().mockReturnValue(null), + } as any, + sessionDeltaService: { + listRecentLaneSessionDeltas: vi.fn().mockReturnValue([ + { + sessionId: "delta-1", + laneId: "lane-review", + startedAt: "2026-04-06T09:40:00.000Z", + endedAt: "2026-04-06T09:50:00.000Z", + headShaStart: null, + headShaEnd: null, + filesChanged: 1, + insertions: 1, + deletions: 0, + touchedFiles: ["apps/desktop/src/preload/reviewBridge.ts"], + failureLines: ["AssertionError: bridge mismatch"], + computedAt: "2026-04-06T09:50:10.000Z", + }, + ]), + } as any, + testService: { + listSuites: vi.fn().mockReturnValue([]), + listRuns: vi.fn().mockReturnValue([]), + getLogTail: vi.fn().mockReturnValue(""), + } as any, + issueInventoryService: { + getInventory: vi.fn().mockReturnValue({ + prId: "pr-1", + items: [ + { + id: "inventory-1", + prId: "pr-1", + source: "human", + type: "review_thread", + externalId: "thread-1", + state: "new", + round: 1, + filePath: "apps/desktop/src/preload/reviewBridge.ts", + line: 10, + severity: "major", + headline: "Reviewer says the preload bridge still drifts", + body: null, + author: "reviewer", + url: null, + dismissReason: null, + agentSessionId: null, + createdAt: "2026-04-06T10:01:00.000Z", + updatedAt: "2026-04-06T10:02:00.000Z", + }, + ], + convergence: { currentRound: 1 }, + runtime: { currentRound: 1 }, + }), + } as any, + prService: { + getChecks: vi.fn().mockResolvedValue([]), + getReviewSnapshot: vi.fn().mockResolvedValue({ + id: "pr-1", + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 26, + githubUrl: "https://github.com/ade-dev/ade/pull/26", + baseBranch: "main", + headBranch: "feature/bridge", + baseSha: "abc123", + headSha: "def456", + files: [{ filename: "apps/desktop/src/preload/reviewBridge.ts" }], + }), + } as any, + }); + + const packet = await builder.buildContext({ + run: makeRun() as any, + materialized: makeMaterialized(["apps/desktop/src/preload/reviewBridge.ts"]) as any, + }); + + const kinds = packet.provenance.payload.lateStageSignals.map((signal) => signal.kind); + expect(kinds).toContain("validation_failure_followed_by_edits"); + expect(kinds).toContain("review_feedback_followed_by_edits"); + expect(kinds).toContain("prior_review_overlap"); + }); +}); diff --git a/apps/desktop/src/main/services/review/reviewContextBuilder.ts b/apps/desktop/src/main/services/review/reviewContextBuilder.ts new file mode 100644 index 000000000..524ded6df --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewContextBuilder.ts @@ -0,0 +1,848 @@ +import type { + IssueInventorySnapshot, + PrCheck, + PrReviewSnapshot, + ReviewRun, + SessionDeltaSummary, +} from "../../../shared/types"; +import type { Logger } from "../logging/logger"; +import type { createLaneService } from "../lanes/laneService"; +import type { createPrService } from "../prs/prService"; +import type { createIssueInventoryService } from "../prs/issueInventoryService"; +import { parseWorkerDigestRow } from "../orchestrator/workerTracking"; +import type { createSessionDeltaService } from "../sessions/sessionDeltaService"; +import type { AdeDb } from "../state/kvDb"; +import type { createTestService } from "../tests/testService"; +import { getErrorMessage } from "../shared/utils"; +import type { ReviewMaterializedTarget } from "./reviewTargetMaterializer"; +import { + matchReviewRuleOverlays, + type MatchedReviewRuleOverlay, +} from "./reviewRuleRegistry"; + +const MISSION_LIMIT = 1; +const WORKER_DIGEST_LIMIT = 3; +const SESSION_DELTA_LIMIT = 3; +const PRIOR_REVIEW_LIMIT = 2; +const VALIDATION_SIGNAL_LIMIT = 5; +const ISSUE_INVENTORY_LIMIT = 5; +const MAX_TEXT_FIELD = 220; +const MAX_PROMPT_SECTION = 6_000; + +type LinkedPrRow = { + id: string; + title: string | null; + state: string; + github_url: string; + repo_owner: string; + repo_name: string; + github_pr_number: number; + updated_at: string; +}; + +type MissionRow = { + mission_id: string | null; + title: string | null; + prompt: string | null; + status: string | null; + outcome_summary: string | null; + updated_at: string | null; +}; + +type PriorReviewRow = { + id: string; + status: string; + summary: string | null; + finding_count: number; + created_at: string; + publication_count: number; +}; + +type WorkerDigestRow = { + id: string; + mission_id: string; + run_id: string; + step_id: string; + attempt_id: string; + lane_id: string | null; + session_id: string | null; + step_key: string | null; + status: string; + summary: string; + files_changed_json: string | null; + tests_run_json: string | null; + warnings_json: string | null; + tokens_json: string | null; + cost_usd: number | null; + suggested_next_actions_json: string | null; + created_at: string; +}; + +export type ReviewContextProvenancePayload = { + changedPaths: string[]; + laneSnapshot: { + updatedAt: string | null; + agentSummary: string | null; + missionSummary: string | null; + } | null; + missions: Array<{ + id: string; + title: string; + status: string | null; + outcomeSummary: string | null; + intentSummary: string | null; + updatedAt: string | null; + }>; + workerDigests: Array<{ + id: string; + stepKey: string | null; + status: string; + summary: string; + filesChanged: string[]; + testsSummary: string | null; + warnings: string[]; + createdAt: string; + }>; + sessionDeltas: Array<{ + sessionId: string; + startedAt: string; + endedAt: string | null; + filesChanged: number; + touchedFiles: string[]; + failureLines: string[]; + computedAt: string | null; + }>; + priorReviews: Array<{ + runId: string; + status: string; + summary: string | null; + findingCount: number; + publicationCount: number; + overlappingPaths: string[]; + createdAt: string; + }>; + lateStageSignals: Array<{ + kind: "validation_failure_followed_by_edits" | "review_feedback_followed_by_edits" | "prior_review_overlap"; + summary: string; + filePaths: string[]; + source: string; + occurredAt: string | null; + }>; +}; + +export type ReviewContextRulesPayload = { + changedPaths: string[]; + overlays: Array<{ + id: MatchedReviewRuleOverlay["id"]; + label: string; + description: string; + matchedPaths: string[]; + rolloutExpectations: string[]; + coveredFamilies: Array<{ id: string; label: string }>; + missingFamilies: Array<{ id: string; label: string }>; + adjudicationPolicy: MatchedReviewRuleOverlay["adjudicationPolicy"]; + }>; +}; + +export type ReviewContextValidationPayload = { + linkedPr: { + prId: string; + title: string | null; + state: string; + repo: string; + githubUrl: string; + updatedAt: string; + } | null; + reviewSnapshot: { + baseBranch: string | null; + headBranch: string | null; + baseSha: string | null; + headSha: string | null; + fileCount: number; + } | null; + checks: Array<{ + name: string; + status: string; + conclusion: string | null; + detailsUrl: string | null; + startedAt: string | null; + completedAt: string | null; + }>; + suites: string[]; + testRuns: Array<{ + runId: string; + suiteId: string; + suiteName: string; + status: string; + exitCode: number | null; + startedAt: string; + endedAt: string | null; + logExcerpt: string | null; + }>; + issueInventory: Array<{ + id: string; + source: string; + type: string; + state: string; + round: number; + headline: string; + body: string | null; + filePath: string | null; + line: number | null; + updatedAt: string; + }>; + sessionFailures: Array<{ + sessionId: string; + touchedFiles: string[]; + failureLines: string[]; + computedAt: string | null; + }>; + signals: Array<{ + kind: "pr_check_failure" | "test_run_failure" | "review_feedback" | "session_failure"; + summary: string; + filePaths: string[]; + sourceId: string; + }>; +}; + +export type ReviewContextSection> = { + summary: string; + prompt: string; + payload: TPayload; + metadata: Record; +}; + +export type ReviewContextPacket = { + matchedRuleOverlays: MatchedReviewRuleOverlay[]; + provenance: ReviewContextSection; + rules: ReviewContextSection; + validation: ReviewContextSection; +}; + +function clipText(value: string | null | undefined, maxChars: number = MAX_TEXT_FIELD): string | null { + const normalized = String(value ?? "").replace(/\s+/g, " ").trim(); + if (!normalized) return null; + if (normalized.length <= maxChars) return normalized; + return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`; +} + +function compactList(values: Array, limit: number): string[] { + const seen = new Set(); + const compacted: string[] = []; + for (const value of values) { + const clipped = clipText(value); + if (!clipped || seen.has(clipped)) continue; + seen.add(clipped); + compacted.push(clipped); + if (compacted.length >= limit) break; + } + return compacted; +} + +function summarizeRecord(record: Record | null): string | null { + if (!record) return null; + const directKeys = [ + "summary", + "headline", + "title", + "goal", + "currentTask", + "statusSummary", + "latestMessage", + "intent", + "mission", + ]; + const directValues = directKeys + .map((key) => record[key]) + .filter((value): value is string => typeof value === "string"); + const clippedDirect = compactList(directValues, 2); + if (clippedDirect.length > 0) return clippedDirect.join(" | "); + + const nestedValues = Object.values(record) + .flatMap((value) => { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string"); + } + return []; + }); + const clippedNested = compactList(nestedValues, 2); + return clippedNested.length > 0 ? clippedNested.join(" | ") : null; +} + +function overlapsChangedPaths(candidatePaths: Array, changedPaths: string[]): string[] { + const changedSet = new Set(changedPaths); + return candidatePaths + .map((value) => value?.trim() ?? "") + .filter((value) => value.length > 0 && changedSet.has(value)) + .slice(0, 5); +} + +function extractFailureExcerpt(rawTail: string): string | null { + const lines = rawTail + .split(/\r?\n/) + .map((line) => line.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .filter((line) => /\b(error|failed|failure|exception|fatal|traceback)\b/i.test(line)); + return compactList(lines, 3).join(" | ") || null; +} + +function truncatePromptSection(value: string): string { + if (value.length <= MAX_PROMPT_SECTION) return value; + return `${value.slice(0, MAX_PROMPT_SECTION)}\n...(truncated)...\n`; +} + +export function createReviewContextBuilder({ + db, + projectId, + logger, + laneService, + sessionDeltaService, + testService, + issueInventoryService, + prService, +}: { + db: AdeDb; + projectId: string; + logger: Logger; + laneService: Pick, "getStateSnapshot">; + sessionDeltaService: Pick, "listRecentLaneSessionDeltas">; + testService: Pick, "listRuns" | "getLogTail" | "listSuites">; + issueInventoryService: Pick, "getInventory">; + prService?: Pick, "getChecks" | "getReviewSnapshot">; +}) { + function getLinkedPrRow(run: ReviewRun): LinkedPrRow | null { + if (run.target.mode === "pr") { + return db.get( + ` + select id, title, state, github_url, repo_owner, repo_name, github_pr_number, updated_at + from pull_requests + where id = ? + and project_id = ? + limit 1 + `, + [run.target.prId, projectId], + ); + } + return db.get( + ` + select id, title, state, github_url, repo_owner, repo_name, github_pr_number, updated_at + from pull_requests + where project_id = ? + and lane_id = ? + and state in ('open', 'draft') + order by updated_at desc + limit 1 + `, + [projectId, run.laneId], + ); + } + + function getMissionRow(laneId: string): MissionRow | null { + return db.get( + ` + select + l.mission_id as mission_id, + m.title as title, + m.prompt as prompt, + m.status as status, + m.outcome_summary as outcome_summary, + m.updated_at as updated_at + from lanes l + left join missions m on m.id = l.mission_id + where l.id = ? + and l.project_id = ? + limit 1 + `, + [laneId, projectId], + ); + } + + function listWorkerDigests(missionId: string | null, laneId: string): ReviewContextProvenancePayload["workerDigests"] { + if (!missionId?.trim()) return []; + const rows = db.all( + ` + select * + from orchestrator_worker_digests + where mission_id = ? + and project_id = ? + and (lane_id = ? or lane_id is null) + order by created_at desc + limit ? + `, + [missionId, projectId, laneId, WORKER_DIGEST_LIMIT], + ); + return rows.map((row) => { + const digest = parseWorkerDigestRow(row); + return { + id: digest.id, + stepKey: digest.stepKey, + status: digest.status, + summary: clipText(digest.summary) ?? "No summary", + filesChanged: digest.filesChanged.slice(0, 4), + testsSummary: clipText(digest.testsRun?.summary ?? null), + warnings: compactList(digest.warnings, 2), + createdAt: digest.createdAt, + }; + }); + } + + function listPriorReviews(runId: string, laneId: string, changedPaths: string[]): ReviewContextProvenancePayload["priorReviews"] { + const rows = db.all( + ` + select + r.id, + r.status, + r.summary, + r.finding_count, + r.created_at, + ( + select count(1) + from review_run_publications p + where p.run_id = r.id + ) as publication_count + from review_runs r + where r.project_id = ? + and r.lane_id = ? + and r.id != ? + order by r.created_at desc + limit ? + `, + [projectId, laneId, runId, PRIOR_REVIEW_LIMIT], + ); + return rows.map((row) => { + const findingRows = db.all<{ file_path: string | null }>( + ` + select file_path + from review_findings + where run_id = ? + and file_path is not null + order by file_path asc + limit 16 + `, + [row.id], + ); + return { + runId: row.id, + status: row.status, + summary: clipText(row.summary), + findingCount: Number(row.finding_count ?? 0), + publicationCount: Number(row.publication_count ?? 0), + overlappingPaths: overlapsChangedPaths(findingRows.map((entry) => entry.file_path), changedPaths), + createdAt: row.created_at, + }; + }); + } + + async function buildValidationPayload(args: { + laneId: string; + changedPaths: string[]; + linkedPr: LinkedPrRow | null; + sessionDeltas: ReviewContextProvenancePayload["sessionDeltas"]; + }): Promise { + let reviewSnapshot: PrReviewSnapshot | null = null; + let checks: PrCheck[] = []; + let inventory: IssueInventorySnapshot | null = null; + + if (args.linkedPr?.id) { + if (prService) { + reviewSnapshot = await prService.getReviewSnapshot(args.linkedPr.id).catch((error) => { + logger.debug("review.context_builder.review_snapshot_unavailable", { + prId: args.linkedPr?.id, + error: getErrorMessage(error), + }); + return null; + }); + checks = await prService.getChecks(args.linkedPr.id).catch((error) => { + logger.debug("review.context_builder.pr_checks_unavailable", { + prId: args.linkedPr?.id, + error: getErrorMessage(error), + }); + return []; + }); + } + try { + inventory = issueInventoryService.getInventory(args.linkedPr.id); + } catch (error) { + logger.debug("review.context_builder.issue_inventory_unavailable", { + prId: args.linkedPr?.id, + error: getErrorMessage(error), + }); + } + } + + const suites = testService.listSuites().map((suite) => suite.id).slice(0, VALIDATION_SIGNAL_LIMIT); + const testRuns = testService.listRuns({ laneId: args.laneId, limit: VALIDATION_SIGNAL_LIMIT }); + const normalizedTestRuns = testRuns.map((run) => ({ + runId: run.id, + suiteId: run.suiteId, + suiteName: clipText(run.suiteName, 80) ?? run.suiteId, + status: run.status, + exitCode: run.exitCode, + startedAt: run.startedAt, + endedAt: run.endedAt, + logExcerpt: (run.status === "failed" || run.status === "timed_out") + ? clipText(extractFailureExcerpt(testService.getLogTail({ runId: run.id, maxBytes: 12_000 })), 260) + : null, + })); + const normalizedChecks = checks.map((check) => ({ + name: clipText(check.name, 100) ?? "unnamed check", + status: check.status, + conclusion: check.conclusion, + detailsUrl: check.detailsUrl, + startedAt: check.startedAt, + completedAt: check.completedAt, + })); + const unresolvedInventoryItems = (inventory?.items ?? []) + .filter((item) => item.state !== "fixed" && item.state !== "dismissed") + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .slice(0, ISSUE_INVENTORY_LIMIT) + .map((item) => ({ + id: item.id, + source: item.source, + type: item.type, + state: item.state, + round: item.round, + headline: clipText(item.headline) ?? item.id, + body: clipText(item.body), + filePath: item.filePath, + line: item.line, + updatedAt: item.updatedAt, + })); + const sessionFailures = args.sessionDeltas + .filter((delta) => delta.failureLines.length > 0) + .slice(0, SESSION_DELTA_LIMIT) + .map((delta) => ({ + sessionId: delta.sessionId, + touchedFiles: delta.touchedFiles.slice(0, 5), + failureLines: compactList(delta.failureLines, 3), + computedAt: delta.computedAt, + })); + + const signals: ReviewContextValidationPayload["signals"] = []; + for (const check of normalizedChecks) { + if (signals.length >= VALIDATION_SIGNAL_LIMIT) break; + const failed = check.conclusion === "failure" || (check.status === "completed" && check.conclusion !== "success"); + if (!failed) continue; + signals.push({ + kind: "pr_check_failure", + summary: clipText(`${check.name}: ${check.status}${check.conclusion ? ` / ${check.conclusion}` : ""}${check.detailsUrl ? ` (${check.detailsUrl})` : ""}`, 260) ?? check.name, + filePaths: [], + sourceId: check.name, + }); + } + for (const run of normalizedTestRuns) { + if (signals.length >= VALIDATION_SIGNAL_LIMIT) break; + if (run.status !== "failed" && run.status !== "timed_out") continue; + signals.push({ + kind: "test_run_failure", + summary: clipText(`${run.suiteName}: ${run.status}${run.logExcerpt ? ` — ${run.logExcerpt}` : ""}`, 260) ?? run.suiteName, + filePaths: [], + sourceId: run.runId, + }); + } + for (const item of unresolvedInventoryItems) { + if (signals.length >= VALIDATION_SIGNAL_LIMIT) break; + signals.push({ + kind: "review_feedback", + summary: clipText(`${item.headline}${item.filePath ? ` (${item.filePath}${item.line ? `:${item.line}` : ""})` : ""}`, 260) ?? item.id, + filePaths: item.filePath ? [item.filePath] : [], + sourceId: item.id, + }); + } + for (const delta of sessionFailures) { + if (signals.length >= VALIDATION_SIGNAL_LIMIT) break; + signals.push({ + kind: "session_failure", + summary: clipText(delta.failureLines.join(" | "), 260) ?? delta.sessionId, + filePaths: overlapsChangedPaths(delta.touchedFiles, args.changedPaths), + sourceId: delta.sessionId, + }); + } + + return { + linkedPr: args.linkedPr ? { + prId: args.linkedPr.id, + title: clipText(args.linkedPr.title), + state: args.linkedPr.state, + repo: `${args.linkedPr.repo_owner}/${args.linkedPr.repo_name}`, + githubUrl: args.linkedPr.github_url, + updatedAt: args.linkedPr.updated_at, + } : null, + reviewSnapshot: reviewSnapshot ? { + baseBranch: reviewSnapshot.baseBranch ?? null, + headBranch: reviewSnapshot.headBranch ?? null, + baseSha: reviewSnapshot.baseSha ?? null, + headSha: reviewSnapshot.headSha ?? null, + fileCount: reviewSnapshot.files.length, + } : null, + checks: normalizedChecks, + suites, + testRuns: normalizedTestRuns, + issueInventory: unresolvedInventoryItems, + sessionFailures, + signals, + }; + } + + function buildProvenancePayload(args: { + materialized: ReviewMaterializedTarget; + laneSnapshot: ReturnType, "getStateSnapshot">["getStateSnapshot"]>; + missionRow: MissionRow | null; + workerDigests: ReviewContextProvenancePayload["workerDigests"]; + sessionDeltas: SessionDeltaSummary[]; + priorReviews: ReviewContextProvenancePayload["priorReviews"]; + validation: ReviewContextValidationPayload; + }): ReviewContextProvenancePayload { + const changedPaths = args.materialized.changedFiles.map((file) => file.filePath); + const normalizedSessionDeltas = args.sessionDeltas.slice(0, SESSION_DELTA_LIMIT).map((delta) => ({ + sessionId: delta.sessionId, + startedAt: delta.startedAt, + endedAt: delta.endedAt, + filesChanged: delta.filesChanged, + touchedFiles: delta.touchedFiles.slice(0, 5), + failureLines: compactList(delta.failureLines, 3), + computedAt: delta.computedAt, + })); + const missions = args.missionRow?.mission_id && MISSION_LIMIT > 0 ? [{ + id: args.missionRow.mission_id, + title: clipText(args.missionRow.title, 120) ?? args.missionRow.mission_id, + status: args.missionRow.status, + outcomeSummary: clipText(args.missionRow.outcome_summary), + intentSummary: clipText(args.missionRow.prompt), + updatedAt: args.missionRow.updated_at, + }] : []; + const lateStageSignals: ReviewContextProvenancePayload["lateStageSignals"] = []; + for (const delta of normalizedSessionDeltas) { + const overlappingPaths = overlapsChangedPaths(delta.touchedFiles, changedPaths); + if (overlappingPaths.length === 0 || delta.failureLines.length === 0) continue; + lateStageSignals.push({ + kind: "validation_failure_followed_by_edits", + summary: clipText(`Recent lane validation failed before edits touched ${overlappingPaths.join(", ")}: ${delta.failureLines.join(" | ")}`, 260) ?? delta.sessionId, + filePaths: overlappingPaths, + source: delta.sessionId, + occurredAt: delta.computedAt ?? delta.endedAt, + }); + } + for (const item of args.validation.issueInventory) { + if (lateStageSignals.length >= VALIDATION_SIGNAL_LIMIT) break; + const overlappingPaths = overlapsChangedPaths([item.filePath], changedPaths); + if (overlappingPaths.length === 0) continue; + lateStageSignals.push({ + kind: "review_feedback_followed_by_edits", + summary: clipText(`Open reviewer or check feedback still targets ${overlappingPaths.join(", ")}: ${item.headline}`, 260) ?? item.id, + filePaths: overlappingPaths, + source: item.id, + occurredAt: item.updatedAt, + }); + } + for (const review of args.priorReviews) { + if (lateStageSignals.length >= VALIDATION_SIGNAL_LIMIT) break; + if (review.overlappingPaths.length === 0) continue; + lateStageSignals.push({ + kind: "prior_review_overlap", + summary: clipText(`A prior ADE review already flagged ${review.overlappingPaths.join(", ")} (${review.findingCount} finding${review.findingCount === 1 ? "" : "s"}).`, 260) ?? review.runId, + filePaths: review.overlappingPaths, + source: review.runId, + occurredAt: review.createdAt, + }); + } + + return { + changedPaths, + laneSnapshot: args.laneSnapshot ? { + updatedAt: args.laneSnapshot.updatedAt, + agentSummary: clipText(summarizeRecord(args.laneSnapshot.agentSummary)), + missionSummary: clipText(summarizeRecord(args.laneSnapshot.missionSummary)), + } : null, + missions, + workerDigests: args.workerDigests, + sessionDeltas: normalizedSessionDeltas, + priorReviews: args.priorReviews, + lateStageSignals: lateStageSignals.slice(0, VALIDATION_SIGNAL_LIMIT), + }; + } + + function buildProvenancePrompt(payload: ReviewContextProvenancePayload): string { + const lines: string[] = []; + if (payload.laneSnapshot?.agentSummary) { + lines.push(`- Lane agent summary: ${payload.laneSnapshot.agentSummary}`); + } + if (payload.laneSnapshot?.missionSummary) { + lines.push(`- Lane mission summary: ${payload.laneSnapshot.missionSummary}`); + } + for (const mission of payload.missions) { + lines.push(`- Mission: ${mission.title}${mission.status ? ` [${mission.status}]` : ""}${mission.outcomeSummary ? ` — ${mission.outcomeSummary}` : mission.intentSummary ? ` — ${mission.intentSummary}` : ""}`); + } + for (const digest of payload.workerDigests) { + lines.push(`- Worker digest: ${digest.stepKey ?? "worker"} ${digest.status} — ${digest.summary}`); + } + for (const delta of payload.sessionDeltas) { + if (delta.failureLines.length > 0) { + lines.push(`- Session delta: ${delta.failureLines.join(" | ")}`); + } + } + for (const review of payload.priorReviews) { + lines.push(`- Prior ADE review: ${review.summary ?? "No summary"}${review.overlappingPaths.length > 0 ? ` (overlaps ${review.overlappingPaths.join(", ")})` : ""}`); + } + for (const signal of payload.lateStageSignals) { + lines.push(`- Late-stage signal: ${signal.summary}`); + } + return truncatePromptSection(lines.length > 0 ? lines.join("\n") : "- No ADE provenance or intent context was available."); + } + + function buildRulesPrompt(overlays: MatchedReviewRuleOverlay[]): string { + if (overlays.length === 0) { + return "- No ADE repo/path-specific rule overlay matched the changed paths."; + } + const lines = overlays.map((overlay) => { + const coverage = overlay.missingFamilies.length > 0 + ? `missing companion coverage: ${overlay.missingFamilies.map((family) => family.label).join(", ")}` + : "companion families touched in this diff"; + return `- ${overlay.label}: matched ${overlay.matchedPaths.join(", ")}; ${coverage}; rollout expectations: ${overlay.rolloutExpectations.join(" ")}`; + }); + return truncatePromptSection(lines.join("\n")); + } + + function buildValidationPrompt(payload: ReviewContextValidationPayload): string { + const lines: string[] = []; + if (payload.linkedPr) { + lines.push(`- Linked PR: ${payload.linkedPr.repo} #${payload.linkedPr.prId}${payload.linkedPr.title ? ` — ${payload.linkedPr.title}` : ""}`); + } + for (const signal of payload.signals) { + lines.push(`- Validation signal: ${signal.summary}`); + } + if (payload.signals.length === 0 && payload.testRuns.length > 0) { + const latestRuns = payload.testRuns.slice(0, 2).map((run) => `${run.suiteName}: ${run.status}`).join(" | "); + lines.push(`- Recent test runs: ${latestRuns}`); + } + return truncatePromptSection(lines.length > 0 ? lines.join("\n") : "- No prior ADE validation signals were available."); + } + + function buildProvenanceSummary(payload: ReviewContextProvenancePayload): string { + const parts = [ + payload.missions.length > 0 ? `${payload.missions.length} mission` : null, + payload.workerDigests.length > 0 ? `${payload.workerDigests.length} worker digest${payload.workerDigests.length === 1 ? "" : "s"}` : null, + payload.sessionDeltas.length > 0 ? `${payload.sessionDeltas.length} session delta${payload.sessionDeltas.length === 1 ? "" : "s"}` : null, + payload.priorReviews.length > 0 ? `${payload.priorReviews.length} prior review${payload.priorReviews.length === 1 ? "" : "s"}` : null, + payload.lateStageSignals.length > 0 ? `${payload.lateStageSignals.length} late-stage signal${payload.lateStageSignals.length === 1 ? "" : "s"}` : null, + ].filter((value): value is string => Boolean(value)); + return parts.length > 0 ? parts.join(", ") : "No ADE provenance context"; + } + + function buildRulesSummary(overlays: MatchedReviewRuleOverlay[]): string { + if (overlays.length === 0) return "No rule overlays matched"; + return `${overlays.length} rule overlay${overlays.length === 1 ? "" : "s"} matched`; + } + + function buildValidationSummary(payload: ReviewContextValidationPayload): string { + const parts = [ + payload.signals.length > 0 ? `${payload.signals.length} validation signal${payload.signals.length === 1 ? "" : "s"}` : null, + payload.checks.length > 0 ? `${payload.checks.length} check${payload.checks.length === 1 ? "" : "s"}` : null, + payload.testRuns.length > 0 ? `${payload.testRuns.length} test run${payload.testRuns.length === 1 ? "" : "s"}` : null, + payload.issueInventory.length > 0 ? `${payload.issueInventory.length} inventory item${payload.issueInventory.length === 1 ? "" : "s"}` : null, + ].filter((value): value is string => Boolean(value)); + return parts.length > 0 ? parts.join(", ") : "No validation signals"; + } + + return { + async buildContext(args: { + run: ReviewRun; + materialized: ReviewMaterializedTarget; + }): Promise { + const changedPaths = args.materialized.changedFiles.map((file) => file.filePath); + const laneSnapshot = laneService.getStateSnapshot(args.run.laneId); + const missionRow = getMissionRow(args.run.laneId); + const sessionDeltas = sessionDeltaService.listRecentLaneSessionDeltas(args.run.laneId, SESSION_DELTA_LIMIT); + const workerDigests = listWorkerDigests(missionRow?.mission_id ?? null, args.run.laneId); + const priorReviews = listPriorReviews(args.run.id, args.run.laneId, changedPaths); + const linkedPr = getLinkedPrRow(args.run); + const matchedRules = matchReviewRuleOverlays(changedPaths); + const validationPayload = await buildValidationPayload({ + laneId: args.run.laneId, + changedPaths, + linkedPr, + sessionDeltas: sessionDeltas.map((delta) => ({ + sessionId: delta.sessionId, + startedAt: delta.startedAt, + endedAt: delta.endedAt, + filesChanged: delta.filesChanged, + touchedFiles: delta.touchedFiles, + failureLines: delta.failureLines, + computedAt: delta.computedAt, + })), + }); + const provenancePayload = buildProvenancePayload({ + materialized: args.materialized, + laneSnapshot, + missionRow, + workerDigests, + sessionDeltas, + priorReviews, + validation: validationPayload, + }); + const rulesPayload: ReviewContextRulesPayload = { + changedPaths, + overlays: matchedRules.map((overlay) => ({ + id: overlay.id, + label: overlay.label, + description: overlay.description, + matchedPaths: overlay.matchedPaths, + rolloutExpectations: overlay.rolloutExpectations, + coveredFamilies: overlay.coveredFamilies, + missingFamilies: overlay.missingFamilies, + adjudicationPolicy: overlay.adjudicationPolicy, + })), + }; + + return { + matchedRuleOverlays: matchedRules, + provenance: { + summary: buildProvenanceSummary(provenancePayload), + prompt: buildProvenancePrompt(provenancePayload), + payload: provenancePayload, + metadata: { + summary: buildProvenanceSummary(provenancePayload), + provenanceCount: + provenancePayload.missions.length + + provenancePayload.workerDigests.length + + provenancePayload.sessionDeltas.length + + provenancePayload.priorReviews.length + + provenancePayload.lateStageSignals.length, + missionCount: provenancePayload.missions.length, + workerDigestCount: provenancePayload.workerDigests.length, + sessionDeltaCount: provenancePayload.sessionDeltas.length, + priorReviewCount: provenancePayload.priorReviews.length, + lateStageSignalCount: provenancePayload.lateStageSignals.length, + }, + }, + rules: { + summary: buildRulesSummary(matchedRules), + prompt: buildRulesPrompt(matchedRules), + payload: rulesPayload, + metadata: { + summary: buildRulesSummary(matchedRules), + matchedRuleCount: matchedRules.length, + ruleCount: matchedRules.length, + pathCount: changedPaths.length, + matchedRuleIds: matchedRules.map((overlay) => overlay.id), + }, + }, + validation: { + summary: buildValidationSummary(validationPayload), + prompt: buildValidationPrompt(validationPayload), + payload: validationPayload, + metadata: { + summary: buildValidationSummary(validationPayload), + signalCount: validationPayload.signals.length, + checkCount: validationPayload.checks.length, + testRunCount: validationPayload.testRuns.length, + issueCount: validationPayload.issueInventory.length, + sessionFailureCount: validationPayload.sessionFailures.length, + suiteCount: validationPayload.suites.length, + }, + }, + }; + }, + }; +} diff --git a/apps/desktop/src/main/services/review/reviewRuleRegistry.ts b/apps/desktop/src/main/services/review/reviewRuleRegistry.ts new file mode 100644 index 000000000..ecb380f5f --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewRuleRegistry.ts @@ -0,0 +1,255 @@ +import type { ReviewPassKey } from "../../../shared/types"; + +export type ReviewRuleCompanionFamily = { + id: string; + label: string; + pathPatterns: string[]; +}; + +export type ReviewRuleAdjudicationPolicy = { + evidenceMode: "normal" | "cross_boundary"; + requireDualPathConsideration?: boolean; +}; + +export type ReviewRuleOverlayDefinition = { + id: "renderer-surface" | "preload-bridge" | "shared-contract" | "mcp-dual-path"; + label: string; + description: string; + pathPatterns: string[]; + rolloutExpectations: string[]; + companionFamilies: ReviewRuleCompanionFamily[]; + promptGuidance: Partial>; + adjudicationPolicy: ReviewRuleAdjudicationPolicy; +}; + +export type MatchedReviewRuleOverlay = ReviewRuleOverlayDefinition & { + matchedPaths: string[]; + coveredFamilies: Array>; + missingFamilies: Array>; +}; + +function matchesPathPattern(filePath: string, pattern: string): boolean { + if (!pattern.trim()) return false; + if (pattern.endsWith("/**")) { + return filePath.startsWith(pattern.slice(0, -3)); + } + return filePath === pattern; +} + +function matchesAnyPattern(filePath: string, patterns: string[]): boolean { + return patterns.some((pattern) => matchesPathPattern(filePath, pattern)); +} + +const REVIEW_RULE_OVERLAYS: ReviewRuleOverlayDefinition[] = [ + { + id: "renderer-surface", + label: "Renderer surface", + description: "Renderer-facing changes should preserve visible flows, edge states, and the shared contracts they consume.", + pathPatterns: ["apps/desktop/src/renderer/**"], + rolloutExpectations: [ + "Check user-visible empty, loading, and error states.", + "Confirm renderer changes still match the shared data shape they consume.", + ], + companionFamilies: [ + { + id: "renderer", + label: "renderer surface", + pathPatterns: ["apps/desktop/src/renderer/**"], + }, + ], + promptGuidance: { + "diff-risk": [ + "Treat renderer changes as user-visible behavior changes, not just presentation edits.", + "Look for broken loading, empty, error, and optimistic states.", + ], + "cross-file-impact": [ + "Check whether renderer assumptions still match the shared types and IPC payloads it consumes.", + ], + }, + adjudicationPolicy: { + evidenceMode: "normal", + }, + }, + { + id: "preload-bridge", + label: "Preload bridge", + description: "Preload bridge changes must keep preload exports, IPC contracts, and renderer consumers aligned.", + pathPatterns: [ + "apps/desktop/src/preload/**", + "apps/desktop/src/preload/global.d.ts", + "apps/desktop/src/shared/ipc.ts", + ], + rolloutExpectations: [ + "Keep preload exports, `global.d.ts`, IPC contracts, and renderer call sites in sync.", + "Treat bridge mismatches as rollout gaps even when the changed file compiles in isolation.", + ], + companionFamilies: [ + { + id: "preload", + label: "preload bridge", + pathPatterns: [ + "apps/desktop/src/preload/**", + "apps/desktop/src/preload/global.d.ts", + ], + }, + { + id: "shared-ipc", + label: "shared IPC contract", + pathPatterns: ["apps/desktop/src/shared/ipc.ts"], + }, + { + id: "renderer-consumer", + label: "renderer consumer", + pathPatterns: ["apps/desktop/src/renderer/**"], + }, + ], + promptGuidance: { + "diff-risk": [ + "Treat preload or IPC edits as bridge changes that can silently break renderer access.", + ], + "cross-file-impact": [ + "Explicitly check preload exports, `global.d.ts`, shared IPC contracts, and renderer consumers together.", + ], + "checks-and-tests": [ + "Prefer validation evidence that proves the bridge stayed aligned across preload and renderer boundaries.", + ], + }, + adjudicationPolicy: { + evidenceMode: "cross_boundary", + }, + }, + { + id: "shared-contract", + label: "Shared contract", + description: "Shared contract and type changes should be reviewed as interface rollouts across their desktop consumers.", + pathPatterns: ["apps/desktop/src/shared/**"], + rolloutExpectations: [ + "Check whether shared contract changes were rolled out to preload, main, and renderer consumers.", + "Prefer concrete interface mismatches over speculative API concerns.", + ], + companionFamilies: [ + { + id: "shared", + label: "shared contract", + pathPatterns: ["apps/desktop/src/shared/**"], + }, + { + id: "preload-consumer", + label: "preload consumer", + pathPatterns: [ + "apps/desktop/src/preload/**", + "apps/desktop/src/preload/global.d.ts", + ], + }, + { + id: "renderer-consumer", + label: "renderer consumer", + pathPatterns: ["apps/desktop/src/renderer/**"], + }, + ], + promptGuidance: { + "diff-risk": [ + "Treat shared type or contract changes as interface changes that can break downstream callers.", + ], + "cross-file-impact": [ + "Look for missing rollout across preload, renderer, and other shared-contract consumers.", + ], + "checks-and-tests": [ + "Use validation evidence to confirm consumer updates actually landed for shared contract changes.", + ], + }, + adjudicationPolicy: { + evidenceMode: "cross_boundary", + }, + }, + { + id: "mcp-dual-path", + label: "MCP dual path", + description: "ADE MCP changes should keep the headless server path and the desktop socket-backed proxy path in sync.", + pathPatterns: [ + "apps/mcp-server/**", + "apps/desktop/src/main/adeMcpProxy.ts", + "apps/desktop/src/main/adeMcpProxyUtils.ts", + "apps/desktop/src/main/services/runtime/adeMcpLaunch.ts", + ], + rolloutExpectations: [ + "Check both headless MCP mode and the desktop socket-backed launch/proxy path.", + "Treat one-sided MCP changes as incomplete rollout unless the other path is intentionally unaffected and the diff proves it.", + ], + companionFamilies: [ + { + id: "mcp-server", + label: "headless MCP server", + pathPatterns: ["apps/mcp-server/**"], + }, + { + id: "desktop-mcp", + label: "desktop MCP proxy/runtime", + pathPatterns: [ + "apps/desktop/src/main/adeMcpProxy.ts", + "apps/desktop/src/main/adeMcpProxyUtils.ts", + "apps/desktop/src/main/services/runtime/adeMcpLaunch.ts", + ], + }, + ], + promptGuidance: { + "diff-risk": [ + "Treat MCP edits as transport or protocol changes that can break ADE-native tool execution.", + ], + "cross-file-impact": [ + "Before concluding there is no issue, explicitly consider both headless MCP behavior and the desktop socket-backed proxy path.", + ], + "checks-and-tests": [ + "Prefer evidence from existing MCP-adjacent validation instead of generic 'add tests' guidance.", + ], + }, + adjudicationPolicy: { + evidenceMode: "cross_boundary", + requireDualPathConsideration: true, + }, + }, +]; + +export function getReviewRuleOverlayDefinitions(): ReviewRuleOverlayDefinition[] { + return REVIEW_RULE_OVERLAYS.map((overlay) => ({ + ...overlay, + pathPatterns: [...overlay.pathPatterns], + rolloutExpectations: [...overlay.rolloutExpectations], + companionFamilies: overlay.companionFamilies.map((family) => ({ + ...family, + pathPatterns: [...family.pathPatterns], + })), + promptGuidance: Object.fromEntries( + Object.entries(overlay.promptGuidance).map(([passKey, guidance]) => [passKey, [...(guidance ?? [])]]), + ) as ReviewRuleOverlayDefinition["promptGuidance"], + })); +} + +export function matchReviewRuleOverlays(changedPaths: string[]): MatchedReviewRuleOverlay[] { + return getReviewRuleOverlayDefinitions().flatMap((overlay) => { + const matchedPaths = changedPaths.filter((filePath) => matchesAnyPattern(filePath, overlay.pathPatterns)); + if (matchedPaths.length === 0) return []; + const coveredFamilies = overlay.companionFamilies + .filter((family) => changedPaths.some((filePath) => matchesAnyPattern(filePath, family.pathPatterns))) + .map((family) => ({ id: family.id, label: family.label })); + const missingFamilies = overlay.companionFamilies + .filter((family) => !coveredFamilies.some((covered) => covered.id === family.id)) + .map((family) => ({ id: family.id, label: family.label })); + return [{ + ...overlay, + matchedPaths, + coveredFamilies, + missingFamilies, + }]; + }); +} + +export function overlayMatchesPath(overlay: Pick, filePath: string | null | undefined): boolean { + if (!filePath?.trim()) return false; + return matchesAnyPattern(filePath, overlay.pathPatterns); +} + +export function collectRulePromptGuidance(overlays: MatchedReviewRuleOverlay[], passKey: ReviewPassKey): string[] { + const guidance = overlays.flatMap((overlay) => overlay.promptGuidance[passKey] ?? []); + return Array.from(new Set(guidance.map((line) => line.trim()).filter(Boolean))); +} diff --git a/apps/desktop/src/main/services/review/reviewService.test.ts b/apps/desktop/src/main/services/review/reviewService.test.ts index 7b7288205..b3f2739b0 100644 --- a/apps/desktop/src/main/services/review/reviewService.test.ts +++ b/apps/desktop/src/main/services/review/reviewService.test.ts @@ -3,17 +3,28 @@ import { createRequire } from "node:module"; import initSqlJs from "sql.js"; import type { Database, SqlJsStatic } from "sql.js"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReviewPublicationDestination, ReviewRunConfig } from "../../../shared/types"; const mockMaterializer = vi.hoisted(() => ({ materialize: vi.fn(), })); +const mockContextBuilder = vi.hoisted(() => ({ + buildContext: vi.fn(), +})); + vi.mock("./reviewTargetMaterializer", () => ({ createReviewTargetMaterializer: () => ({ materialize: (...args: unknown[]) => mockMaterializer.materialize(...args), }), })); +vi.mock("./reviewContextBuilder", () => ({ + createReviewContextBuilder: () => ({ + buildContext: (...args: unknown[]) => mockContextBuilder.buildContext(...args), + }), +})); + import { createReviewService } from "./reviewService"; type SqlValue = string | number | null | Uint8Array; @@ -75,6 +86,7 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { run_id text not null, title text not null, severity text not null, + finding_class text, body text not null, confidence real not null default 0.5, evidence_json text, @@ -82,7 +94,9 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { line integer, anchor_state text not null, source_pass text not null, - publication_state text not null + publication_state text not null, + originating_passes_json text, + adjudication_json text ) `); raw.run(` @@ -135,445 +149,924 @@ async function waitFor(fn: () => T | Promise, predicate: (value: T) => boo throw new Error("Timed out waiting for review service state"); } -describe("reviewService", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +function makeConfig(overrides: Partial = {}): ReviewRunConfig { + return { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: "openai/gpt-5.4-codex", + reasoningEffort: "medium", + budgets: { + maxFiles: 60, + maxDiffChars: 180_000, + maxPromptChars: 220_000, + maxFindings: 12, + maxFindingsPerPass: 6, + maxPublishedFindings: 6, + }, + publishBehavior: "local_only", + ...overrides, + }; +} + +function makeChangedFile(overrides: Partial<{ + filePath: string; + excerpt: string; + lineNumbers: number[]; + diffPositionsByLine: Record; +}> = {}) { + return { + filePath: "src/review.ts", + excerpt: "@@ -1,2 +1,4 @@\n context\n+return null;\n+missing fallback\n", + lineNumbers: [2, 3], + diffPositionsByLine: { 2: 1, 3: 2 }, + ...overrides, + }; +} - it("persists a completed review run and reopens its findings later", async () => { - const { db, raw } = createInMemoryAdeDb(); - mockMaterializer.materialize.mockResolvedValue({ - targetLabel: "feature/review-tab vs main", - compareTarget: { - kind: "default_branch", - label: "main", - ref: "main", - laneId: null, - branchRef: "main", +function makeMaterializedTarget(overrides: Partial<{ + targetLabel: string; + publicationTarget: ReviewPublicationDestination | null; + fullPatchText: string; + changedFiles: ReturnType[]; +}> = {}) { + const fullPatchText = overrides.fullPatchText + ?? "diff --git a/src/review.ts b/src/review.ts\n@@ -1,2 +1,4 @@\n context\n+return null;\n+missing fallback\n"; + const changedFiles = overrides.changedFiles ?? [makeChangedFile()]; + return { + targetLabel: overrides.targetLabel ?? "feature/review-tab vs main", + compareTarget: { + kind: "default_branch", + label: "main", + ref: "main", + laneId: null, + branchRef: "main", + }, + publicationTarget: overrides.publicationTarget ?? null, + fullPatchText, + changedFiles, + artifacts: [ + { + artifactType: "diff_bundle", + title: "Diff bundle", + mimeType: "text/plain", + contentText: fullPatchText, + metadata: null, }, - publicationTarget: null, - fullPatchText: "diff --git a/src/review.ts b/src/review.ts\n@@ -1,1 +1,2 @@\n+return null;\n", - changedFiles: [ - { - filePath: "src/review.ts", - excerpt: "@@ -1,1 +1,2 @@\n+return null;", - lineNumbers: [2], - diffPositionsByLine: { 2: 1 }, - }, - ], - artifacts: [ - { - artifactType: "diff_bundle", - title: "Diff bundle", - mimeType: "text/plain", - contentText: "diff --git a/src/review.ts b/src/review.ts\n@@ -1,1 +1,2 @@\n+return null;\n", - metadata: null, - }, - ], - }); + ], + }; +} - const events: Array<{ type: string; runId?: string; status?: string }> = []; - const service = createReviewService({ - db: db as any, - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as any, - projectId: "project-1", - projectRoot: "/tmp/ade", - projectDefaultBranch: "main", - laneService: { - getLaneBaseAndBranch: vi.fn().mockReturnValue({ - baseRef: "main", - branchRef: "feature/review-tab", - worktreePath: "/tmp/ade/lane", - laneType: "worktree", - }), - list: vi.fn(async () => [ +function makeFinding(overrides: Partial> = {}): Record { + return { + title: "Missing fallback", + severity: "high", + body: "The branch returns null without the previous fallback path.", + confidence: 0.84, + filePath: "src/review.ts", + line: 2, + evidence: [ + { + summary: "The diff adds a direct null return.", + filePath: "src/review.ts", + line: 2, + quote: "+return null;", + }, + ], + ...overrides, + }; +} + +function makeOutput(summary: string, findings: Array>): string { + return JSON.stringify({ summary, findings }); +} + +function makeContextPacket(overrides: Partial> = {}) { + return { + matchedRuleOverlays: [], + provenance: { + summary: "1 mission, 1 late-stage signal", + prompt: "- Mission: keep renderer and preload behavior aligned.\n- Late-stage signal: recent validation failed on src/review.ts", + payload: { + changedPaths: ["src/review.ts"], + laneSnapshot: { + updatedAt: "2026-04-06T09:58:00.000Z", + agentSummary: "Recent ADE chat focused on restoring the review fallback path.", + missionSummary: "Finish the review engine rollout cleanly.", + }, + missions: [ { - id: "lane-review", - name: "feature/review-tab", - laneType: "worktree", - baseRef: "main", - branchRef: "feature/review-tab", - color: null, + id: "mission-1", + title: "Restore review fallback behavior", + status: "running", + outcomeSummary: null, + intentSummary: "Keep fallback behavior and ship the review engine safely.", + updatedAt: "2026-04-06T09:59:00.000Z", }, - ]), - } as any, - gitService: { - listRecentCommits: vi.fn(async () => [ + ], + workerDigests: [ { - sha: "abc1234567890", - shortSha: "abc1234", - parents: [], - authorName: "Arul", - authoredAt: "2026-04-05T12:00:00.000Z", - subject: "Recent review work", - pushed: true, + id: "digest-1", + stepKey: "implement", + status: "succeeded", + summary: "Updated the review engine behavior.", + filesChanged: ["src/review.ts"], + testsSummary: "1 failed", + warnings: [], + createdAt: "2026-04-06T10:00:00.000Z", }, - ]), - } as any, - agentChatService: { - createSession: vi.fn(async () => ({ - id: "session-review-1", - laneId: "lane-review", - provider: "codex", - model: "gpt-5.4-codex", - modelId: "openai/gpt-5.4-codex", - })), - getSessionSummary: vi.fn(async () => ({ - sessionId: "session-review-1", + ], + sessionDeltas: [ + { + sessionId: "session-delta-1", + startedAt: "2026-04-06T09:55:00.000Z", + endedAt: "2026-04-06T09:57:00.000Z", + filesChanged: 1, + touchedFiles: ["src/review.ts"], + failureLines: ["AssertionError: fallback missing"], + computedAt: "2026-04-06T09:57:10.000Z", + }, + ], + priorReviews: [ + { + runId: "prior-run-1", + status: "completed", + summary: "Earlier ADE review flagged the same fallback path.", + findingCount: 1, + publicationCount: 0, + overlappingPaths: ["src/review.ts"], + createdAt: "2026-04-05T16:00:00.000Z", + }, + ], + lateStageSignals: [ + { + kind: "validation_failure_followed_by_edits", + summary: "Recent validation failed on src/review.ts before this edit.", + filePaths: ["src/review.ts"], + source: "session-delta-1", + occurredAt: "2026-04-06T09:57:10.000Z", + }, + ], + }, + metadata: { + summary: "1 mission, 1 worker digest, 1 session delta, 1 late-stage signal", + provenanceCount: 4, + missionCount: 1, + workerDigestCount: 1, + sessionDeltaCount: 1, + priorReviewCount: 1, + lateStageSignalCount: 1, + }, + }, + rules: { + summary: "1 rule overlay matched", + prompt: "- Preload bridge: matched src/review.ts; missing companion coverage: renderer consumer", + payload: { + changedPaths: ["src/review.ts"], + overlays: [], + }, + metadata: { + summary: "1 rule overlay matched", + matchedRuleCount: 1, + ruleCount: 1, + pathCount: 1, + matchedRuleIds: ["renderer-surface"], + }, + }, + validation: { + summary: "2 validation signals, 1 check, 1 test run", + prompt: "- Validation signal: unit-tests failure\n- Validation signal: fallback assertion failed", + payload: { + linkedPr: null, + reviewSnapshot: null, + checks: [ + { + name: "unit-tests", + status: "completed", + conclusion: "failure", + detailsUrl: "https://ci.example/unit-tests", + startedAt: "2026-04-06T10:00:00.000Z", + completedAt: "2026-04-06T10:03:00.000Z", + }, + ], + suites: ["unit"], + testRuns: [ + { + runId: "test-run-1", + suiteId: "unit", + suiteName: "Unit", + status: "failed", + exitCode: 1, + startedAt: "2026-04-06T10:00:00.000Z", + endedAt: "2026-04-06T10:02:00.000Z", + logExcerpt: "AssertionError: fallback missing", + }, + ], + issueInventory: [], + sessionFailures: [ + { + sessionId: "session-delta-1", + touchedFiles: ["src/review.ts"], + failureLines: ["AssertionError: fallback missing"], + computedAt: "2026-04-06T09:57:10.000Z", + }, + ], + signals: [ + { + kind: "pr_check_failure", + summary: "unit-tests: completed / failure", + filePaths: [], + sourceId: "unit-tests", + }, + { + kind: "session_failure", + summary: "AssertionError: fallback missing", + filePaths: ["src/review.ts"], + sourceId: "session-delta-1", + }, + ], + }, + metadata: { + summary: "2 validation signals, 1 check, 1 test run", + signalCount: 2, + checkCount: 1, + testRunCount: 1, + issueCount: 0, + sessionFailureCount: 1, + suiteCount: 1, + }, + }, + ...overrides, + }; +} + +function buildPublicationResult(args: { + runId: string; + destination: ReviewPublicationDestination; + findings: Array<{ id: string; filePath: string | null; line: number | null }>; +}) { + const firstInline = args.findings.find((finding) => finding.filePath && finding.line != null) ?? null; + return { + id: `publication-${args.runId}`, + runId: args.runId, + destination: args.destination, + reviewEvent: "COMMENT" as const, + status: "published" as const, + reviewUrl: "https://github.com/ade-dev/ade/pull/80#pullrequestreview-1", + remoteReviewId: "1", + summaryBody: "Summary body", + inlineComments: firstInline ? [{ + findingId: firstInline.id, + path: firstInline.filePath ?? "src/review.ts", + line: firstInline.line ?? 1, + position: 2, + body: "Inline comment", + }] : [], + summaryFindingIds: args.findings.filter((finding) => !firstInline || finding.id !== firstInline.id).map((finding) => finding.id), + errorMessage: null, + createdAt: "2026-04-06T10:00:00.000Z", + updatedAt: "2026-04-06T10:00:02.000Z", + completedAt: "2026-04-06T10:00:02.000Z", + }; +} + +function createHarness(args: { + outputs: string[]; + targetLabel?: string; + publicationTarget?: ReviewPublicationDestination | null; + config?: Partial; + target?: { mode: "lane_diff" | "pr" | "commit_range" | "working_tree"; laneId: string; prId?: string; baseCommit?: string; headCommit?: string }; +}) { + const { db, raw } = createInMemoryAdeDb(); + let sessionCount = 0; + const queuedOutputs = [...args.outputs]; + const publishReviewPublication = vi.fn(async (input: any) => buildPublicationResult({ + runId: input.runId, + destination: input.destination, + findings: input.findings, + })); + + mockMaterializer.materialize.mockResolvedValue(makeMaterializedTarget({ + targetLabel: args.targetLabel, + publicationTarget: args.publicationTarget ?? null, + })); + mockContextBuilder.buildContext.mockResolvedValue(makeContextPacket()); + + const runSessionTurn = vi.fn(async () => { + const outputText = queuedOutputs.shift(); + if (!outputText) throw new Error("No mock review output left."); + return { + sessionId: `session-review-${sessionCount}`, + provider: "codex", + model: "gpt-5.4-codex", + modelId: "openai/gpt-5.4-codex", + outputText, + }; + }); + + const service = createReviewService({ + db: db as any, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any, + projectId: "project-1", + projectRoot: "/tmp/ade", + projectDefaultBranch: "main", + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/review-tab", + worktreePath: "/tmp/ade/lane", + laneType: "worktree", + }), + getStateSnapshot: vi.fn().mockReturnValue(null), + list: vi.fn(async () => []), + } as any, + gitService: { + listRecentCommits: vi.fn(async () => []), + } as any, + agentChatService: { + createSession: vi.fn(async () => { + sessionCount += 1; + return { + id: `session-review-${sessionCount}`, laneId: "lane-review", provider: "codex", model: "gpt-5.4-codex", modelId: "openai/gpt-5.4-codex", - title: "Review transcript", - surface: "automation", - status: "idle", - startedAt: "2026-04-05T12:00:00.000Z", - endedAt: null, - lastActivityAt: "2026-04-05T12:05:00.000Z", - lastOutputPreview: "Review output", - summary: "Saved review transcript", - })), - runSessionTurn: vi.fn(async () => ({ - sessionId: "session-review-1", - provider: "codex", - model: "gpt-5.4-codex", - modelId: "openai/gpt-5.4-codex", - outputText: JSON.stringify({ - summary: "One anchored finding.", - findings: [ - { - title: "Missing fallback", - severity: "high", - body: "The new branch returns null without a fallback path.", - confidence: 0.91, - filePath: "src/review.ts", - line: 2, - evidence: [ - { - summary: "The diff adds a direct null return.", - filePath: "src/review.ts", - line: 2, - quote: "+return null;", - }, - ], - }, - ], - }), - })), - } as any, - sessionService: { - updateMeta: vi.fn(), - } as any, - onEvent: (event) => { - events.push({ type: event.type, runId: (event as any).runId, status: (event as any).status }); - }, - }); + }; + }), + getSessionSummary: vi.fn(async (sessionId: string) => ({ + sessionId, + laneId: "lane-review", + provider: "codex", + model: "gpt-5.4-codex", + modelId: "openai/gpt-5.4-codex", + title: "Review transcript", + surface: "automation", + status: "idle", + startedAt: "2026-04-05T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-04-05T12:05:00.000Z", + lastOutputPreview: "Review output", + summary: "Saved review transcript", + })), + runSessionTurn, + } as any, + sessionService: { + updateMeta: vi.fn(), + } as any, + sessionDeltaService: { + listRecentLaneSessionDeltas: vi.fn().mockReturnValue([]), + } as any, + testService: { + listRuns: vi.fn().mockReturnValue([]), + getLogTail: vi.fn().mockReturnValue(""), + listSuites: vi.fn().mockReturnValue([]), + } as any, + issueInventoryService: { + getInventory: vi.fn().mockReturnValue({ prId: "pr-80", items: [], convergence: {}, runtime: {} }), + } as any, + prService: args.publicationTarget ? { + getReviewSnapshot: vi.fn(), + getChecks: vi.fn(async () => [ + { + name: "unit-tests", + status: "completed", + conclusion: "failure", + detailsUrl: "https://ci.example/unit-tests", + startedAt: "2026-04-06T10:00:00.000Z", + completedAt: "2026-04-06T10:03:00.000Z", + }, + ]), + publishReviewPublication, + } as any : undefined, + }); - const run = await service.startRun({ - target: { mode: "lane_diff", laneId: "lane-review" }, - }); + const target = args.target?.mode === "pr" + ? { mode: "pr" as const, laneId: args.target.laneId, prId: args.target.prId ?? "pr-80" } + : args.target?.mode === "commit_range" + ? { + mode: "commit_range" as const, + laneId: args.target.laneId, + baseCommit: args.target.baseCommit ?? "abc123456789", + headCommit: args.target.headCommit ?? "def456789012", + } + : args.target?.mode === "working_tree" + ? { mode: "working_tree" as const, laneId: args.target.laneId } + : { mode: "lane_diff" as const, laneId: args.target?.laneId ?? "lane-review" }; + + return { + raw, + service, + runSessionTurn, + publishReviewPublication, + start: (config?: Partial) => service.startRun({ + target, + config: makeConfig({ + ...(args.config ?? {}), + ...(config ?? {}), + }), + }), + }; +} - expect(run.status).toBe("queued"); +describe("reviewService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("merges overlapping multi-pass findings and persists the pass-level artifact trail", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("Direct diff risk.", [ + makeFinding(), + ]), + makeOutput("Cross-file corroboration.", [ + makeFinding({ + title: "Fallback path removed", + body: "The previous fallback is gone, so downstream callers can now receive null.", + confidence: 0.79, + }), + ]), + makeOutput("No extra check findings.", []), + ], + }); - const completed = await waitFor( - () => service.listRuns(), + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), (runs) => runs[0]?.status === "completed", ); - expect(completed[0]?.targetLabel).toBe("feature/review-tab vs main"); - const detail = await service.getRunDetail({ runId: run.id }); - expect(detail?.summary).toBe("One anchored finding."); + const detail = await harness.service.getRunDetail({ runId: run.id }); expect(detail?.findingCount).toBe(1); - expect(detail?.findings[0]?.anchorState).toBe("anchored"); - expect(detail?.artifacts.some((artifact) => artifact.artifactType === "prompt")).toBe(true); - expect(detail?.artifacts.some((artifact) => artifact.artifactType === "review_output")).toBe(true); - expect(detail?.publications).toEqual([]); - expect(detail?.chatSession?.sessionId).toBe("session-review-1"); - expect(events.some((event) => event.type === "run-completed" && event.runId === run.id && event.status === "completed")).toBe(true); - - const persistedRuns = mapExecRows(raw.exec("select status, finding_count from review_runs")); - expect(String(persistedRuns[0]?.status)).toBe("completed"); - expect(Number(persistedRuns[0]?.finding_count)).toBe(1); + expect(detail?.findings[0]?.sourcePass).toBe("adjudicated"); + expect(detail?.findings[0]?.originatingPasses).toEqual(["diff-risk", "cross-file-impact"]); + expect(detail?.findings[0]?.adjudication?.mergedFindingIds).toHaveLength(2); + expect(detail?.artifacts.filter((artifact) => artifact.artifactType === "pass_prompt")).toHaveLength(3); + expect(detail?.artifacts.filter((artifact) => artifact.artifactType === "pass_output")).toHaveLength(3); + expect(detail?.artifacts.filter((artifact) => artifact.artifactType === "pass_findings")).toHaveLength(3); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "adjudication_result")).toBe(true); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "merged_findings")).toBe(true); + + const persistedFindings = mapExecRows(harness.raw.exec("select source_pass, originating_passes_json, adjudication_json from review_findings")); + expect(String(persistedFindings[0]?.source_pass)).toBe("adjudicated"); + expect(String(persistedFindings[0]?.originating_passes_json)).toContain("diff-risk"); + expect(String(persistedFindings[0]?.adjudication_json)).toContain("publicationEligible"); }); - it("reruns a prior review with the same target and config", async () => { - const { db } = createInMemoryAdeDb(); - mockMaterializer.materialize.mockResolvedValue({ - targetLabel: "feature/review-tab vs main", - compareTarget: { - kind: "default_branch", - label: "main", - ref: "main", - laneId: null, - branchRef: "main", - }, - publicationTarget: null, - fullPatchText: "", - changedFiles: [], - artifacts: [ + it("persists provenance, rules, and validation artifacts and keeps renderer findings on the normal evidence path", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("Renderer regression.", [ + makeFinding({ + title: "Fallback intent drift", + findingClass: "intent_drift", + }), + ]), + makeOutput("No cross-file issues.", []), + makeOutput("No checks issues.", []), + ], + }); + mockContextBuilder.buildContext.mockResolvedValueOnce(makeContextPacket({ + matchedRuleOverlays: [ { - artifactType: "diff_bundle", - title: "Diff bundle", - mimeType: "text/plain", - contentText: "", - metadata: null, + id: "renderer-surface", + label: "Renderer surface", + description: "Renderer rule", + pathPatterns: ["src/review.ts"], + rolloutExpectations: ["Check user-visible fallback behavior."], + companionFamilies: [{ id: "renderer", label: "renderer", pathPatterns: ["src/review.ts"] }], + promptGuidance: { "diff-risk": ["Treat this as a visible renderer flow."] }, + adjudicationPolicy: { evidenceMode: "normal" }, + matchedPaths: ["src/review.ts"], + coveredFamilies: [{ id: "renderer", label: "renderer" }], + missingFamilies: [], }, ], - }); + rules: { + summary: "1 rule overlay matched", + prompt: "- Renderer surface: check user-visible fallback behavior.", + payload: { + changedPaths: ["src/review.ts"], + overlays: [{ + id: "renderer-surface", + label: "Renderer surface", + description: "Renderer rule", + matchedPaths: ["src/review.ts"], + rolloutExpectations: ["Check user-visible fallback behavior."], + coveredFamilies: [{ id: "renderer", label: "renderer" }], + missingFamilies: [], + adjudicationPolicy: { evidenceMode: "normal" }, + }], + }, + metadata: { + summary: "1 rule overlay matched", + matchedRuleCount: 1, + ruleCount: 1, + pathCount: 1, + matchedRuleIds: ["renderer-surface"], + }, + }, + })); - const service = createReviewService({ - db: db as any, - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as any, - projectId: "project-1", - projectRoot: "/tmp/ade", - projectDefaultBranch: "main", - laneService: { - getLaneBaseAndBranch: vi.fn().mockReturnValue({ - baseRef: "main", - branchRef: "feature/review-tab", - worktreePath: "/tmp/ade/lane", - laneType: "worktree", - }), - list: vi.fn(async () => []), - } as any, - gitService: { - listRecentCommits: vi.fn(async () => []), - } as any, - agentChatService: { - createSession: vi.fn(async () => ({ - id: "session-review-2", - laneId: "lane-review", - provider: "codex", - model: "gpt-5.4-codex", - modelId: "openai/gpt-5.4-codex", - })), - getSessionSummary: vi.fn(async () => null), - runSessionTurn: vi.fn(async () => ({ - sessionId: "session-review-2", - provider: "codex", - model: "gpt-5.4-codex", - modelId: "openai/gpt-5.4-codex", - outputText: JSON.stringify({ summary: "No issues.", findings: [] }), - })), - } as any, - sessionService: { - updateMeta: vi.fn(), - } as any, - }); + const run = await harness.start(); + await waitFor(() => harness.service.listRuns(), (runs) => runs[0]?.status === "completed"); - const first = await service.startRun({ - target: { - mode: "commit_range", - laneId: "lane-review", - baseCommit: "abc123456789", - headCommit: "def456789012", - }, - config: { - selectionMode: "selected_commits", - compareAgainst: { kind: "default_branch" }, - dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", - reasoningEffort: "high", - budgets: { maxFiles: 10, maxDiffChars: 1000, maxPromptChars: 1000, maxFindings: 4 }, - publishBehavior: "local_only", - }, + const detail = await harness.service.getRunDetail({ runId: run.id }); + expect(detail?.findings).toHaveLength(1); + expect(detail?.findings[0]?.findingClass).toBe("intent_drift"); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "provenance_brief")).toBe(true); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "rule_overlays")).toBe(true); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "validation_signals")).toBe(true); + + const harnessArtifact = detail?.artifacts.find((artifact) => artifact.artifactType === "prompt"); + expect(harnessArtifact?.metadata).toMatchObject({ + matchedRuleCount: 1, + provenanceCount: 4, + validationSignalCount: 2, + matchedRuleIds: ["renderer-surface"], }); - await waitFor(() => service.listRuns(), (runs) => runs.some((run) => run.id === first.id && run.status === "completed")); - const rerun = await service.rerun(first.id); - expect(rerun.id).not.toBe(first.id); - await waitFor(() => service.listRuns(), (runs) => runs.some((run) => run.id === rerun.id && run.status === "completed")); + const firstPassPrompt = detail?.artifacts.find((artifact) => artifact.artifactType === "pass_prompt"); + expect(firstPassPrompt?.contentText).toContain("provenance_brief artifact id"); + expect(firstPassPrompt?.contentText).toContain("rule_overlays artifact id"); - const allRuns = await service.listRuns(); - const rerunRecord = allRuns.find((entry) => entry.id === rerun.id); - expect(rerunRecord?.target).toEqual({ - mode: "commit_range", - laneId: "lane-review", - baseCommit: "abc123456789", - headCommit: "def456789012", - }); - expect(rerunRecord?.config.selectionMode).toBe("selected_commits"); - expect(rerunRecord?.config.reasoningEffort).toBe("high"); + const persisted = mapExecRows(harness.raw.exec("select finding_class from review_findings")); + expect(String(persisted[0]?.finding_class)).toBe("intent_drift"); }); - it("publishes PR-backed review runs, preserves summary findings, and reruns with the same publication flow", async () => { - const { db } = createInMemoryAdeDb(); - mockMaterializer.materialize.mockResolvedValue({ - targetLabel: "PR #80 feature/pr-80 -> main", - compareTarget: { - kind: "default_branch", - label: "main", - ref: "main", - laneId: null, - branchRef: "main", - }, - publicationTarget: { - kind: "github_pr_review", - prId: "pr-80", - repoOwner: "ade-dev", - repoName: "ade", - prNumber: 80, - githubUrl: "https://github.com/ade-dev/ade/pull/80", + it("rejects strict preload/shared findings without cross-boundary or provenance-backed evidence", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("Bridge mismatch.", [ + makeFinding({ + title: "Bridge rollout incomplete", + findingClass: "incomplete_rollout", + evidence: [ + { + summary: "The preload diff changes the exposed method name.", + filePath: "src/review.ts", + line: 2, + quote: "+exposeReviewV2()", + }, + ], + }), + ]), + makeOutput("No cross-file issues.", []), + makeOutput("No checks issues.", []), + ], + }); + mockContextBuilder.buildContext.mockResolvedValueOnce(makeContextPacket({ + provenance: { + summary: "No ADE provenance context", + prompt: "- No ADE provenance or intent context was available.", + payload: { + changedPaths: ["src/review.ts"], + laneSnapshot: null, + missions: [], + workerDigests: [], + sessionDeltas: [], + priorReviews: [], + lateStageSignals: [], + }, + metadata: { + summary: "No ADE provenance context", + provenanceCount: 0, + missionCount: 0, + workerDigestCount: 0, + sessionDeltaCount: 0, + priorReviewCount: 0, + lateStageSignalCount: 0, + }, }, - fullPatchText: "diff --git a/src/review.ts b/src/review.ts\n@@ -10,2 +10,4 @@\n context\n+anchored\n+summary only\n", - changedFiles: [ + matchedRuleOverlays: [ { - filePath: "src/review.ts", - excerpt: "@@ -10,2 +10,4 @@\n context\n+anchored\n+summary only", - lineNumbers: [10, 11, 12], - diffPositionsByLine: { 10: 1, 11: 2, 12: 3 }, + id: "preload-bridge", + label: "Preload bridge", + description: "Strict bridge rule", + pathPatterns: ["src/review.ts"], + rolloutExpectations: ["Keep bridge and consumer updates aligned."], + companionFamilies: [ + { id: "preload", label: "preload", pathPatterns: ["src/review.ts"] }, + { id: "renderer", label: "renderer", pathPatterns: ["src/renderer.ts"] }, + ], + promptGuidance: { "cross-file-impact": ["Check both sides of the bridge."] }, + adjudicationPolicy: { evidenceMode: "cross_boundary" }, + matchedPaths: ["src/review.ts"], + coveredFamilies: [{ id: "preload", label: "preload" }], + missingFamilies: [{ id: "renderer", label: "renderer" }], }, ], - artifacts: [ - { - artifactType: "diff_bundle", - title: "Diff bundle", - mimeType: "text/plain", - contentText: "diff --git a/src/review.ts b/src/review.ts\n@@ -10,2 +10,4 @@\n context\n+anchored\n+summary only\n", - metadata: null, + rules: { + summary: "1 rule overlay matched", + prompt: "- Preload bridge: missing companion coverage: renderer", + payload: { + changedPaths: ["src/review.ts"], + overlays: [{ + id: "preload-bridge", + label: "Preload bridge", + description: "Strict bridge rule", + matchedPaths: ["src/review.ts"], + rolloutExpectations: ["Keep bridge and consumer updates aligned."], + coveredFamilies: [{ id: "preload", label: "preload" }], + missingFamilies: [{ id: "renderer", label: "renderer" }], + adjudicationPolicy: { evidenceMode: "cross_boundary" }, + }], }, - ], - }); - - const publishReviewPublication = vi.fn(async (args: any) => ({ - id: `publication-${args.runId}`, - runId: args.runId, - destination: args.destination, - reviewEvent: "COMMENT", - status: "published", - reviewUrl: "https://github.com/ade-dev/ade/pull/80#pullrequestreview-1", - remoteReviewId: "1", - summaryBody: "Summary body", - inlineComments: [ - { - findingId: args.findings[0].id, - path: "src/review.ts", - line: 11, - position: 2, - body: "Inline comment", + metadata: { + summary: "1 rule overlay matched", + matchedRuleCount: 1, + ruleCount: 1, + pathCount: 1, + matchedRuleIds: ["preload-bridge"], }, - ], - summaryFindingIds: [args.findings[1].id], - errorMessage: null, - createdAt: "2026-04-06T10:00:00.000Z", - updatedAt: "2026-04-06T10:00:02.000Z", - completedAt: "2026-04-06T10:00:02.000Z", + }, + validation: { + summary: "No validation signals", + prompt: "- No prior ADE validation signals were available.", + payload: { + linkedPr: null, + reviewSnapshot: null, + checks: [], + suites: [], + testRuns: [], + issueInventory: [], + sessionFailures: [], + signals: [], + }, + metadata: { + summary: "No validation signals", + signalCount: 0, + checkCount: 0, + testRunCount: 0, + issueCount: 0, + sessionFailureCount: 0, + suiteCount: 0, + }, + }, })); - const service = createReviewService({ - db: db as any, - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as any, - projectId: "project-1", - projectRoot: "/tmp/ade", - projectDefaultBranch: "main", - laneService: { - getLaneBaseAndBranch: vi.fn().mockReturnValue({ - baseRef: "main", - branchRef: "feature/pr-80", - worktreePath: "/tmp/ade/lane", - laneType: "worktree", - }), - list: vi.fn(async () => []), - } as any, - gitService: { - listRecentCommits: vi.fn(async () => []), - } as any, - agentChatService: { - createSession: vi.fn(async () => ({ - id: "session-pr-review", - laneId: "lane-review", - provider: "codex", - model: "gpt-5.4-codex", - modelId: "openai/gpt-5.4-codex", - })), - getSessionSummary: vi.fn(async () => null), - runSessionTurn: vi.fn(async () => ({ - sessionId: "session-pr-review", - provider: "codex", - model: "gpt-5.4-codex", - modelId: "openai/gpt-5.4-codex", - outputText: JSON.stringify({ - summary: "Two findings on the PR.", - findings: [ - { - title: "Anchored finding", - severity: "high", - body: "This should post inline.", - confidence: 0.92, - filePath: "src/review.ts", - line: 11, - }, + const run = await harness.start(); + await waitFor(() => harness.service.listRuns(), (runs) => runs[0]?.status === "completed"); + + const detail = await harness.service.getRunDetail({ runId: run.id }); + expect(detail?.findings).toEqual([]); + const adjudicationArtifact = detail?.artifacts.find((artifact) => artifact.artifactType === "adjudication_result"); + expect(adjudicationArtifact?.contentText).toContain("rule_policy"); + }); + + it("cites validation and provenance artifacts when late-stage signals back the finding", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("No diff-risk issues.", []), + makeOutput("No cross-file issues.", []), + makeOutput("Validation-backed regression.", [ + makeFinding({ + title: "Fallback broke after the failed validation loop", + filePath: "src/review.ts", + line: 2, + evidence: [ { - title: "Summary finding", - severity: "medium", - body: "This should stay in the summary body.", - confidence: 0.64, + summary: "The diff still returns null directly.", filePath: "src/review.ts", - line: 200, + line: 2, + quote: "+return null;", }, ], }), - })), - } as any, - sessionService: { - updateMeta: vi.fn(), - } as any, - prService: { - getReviewSnapshot: vi.fn(), - publishReviewPublication, - } as any, + ]), + ], }); + mockContextBuilder.buildContext.mockResolvedValueOnce(makeContextPacket({ + matchedRuleOverlays: [ + { + id: "renderer-surface", + label: "Renderer surface", + description: "Renderer rule", + pathPatterns: ["src/review.ts"], + rolloutExpectations: ["Check the fallback behavior."], + companionFamilies: [{ id: "renderer", label: "renderer", pathPatterns: ["src/review.ts"] }], + promptGuidance: {}, + adjudicationPolicy: { evidenceMode: "normal" }, + matchedPaths: ["src/review.ts"], + coveredFamilies: [{ id: "renderer", label: "renderer" }], + missingFamilies: [], + }, + ], + rules: { + summary: "1 rule overlay matched", + prompt: "- Renderer surface: check fallback behavior.", + payload: { + changedPaths: ["src/review.ts"], + overlays: [{ + id: "renderer-surface", + label: "Renderer surface", + description: "Renderer rule", + matchedPaths: ["src/review.ts"], + rolloutExpectations: ["Check the fallback behavior."], + coveredFamilies: [{ id: "renderer", label: "renderer" }], + missingFamilies: [], + adjudicationPolicy: { evidenceMode: "normal" }, + }], + }, + metadata: { + summary: "1 rule overlay matched", + matchedRuleCount: 1, + ruleCount: 1, + pathCount: 1, + matchedRuleIds: ["renderer-surface"], + }, + }, + })); - const first = await service.startRun({ - target: { mode: "pr", laneId: "lane-review", prId: "pr-80" }, - config: { publishBehavior: "auto_publish" }, + const run = await harness.start(); + await waitFor(() => harness.service.listRuns(), (runs) => runs[0]?.status === "completed"); + + const detail = await harness.service.getRunDetail({ runId: run.id }); + expect(detail?.findings).toHaveLength(1); + expect(detail?.findings[0]?.findingClass).toBe("late_stage_regression"); + expect(detail?.findings[0]?.evidence.some((entry) => entry.artifactId && entry.artifactId.length > 0)).toBe(true); + const artifactKinds = new Set(detail?.findings[0]?.evidence.map((entry) => entry.artifactId).filter(Boolean)); + expect(artifactKinds.size).toBeGreaterThanOrEqual(2); + }); + + it("uses late-stage regression when overlapping passes disagree on ADE-native class", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("Intent drift.", [ + makeFinding({ + title: "Fallback intent drift", + findingClass: "intent_drift", + }), + ]), + makeOutput("Late-stage corroboration.", [ + makeFinding({ + title: "Fallback intent drift", + findingClass: "late_stage_regression", + body: "The same fallback regression reappeared after the failed validation loop.", + confidence: 0.79, + }), + ]), + makeOutput("No checks issues.", []), + ], }); + const run = await harness.start(); + await waitFor(() => harness.service.listRuns(), (runs) => runs[0]?.status === "completed"); + + const detail = await harness.service.getRunDetail({ runId: run.id }); + expect(detail?.findings).toHaveLength(1); + expect(detail?.findings[0]?.findingClass).toBe("late_stage_regression"); + }); + + it("filters weak findings that do not carry concrete evidence", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("Weak comment.", [ + makeFinding({ + title: "Maybe rename this helper", + severity: "low", + body: "This name could be clearer for future readers.", + confidence: 0.31, + filePath: null, + line: null, + evidence: [], + }), + ]), + makeOutput("No cross-file issues.", []), + makeOutput("No checks issues.", []), + ], + }); + + const run = await harness.start(); await waitFor( - () => service.listRuns(), - (runs) => runs.some((run) => run.id === first.id && run.status === "completed"), + () => harness.service.listRuns(), + (runs) => runs[0]?.status === "completed", ); - const detail = await service.getRunDetail({ runId: first.id }); - expect(publishReviewPublication).toHaveBeenCalledWith(expect.objectContaining({ - runId: first.id, + const detail = await harness.service.getRunDetail({ runId: run.id }); + expect(detail?.findings).toEqual([]); + expect(detail?.summary).toContain("filtered out during adjudication"); + + const adjudicationArtifact = detail?.artifacts.find((artifact) => artifact.artifactType === "adjudication_result"); + expect(adjudicationArtifact?.contentText).toContain("low_evidence"); + }); + + it("applies run and publication budgets and only publishes adjudicated findings", async () => { + const destination: ReviewPublicationDestination = { + kind: "github_pr_review", + prId: "pr-80", + repoOwner: "ade-dev", + repoName: "ade", + prNumber: 80, + githubUrl: "https://github.com/ade-dev/ade/pull/80", + }; + const harness = createHarness({ + publicationTarget: destination, targetLabel: "PR #80 feature/pr-80 -> main", - })); + target: { mode: "pr", laneId: "lane-review", prId: "pr-80" }, + config: { + publishBehavior: "auto_publish", + budgets: { + maxFiles: 60, + maxDiffChars: 180_000, + maxPromptChars: 220_000, + maxFindings: 2, + maxFindingsPerPass: 4, + maxPublishedFindings: 1, + }, + }, + outputs: [ + makeOutput("Diff-risk findings.", [ + makeFinding({ title: "Null fallback removed", filePath: "src/review.ts", line: 2 }), + makeFinding({ title: "Summary-only risk", severity: "medium", filePath: "src/review.ts", line: 200, evidence: [{ summary: "The diff changes behavior without a regression test.", filePath: "src/review.ts", line: 200, quote: "+missing test coverage" }] }), + ]), + makeOutput("Cross-file overlap.", [ + makeFinding({ title: "Fallback path removed", body: "Downstream callers now receive null.", filePath: "src/review.ts", line: 2, confidence: 0.76 }), + makeFinding({ + title: "Dispatch invariant removed", + severity: "high", + body: "The worker dispatch path now skips the invariant check before enqueueing work.", + filePath: "src/worker.ts", + line: 10, + evidence: [{ summary: "The patch drops the invariant guard in the worker path.", filePath: "src/worker.ts", line: 10, quote: "+dispatchWithoutInvariant()" }], + }), + ]), + makeOutput("Checks and tests.", [ + makeFinding({ title: "Missing regression coverage", severity: "medium", filePath: "src/review.ts", line: 200, evidence: [{ summary: "A failing unit check suggests the diff lacks the previous safety net.", filePath: "src/review.ts", line: 200, quote: "unit-tests: completed / failure" }] }), + ]), + ], + }); + + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs[0]?.status === "completed", + ); + + expect(harness.publishReviewPublication).toHaveBeenCalledTimes(1); + const publicationArgs = harness.publishReviewPublication.mock.calls[0]?.[0]; + expect(publicationArgs.findings).toHaveLength(1); + expect(publicationArgs.findings[0]?.sourcePass).toBe("adjudicated"); + + const detail = await harness.service.getRunDetail({ runId: run.id }); + expect(detail?.findings).toHaveLength(2); + expect(detail?.findings.filter((finding) => finding.publicationState === "published")).toHaveLength(1); expect(detail?.publications).toHaveLength(1); - expect(detail?.publications[0]?.status).toBe("published"); - expect(detail?.publications[0]?.summaryFindingIds).toHaveLength(1); expect(detail?.artifacts.some((artifact) => artifact.artifactType === "publication_request")).toBe(true); - expect(detail?.artifacts.some((artifact) => artifact.artifactType === "publication_result")).toBe(true); - expect(detail?.findings.every((finding) => finding.publicationState === "published")).toBe(true); + }); + + it("reruns a saved multi-pass review through the same shared engine", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("First pass run.", [makeFinding()]), + makeOutput("Cross-file overlap.", [makeFinding({ title: "Fallback path removed", confidence: 0.71 })]), + makeOutput("Checks clear.", []), + makeOutput("Second run diff-risk.", [makeFinding()]), + makeOutput("Second run cross-file.", [makeFinding({ title: "Fallback path removed", confidence: 0.71 })]), + makeOutput("Second run checks.", []), + ], + target: { + mode: "commit_range", + laneId: "lane-review", + baseCommit: "abc123456789", + headCommit: "def456789012", + }, + config: { + selectionMode: "selected_commits", + reasoningEffort: "high", + }, + }); - const savedRuns = await service.listRuns(); - const savedPrRun = savedRuns.find((run) => run.id === first.id); - expect(savedPrRun?.target).toEqual({ mode: "pr", laneId: "lane-review", prId: "pr-80" }); - expect(savedPrRun?.config.publishBehavior).toBe("auto_publish"); + const first = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((run) => run.id === first.id && run.status === "completed"), + ); - const rerun = await service.rerun(first.id); + const rerun = await harness.service.rerun(first.id); await waitFor( - () => service.listRuns(), + () => harness.service.listRuns(), (runs) => runs.some((run) => run.id === rerun.id && run.status === "completed"), ); - expect(publishReviewPublication).toHaveBeenCalledTimes(2); - const rerunDetail = await service.getRunDetail({ runId: rerun.id }); - expect(rerunDetail?.publications).toHaveLength(1); + expect(harness.runSessionTurn).toHaveBeenCalledTimes(6); + const rerunDetail = await harness.service.getRunDetail({ runId: rerun.id }); + expect(rerunDetail?.artifacts.filter((artifact) => artifact.artifactType === "pass_prompt")).toHaveLength(3); + + const rerunRecord = (await harness.service.listRuns()).find((entry) => entry.id === rerun.id); + expect(rerunRecord?.target).toEqual({ + mode: "commit_range", + laneId: "lane-review", + baseCommit: "abc123456789", + headCommit: "def456789012", + }); + expect(rerunRecord?.config.selectionMode).toBe("selected_commits"); + expect(rerunRecord?.config.reasoningEffort).toBe("high"); }); }); diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index 480ed3551..9570a3c4e 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -4,6 +4,8 @@ import type { ReviewEventPayload, ReviewEvidence, ReviewFinding, + ReviewFindingAdjudication, + ReviewFindingClass, ReviewPublication, ReviewPublicationDestination, ReviewPublicationInlineComment, @@ -14,6 +16,7 @@ import type { ReviewRunConfig, ReviewRunDetail, ReviewRunStatus, + ReviewPassKey, ReviewSeverity, ReviewSeveritySummary, ReviewSourcePass, @@ -32,8 +35,17 @@ import type { createLaneService } from "../lanes/laneService"; import type { createGitOperationsService } from "../git/gitOperationsService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createSessionService } from "../sessions/sessionService"; +import type { createSessionDeltaService } from "../sessions/sessionDeltaService"; import type { createPrService } from "../prs/prService"; +import type { createIssueInventoryService } from "../prs/issueInventoryService"; +import type { createTestService } from "../tests/testService"; import { createReviewTargetMaterializer } from "./reviewTargetMaterializer"; +import { createReviewContextBuilder, type ReviewContextPacket } from "./reviewContextBuilder"; +import { + collectRulePromptGuidance, + overlayMatchesPath, + type MatchedReviewRuleOverlay, +} from "./reviewRuleRegistry"; type ReviewRunRow = { id: string; @@ -60,6 +72,7 @@ type ReviewFindingRow = { run_id: string; title: string; severity: string; + finding_class: string | null; body: string; confidence: number; evidence_json: string | null; @@ -68,6 +81,8 @@ type ReviewFindingRow = { anchor_state: string; source_pass: string; publication_state: string; + originating_passes_json: string | null; + adjudication_json: string | null; }; type ReviewRunArtifactRow = { @@ -103,7 +118,7 @@ const REVIEW_MODEL_FALLBACK_ID = "openai/gpt-5.4-codex"; function resolveBuiltinReviewModelId(): string { const candidates = [ getDefaultModelDescriptor("codex")?.id ?? null, - getDefaultModelDescriptor("unified")?.id ?? null, + getDefaultModelDescriptor("opencode")?.id ?? null, REVIEW_MODEL_FALLBACK_ID, getDefaultModelDescriptor("claude")?.id ?? null, getDefaultModelDescriptor("cursor")?.id ?? null, @@ -124,6 +139,87 @@ const DEFAULT_BUDGETS: ReviewRunConfig["budgets"] = { maxDiffChars: 180_000, maxPromptChars: 220_000, maxFindings: 12, + maxFindingsPerPass: 6, + maxPublishedFindings: 6, +}; + +const REVIEW_PASS_ORDER: ReviewPassKey[] = [ + "diff-risk", + "cross-file-impact", + "checks-and-tests", +]; + +const SEVERITY_SCORE: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 1, +}; + +type MaterializedChangedFile = { + filePath: string; + excerpt: string; + lineNumbers: number[]; + diffPositionsByLine: Record; +}; + +type PassDefinition = { + key: ReviewPassKey; + label: string; + focus: string; + extraInstructions: string[]; +}; + +type PassCandidateFinding = { + id: string; + runId: string; + passKey: ReviewPassKey; + title: string; + severity: ReviewSeverity; + findingClass: ReviewFindingClass | null; + body: string; + confidence: number; + evidence: ReviewEvidence[]; + filePath: string | null; + line: number | null; + anchorState: ReviewFinding["anchorState"]; + evidenceScore: number; + lowSignal: boolean; + score: number; +}; + +type ReviewContextArtifactIds = { + provenanceArtifactId: string; + rulesArtifactId: string; + validationArtifactId: string; +}; + +type PassExecutionResult = { + pass: PassDefinition; + summary: string | null; + candidates: PassCandidateFinding[]; + promptArtifactId: string; + outputArtifactId: string; + findingsArtifactId: string; + budgetTrimmedCount: number; +}; + +type AdjudicationRejectedFinding = { + candidateIds: string[]; + passKeys: ReviewPassKey[]; + title: string; + reason: "low_evidence" | "low_signal" | "duplicate" | "budget" | "rule_policy"; + detail: string; + score: number; +}; + +type AdjudicationOutcome = { + summary: string; + findings: ReviewFinding[]; + rejected: AdjudicationRejectedFinding[]; + publicationEligibleCount: number; + totalCandidateCount: number; }; function defaultSeveritySummary(): ReviewSeveritySummary { @@ -173,6 +269,48 @@ function normalizeConfidence(value: unknown): number { return 0.5; } +function normalizeFindingClass(value: unknown): ReviewFindingClass | null { + const raw = typeof value === "string" ? value.trim().toLowerCase().replace(/[\s-]+/g, "_") : ""; + if (raw === "intent_drift") return "intent_drift"; + if (raw === "incomplete_rollout") return "incomplete_rollout"; + if (raw === "late_stage_regression") return "late_stage_regression"; + return null; +} + +const FINDING_CLASS_PRIORITY: ReviewFindingClass[] = [ + "late_stage_regression", + "incomplete_rollout", + "intent_drift", +]; + +function mergeFindingClass(classes: Array): ReviewFindingClass | null { + for (const findingClass of FINDING_CLASS_PRIORITY) { + if (classes.some((candidate) => candidate === findingClass)) { + return findingClass; + } + } + return null; +} + +function normalizeBudgetConfig(budgets?: Partial | null): ReviewRunConfig["budgets"] { + return { + maxFiles: clampNumber(Number(budgets?.maxFiles ?? DEFAULT_BUDGETS.maxFiles), 1, 500), + maxDiffChars: clampNumber(Number(budgets?.maxDiffChars ?? DEFAULT_BUDGETS.maxDiffChars), 4_000, 1_000_000), + maxPromptChars: clampNumber(Number(budgets?.maxPromptChars ?? DEFAULT_BUDGETS.maxPromptChars), 4_000, 1_000_000), + maxFindings: clampNumber(Number(budgets?.maxFindings ?? DEFAULT_BUDGETS.maxFindings), 1, 50), + maxFindingsPerPass: clampNumber( + Number(budgets?.maxFindingsPerPass ?? DEFAULT_BUDGETS.maxFindingsPerPass ?? DEFAULT_BUDGETS.maxFindings), + 1, + 50, + ), + maxPublishedFindings: clampNumber( + Number(budgets?.maxPublishedFindings ?? DEFAULT_BUDGETS.maxPublishedFindings ?? DEFAULT_BUDGETS.maxFindings), + 1, + 50, + ), + }; +} + function extractJsonObject(raw: string): Record | null { const candidates: string[] = []; const trimmed = raw.trim(); @@ -251,39 +389,116 @@ function serializeSeveritySummary(summary: ReviewSeveritySummary): string { return JSON.stringify(summary); } -function buildPrompt(args: { +const REVIEW_PASSES: PassDefinition[] = [ + { + key: "diff-risk", + label: "Diff risk", + focus: "changed-file correctness, regressions, edge cases, migrations, and unsafe behavior directly visible in the diff", + extraInstructions: [ + "Stay anchored to the changed code and changed lines first.", + "Prioritize regressions, broken invariants, unsafe defaults, and risky data handling.", + ], + }, + { + key: "cross-file-impact", + label: "Cross-file impact", + focus: "impacted call sites, shared contracts, dependent code paths, and regressions likely to surface outside the touched files", + extraInstructions: [ + "Follow interfaces, configuration, and likely callers beyond the edited files.", + "Reject speculative concerns unless the diff gives a concrete reason to believe a wider breakage is likely.", + ], + }, + { + key: "checks-and-tests", + label: "Checks and tests", + focus: "failing checks, validation gaps, and the strongest missing-test risks that would allow a regression to slip through", + extraInstructions: [ + "Prefer concrete missing-test risks over generic 'add more tests' advice.", + "Use check/test context when present, but do not invent failures that were not supplied.", + ], + }, +]; + +function buildChangedFilesSummary(changedFiles: Array<{ filePath: string }>): string { + const changedFilesSummary = changedFiles.length > 0 + ? changedFiles.map((entry) => `- ${entry.filePath}`).join("\n") + : "- No changed files were detected."; + return changedFilesSummary; +} + +function buildContextArtifactHints(args: { + artifactIds: ReviewContextArtifactIds; + includeValidation: boolean; +}): string[] { + const lines = [ + `- provenance_brief artifact id: ${args.artifactIds.provenanceArtifactId}`, + `- rule_overlays artifact id: ${args.artifactIds.rulesArtifactId}`, + ]; + if (args.includeValidation) { + lines.push(`- validation_signals artifact id: ${args.artifactIds.validationArtifactId}`); + } + return lines; +} + +function buildPassPrompt(args: { run: ReviewRun; + pass: PassDefinition; diffText: string; changedFiles: Array<{ filePath: string }>; + context: ReviewContextPacket; + contextArtifactIds: ReviewContextArtifactIds; }): string { - const changedFilesSummary = args.changedFiles.length > 0 - ? args.changedFiles.map((entry) => `- ${entry.filePath}`).join("\n") - : "- No changed files were detected."; + const changedFilesSummary = buildChangedFilesSummary(args.changedFiles); + const includeValidation = args.pass.key === "checks-and-tests"; + const ruleGuidance = collectRulePromptGuidance(args.context.matchedRuleOverlays, args.pass.key); return [ "You are ADE's local code reviewer.", "Review only the provided local diff bundle.", + `This pass is ${args.pass.label.toLowerCase()} and it focuses on ${args.pass.focus}.`, "Prioritize correctness, regressions, security, data loss, race conditions, risky migrations, and missing tests.", "Do not suggest style-only nits or speculative rewrites.", + "Every finding must include concrete evidence from the diff bundle or supplied review context.", `Return strict JSON only with this exact top-level shape: {"summary": string, "findings": Finding[]}.`, "Each Finding must be an object with:", '- "title": short issue title', '- "severity": one of "critical", "high", "medium", "low", "info"', + '- "findingClass": optional, one of "intent_drift", "incomplete_rollout", "late_stage_regression", or null', '- "body": concise explanation of the risk and why it matters', '- "confidence": number between 0 and 1', '- "filePath": changed file path when known, otherwise null', '- "line": line number when known, otherwise null', - '- "evidence": array of objects with {"summary": string, "quote": string|null, "filePath": string|null, "line": number|null}', - `Return at most ${args.run.config.budgets.maxFindings} findings.`, + '- "evidence": array of objects with {"summary": string, "quote": string|null, "filePath": string|null, "line": number|null, "artifactId": string|null}', + `Return at most ${args.run.config.budgets.maxFindingsPerPass ?? args.run.config.budgets.maxFindings} findings.`, "If there are no real issues, return an empty findings array and explain that in summary.", "", + `Pass key: ${args.pass.key}`, `Review target: ${args.run.targetLabel}`, `Selection mode: ${args.run.config.selectionMode}`, `Publish behavior: ${args.run.config.publishBehavior}`, "", + "Pass guidance:", + ...args.pass.extraInstructions.map((instruction) => `- ${instruction}`), + ...(ruleGuidance.length > 0 ? ["", "Matched rule guidance:", ...ruleGuidance.map((instruction) => `- ${instruction}`)] : []), + "", "Changed files:", changedFilesSummary, "", + "Context artifact ids you may cite in evidence when relevant:", + ...buildContextArtifactHints({ + artifactIds: args.contextArtifactIds, + includeValidation, + }), + "", + "ADE provenance and intent context:", + args.context.provenance.prompt, + "", + "Repo/path rule overlays:", + args.context.rules.prompt, + "", + "Checks and validation context:", + includeValidation ? args.context.validation.prompt : "- Full validation evidence is reserved for the checks-and-tests pass.", + "", "Diff bundle:", truncateText(args.diffText, args.run.config.budgets.maxPromptChars), ].join("\n"); @@ -295,8 +510,17 @@ function parseEvidence(value: unknown): ReviewEvidence[] { if (!isRecord(entry)) return []; const summary = cleanLine(String(entry.summary ?? "")); if (!summary) return []; + const rawKind = typeof entry.kind === "string" ? entry.kind.trim().toLowerCase() : ""; + const kind: ReviewEvidence["kind"] = + rawKind === "artifact" + ? "artifact" + : rawKind === "file_snapshot" + ? "file_snapshot" + : rawKind === "diff_hunk" + ? "diff_hunk" + : "quote"; return [{ - kind: "quote", + kind, summary, filePath: typeof entry.filePath === "string" ? entry.filePath.trim() || null : null, line: typeof entry.line === "number" && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : null, @@ -318,11 +542,163 @@ function computeAnchorState(args: { return match.lineNumbers.has(args.line) ? "anchored" : "file_only"; } +function hasConcreteEvidence(evidence: ReviewEvidence[]): boolean { + return evidence.some((entry) => { + if (entry.kind === "artifact") return false; + return Boolean( + (typeof entry.quote === "string" && entry.quote.trim().length > 0) + || (entry.filePath && entry.line != null) + || (entry.filePath && entry.kind === "diff_hunk"), + ); + }); +} + +function scoreEvidence(evidence: ReviewEvidence[]): number { + if (evidence.length === 0) return 0; + const quoteCount = evidence.filter((entry) => typeof entry.quote === "string" && entry.quote.trim().length > 0).length; + const anchoredCount = evidence.filter((entry) => Boolean(entry.filePath) && entry.line != null).length; + const artifactCount = evidence.filter((entry) => entry.kind === "artifact" && entry.artifactId).length; + return clampNumber( + 0.18 + + Math.min(quoteCount, 3) * 0.18 + + Math.min(anchoredCount, 2) * 0.12 + + Math.min(artifactCount, 2) * 0.08, + 0, + 1, + ); +} + +function isLowSignalFinding(args: { + title: string; + body: string; + severity: ReviewSeverity; + confidence: number; + evidenceScore: number; +}): boolean { + const text = `${args.title} ${args.body}`.toLowerCase(); + const nitPatterns = [ + /\bnit\b/, + /\bnitpick\b/, + /\bstyle\b/, + /\bformat(?:ting)?\b/, + /\bwhitespace\b/, + /\brename\b/, + /\bnaming\b/, + /\bcomment\b/, + /\btypo\b/, + /\bdocs?\b/, + ]; + const looksNitpicky = nitPatterns.some((pattern) => pattern.test(text)); + return looksNitpicky && args.severity !== "critical" && args.severity !== "high" && args.confidence < 0.8 && args.evidenceScore < 0.7; +} + +function buildCandidateScore(args: { + severity: ReviewSeverity; + confidence: number; + evidenceScore: number; + passCount?: number; +}): number { + const corroborationBonus = Math.max(0, (args.passCount ?? 1) - 1) * 0.15; + return Number((SEVERITY_SCORE[args.severity] + args.confidence * 2 + args.evidenceScore * 1.5 + corroborationBonus).toFixed(4)); +} + +function tokenizeFindingText(value: string): string[] { + return cleanLine(value) + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter((token) => token.length >= 3) + .filter((token) => !new Set([ + "this", + "that", + "with", + "from", + "into", + "when", + "then", + "there", + "return", + "returns", + "added", + "change", + "changes", + "issue", + "risk", + "will", + "could", + "should", + "missing", + "tests", + "test", + "check", + "checks", + "code", + "file", + "files", + ]).has(token)); +} + +function similarityScore(left: string, right: string): number { + const leftTokens = tokenizeFindingText(left); + const rightTokens = tokenizeFindingText(right); + if (leftTokens.length === 0 || rightTokens.length === 0) return 0; + const leftSet = new Set(leftTokens); + const rightSet = new Set(rightTokens); + const intersection = Array.from(leftSet).filter((token) => rightSet.has(token)).length; + const union = new Set([...leftSet, ...rightSet]).size; + return union > 0 ? intersection / union : 0; +} + +function hasOverlappingEvidence(left: PassCandidateFinding, right: PassCandidateFinding): boolean { + for (const leftEntry of left.evidence) { + for (const rightEntry of right.evidence) { + if (leftEntry.filePath && leftEntry.filePath === rightEntry.filePath && leftEntry.line != null && leftEntry.line === rightEntry.line) { + return true; + } + if (leftEntry.quote && rightEntry.quote && cleanLine(leftEntry.quote) === cleanLine(rightEntry.quote)) { + return true; + } + } + } + return false; +} + +function findingsOverlap(left: PassCandidateFinding, right: PassCandidateFinding): boolean { + const sameFile = left.filePath && right.filePath && left.filePath === right.filePath; + const lineDistance = left.line != null && right.line != null ? Math.abs(left.line - right.line) : null; + const titleSimilarity = similarityScore(left.title, right.title); + const bodySimilarity = similarityScore(left.body, right.body); + const similarText = Math.max(titleSimilarity, bodySimilarity, similarityScore(`${left.title} ${left.body}`, `${right.title} ${right.body}`)); + if (sameFile && lineDistance != null && lineDistance <= 3 && similarText >= 0.22) return true; + if (sameFile && similarText >= 0.35) return true; + if (hasOverlappingEvidence(left, right) && similarText >= 0.18) return true; + return !left.filePath && !right.filePath && similarText >= 0.65; +} + +function dedupeEvidenceEntries(evidence: ReviewEvidence[]): ReviewEvidence[] { + const seen = new Set(); + const deduped: ReviewEvidence[] = []; + for (const entry of evidence) { + const key = JSON.stringify([ + entry.kind, + entry.summary, + entry.filePath, + entry.line, + entry.quote, + entry.artifactId, + ]); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(entry); + } + return deduped; +} + function normalizeParsedFindings(args: { runId: string; + passKey: ReviewPassKey; parsed: Record | null; changedFilesByPath: Map }>; -}): { summary: string | null; findings: ReviewFinding[] } { +}): { summary: string | null; findings: PassCandidateFinding[] } { const findingsRaw = Array.isArray(args.parsed?.findings) ? args.parsed?.findings : []; const findings = findingsRaw.flatMap((entry) => { if (!isRecord(entry)) return []; @@ -350,20 +726,34 @@ function normalizeParsedFindings(args: { line, changedFilesByPath: args.changedFilesByPath, }); - - const finding: ReviewFinding = { + const evidenceScore = scoreEvidence(evidence); + const confidence = normalizeConfidence(entry.confidence); + const finding: PassCandidateFinding = { id: randomUUID(), runId: args.runId, + passKey: args.passKey, title, severity: normalizeSeverity(entry.severity), + findingClass: normalizeFindingClass(entry.findingClass), body, - confidence: normalizeConfidence(entry.confidence), + confidence, evidence, filePath, line, anchorState, - sourcePass: "single_pass" as ReviewSourcePass, - publicationState: "local_only" as ReviewPublicationState, + evidenceScore, + lowSignal: isLowSignalFinding({ + title, + body, + severity: normalizeSeverity(entry.severity), + confidence, + evidenceScore, + }), + score: buildCandidateScore({ + severity: normalizeSeverity(entry.severity), + confidence, + evidenceScore, + }), }; return [finding]; }); @@ -372,6 +762,364 @@ function normalizeParsedFindings(args: { return { summary, findings }; } +function summarizeAdjudication(args: { + keptFindings: ReviewFinding[]; + rejected: AdjudicationRejectedFinding[]; + totalCandidateCount: number; +}): string { + if (args.keptFindings.length === 0) { + if (args.totalCandidateCount === 0) { + return "Multi-pass review completed with no actionable findings."; + } + return "Multi-pass review completed, but every candidate was filtered out during adjudication."; + } + + const corroboratedCount = args.keptFindings.filter((finding) => (finding.originatingPasses?.length ?? 0) > 1).length; + const publicationEligibleCount = args.keptFindings.filter((finding) => finding.adjudication?.publicationEligible).length; + return [ + `Multi-pass review kept ${args.keptFindings.length} high-signal finding(s) from ${args.totalCandidateCount} candidate(s).`, + corroboratedCount > 0 ? `${corroboratedCount} finding(s) were corroborated by multiple passes.` : null, + publicationEligibleCount > 0 ? `${publicationEligibleCount} finding(s) cleared the publication threshold.` : null, + args.rejected.length > 0 ? `${args.rejected.length} candidate(s) were filtered during adjudication.` : null, + ].filter((line): line is string => Boolean(line)).join(" "); +} + +function selectPreferredAnchor(findings: PassCandidateFinding[]): Pick { + const ranked = [...findings].sort((left, right) => { + const anchorDelta = (left.anchorState === "anchored" ? 2 : left.anchorState === "file_only" ? 1 : 0) + - (right.anchorState === "anchored" ? 2 : right.anchorState === "file_only" ? 1 : 0); + if (anchorDelta !== 0) return -anchorDelta; + if (left.filePath && !right.filePath) return -1; + if (!left.filePath && right.filePath) return 1; + return (left.line ?? Number.MAX_SAFE_INTEGER) - (right.line ?? Number.MAX_SAFE_INTEGER); + }); + const preferred = ranked[0]; + return { + filePath: preferred?.filePath ?? null, + line: preferred?.line ?? null, + anchorState: preferred?.anchorState ?? "missing", + }; +} + +function combineConfidence(findings: PassCandidateFinding[]): number { + const combined = findings.reduce((product, finding) => product * (1 - clampNumber(finding.confidence, 0, 1)), 1); + return clampNumber(1 - combined, 0, 0.99); +} + +function groupPassCandidates(candidates: PassCandidateFinding[]): PassCandidateFinding[][] { + const remaining = [...candidates].sort((left, right) => right.score - left.score); + const groups: PassCandidateFinding[][] = []; + while (remaining.length > 0) { + const seed = remaining.shift(); + if (!seed) continue; + const group = [seed]; + let index = 0; + while (index < remaining.length) { + const candidate = remaining[index]; + if (candidate && group.some((entry) => findingsOverlap(entry, candidate))) { + group.push(candidate); + remaining.splice(index, 1); + continue; + } + index += 1; + } + groups.push(group); + } + return groups; +} + +function getCandidatePathSet(group: PassCandidateFinding[]): string[] { + return Array.from(new Set( + group.flatMap((candidate) => [ + candidate.filePath, + ...candidate.evidence.map((entry) => entry.filePath), + ]).filter((value): value is string => Boolean(value?.trim())), + )); +} + +function countConcreteAnchorFiles(evidence: ReviewEvidence[]): number { + return new Set( + evidence + .filter((entry) => entry.kind !== "artifact") + .filter((entry) => Boolean(entry.filePath) && (entry.line != null || (entry.quote?.trim() ?? "").length > 0 || entry.kind === "diff_hunk")) + .map((entry) => entry.filePath as string), + ).size; +} + +function hasArtifactEvidence(evidence: ReviewEvidence[], artifactIds: string[]): boolean { + const allowed = new Set(artifactIds); + return evidence.some((entry) => entry.kind === "artifact" && entry.artifactId && allowed.has(entry.artifactId)); +} + +function buildContextArtifactEvidence(args: { + group: PassCandidateFinding[]; + context: ReviewContextPacket; + artifactIds: ReviewContextArtifactIds; + relevantOverlays: MatchedReviewRuleOverlay[]; +}): ReviewEvidence[] { + const pathSet = new Set(getCandidatePathSet(args.group)); + const evidence: ReviewEvidence[] = []; + if (args.relevantOverlays.length > 0) { + evidence.push({ + kind: "artifact", + summary: `Matched rule overlays: ${args.relevantOverlays.map((overlay) => overlay.id).join(", ")}`, + filePath: null, + line: null, + quote: null, + artifactId: args.artifactIds.rulesArtifactId, + }); + } + const lateStageMatches = args.context.provenance.payload.lateStageSignals.filter((signal) => + signal.filePaths.some((filePath) => pathSet.has(filePath)), + ); + if (lateStageMatches.length > 0) { + evidence.push({ + kind: "artifact", + summary: `Late-stage ADE signals overlap this area: ${lateStageMatches.map((signal) => signal.summary).join(" | ")}`, + filePath: null, + line: null, + quote: null, + artifactId: args.artifactIds.provenanceArtifactId, + }); + } + const includesChecksPass = args.group.some((candidate) => candidate.passKey === "checks-and-tests"); + if (includesChecksPass && args.context.validation.payload.signals.length > 0) { + evidence.push({ + kind: "artifact", + summary: `Validation signals: ${args.context.validation.payload.signals.slice(0, 2).map((signal) => signal.summary).join(" | ")}`, + filePath: null, + line: null, + quote: null, + artifactId: args.artifactIds.validationArtifactId, + }); + } + return evidence; +} + +function inferFindingClass(args: { + group: PassCandidateFinding[]; + context: ReviewContextPacket; + relevantOverlays: MatchedReviewRuleOverlay[]; +}): ReviewFindingClass | null { + const explicitClass = mergeFindingClass(args.group.map((candidate) => candidate.findingClass)); + if (explicitClass) return explicitClass; + const pathSet = new Set(getCandidatePathSet(args.group)); + const hasLateStageSignal = args.context.provenance.payload.lateStageSignals.some((signal) => + signal.filePaths.some((filePath) => pathSet.has(filePath)), + ); + if (hasLateStageSignal) return "late_stage_regression"; + const hasStrictMissingRollout = args.relevantOverlays.some((overlay) => + overlay.adjudicationPolicy.evidenceMode === "cross_boundary" && overlay.missingFamilies.length > 0, + ); + if (hasStrictMissingRollout) return "incomplete_rollout"; + const hasIntentContext = Boolean( + args.context.provenance.payload.missions.length > 0 + || args.context.provenance.payload.laneSnapshot?.agentSummary + || args.context.provenance.payload.laneSnapshot?.missionSummary, + ); + const wordingSuggestsDrift = args.group.some((candidate) => + /\b(expected|intent|should|instead|missing|omits?|drift)\b/i.test(`${candidate.title} ${candidate.body}`), + ); + if (hasIntentContext && wordingSuggestsDrift) { + return "intent_drift"; + } + return null; +} + +function evaluateRuleEvidencePolicy(args: { + evidence: ReviewEvidence[]; + relevantOverlays: MatchedReviewRuleOverlay[]; + artifactIds: ReviewContextArtifactIds; +}): { ok: boolean; detail: string | null } { + const strictOverlays = args.relevantOverlays.filter((overlay) => overlay.adjudicationPolicy.evidenceMode === "cross_boundary"); + if (strictOverlays.length === 0) { + return { ok: true, detail: null }; + } + const concreteAnchorFiles = countConcreteAnchorFiles(args.evidence); + const hasSupportArtifact = hasArtifactEvidence(args.evidence, [ + args.artifactIds.provenanceArtifactId, + args.artifactIds.validationArtifactId, + ]); + if (concreteAnchorFiles >= 2 || (concreteAnchorFiles >= 1 && hasSupportArtifact)) { + return { ok: true, detail: null }; + } + return { + ok: false, + detail: `Rule overlays ${strictOverlays.map((overlay) => overlay.id).join(", ")} require either two concrete file anchors or one concrete anchor plus provenance/validation artifact support.`, + }; +} + +function adjudicatePassFindings(args: { + runId: string; + passResults: PassExecutionResult[]; + budgets: ReviewRunConfig["budgets"]; + context: ReviewContextPacket; + artifactIds: ReviewContextArtifactIds; +}): AdjudicationOutcome { + const allCandidates = args.passResults.flatMap((result) => result.candidates); + const groupedCandidates = groupPassCandidates(allCandidates); + const findings: ReviewFinding[] = []; + const rejected: AdjudicationRejectedFinding[] = []; + + for (const group of groupedCandidates) { + const passes = Array.from(new Set(group.map((candidate) => candidate.passKey))).sort( + (left, right) => REVIEW_PASS_ORDER.indexOf(left) - REVIEW_PASS_ORDER.indexOf(right), + ); + const bestCandidate = [...group].sort((left, right) => right.score - left.score)[0]; + if (!bestCandidate) continue; + const candidatePaths = getCandidatePathSet(group); + const relevantOverlays = args.context.matchedRuleOverlays.filter((overlay) => + candidatePaths.some((filePath) => overlayMatchesPath(overlay, filePath)), + ); + const mergedEvidence = dedupeEvidenceEntries([ + ...group.flatMap((candidate) => candidate.evidence), + ...args.passResults + .filter((result) => passes.includes(result.pass.key)) + .map((result) => ({ + kind: "artifact" as const, + summary: `Raw ${result.pass.key} pass output`, + filePath: null, + line: null, + quote: null, + artifactId: result.findingsArtifactId, + })), + ...buildContextArtifactEvidence({ + group, + context: args.context, + artifactIds: args.artifactIds, + relevantOverlays, + }), + ]).slice(0, 10); + const evidenceScore = Math.max(bestCandidate.evidenceScore, scoreEvidence(mergedEvidence)); + const lowSignal = group.every((candidate) => candidate.lowSignal); + const findingClass = inferFindingClass({ + group, + context: args.context, + relevantOverlays, + }); + const score = buildCandidateScore({ + severity: group + .map((candidate) => candidate.severity) + .sort((left, right) => SEVERITY_SCORE[right] - SEVERITY_SCORE[left])[0] ?? bestCandidate.severity, + confidence: combineConfidence(group), + evidenceScore, + passCount: passes.length, + }); + + if (!hasConcreteEvidence(mergedEvidence)) { + rejected.push({ + candidateIds: group.map((candidate) => candidate.id), + passKeys: passes, + title: bestCandidate.title, + reason: "low_evidence", + detail: "The finding did not retain enough concrete evidence after adjudication.", + score, + }); + continue; + } + + if (lowSignal && passes.length < 2) { + rejected.push({ + candidateIds: group.map((candidate) => candidate.id), + passKeys: passes, + title: bestCandidate.title, + reason: "low_signal", + detail: "The finding looked nitpicky and was not corroborated by another pass.", + score, + }); + continue; + } + + const rulePolicy = evaluateRuleEvidencePolicy({ + evidence: mergedEvidence, + relevantOverlays, + artifactIds: args.artifactIds, + }); + if (!rulePolicy.ok) { + rejected.push({ + candidateIds: group.map((candidate) => candidate.id), + passKeys: passes, + title: bestCandidate.title, + reason: "rule_policy", + detail: rulePolicy.detail ?? "The finding did not satisfy the matched repo/path rule evidence policy.", + score, + }); + continue; + } + + const preferredAnchor = selectPreferredAnchor(group); + const confidence = combineConfidence(group); + const severity = group + .map((candidate) => candidate.severity) + .sort((left, right) => SEVERITY_SCORE[right] - SEVERITY_SCORE[left])[0] ?? bestCandidate.severity; + const publicationEligible = evidenceScore >= 0.55 && confidence >= 0.45 && severity !== "info"; + const adjudication: ReviewFindingAdjudication = { + score, + candidateCount: group.length, + mergedFindingIds: group.map((candidate) => candidate.id), + rationale: [ + passes.length > 1 + ? `Merged overlapping findings from ${passes.join(", ")} with shared evidence.` + : "Accepted because the finding carried concrete evidence and cleared the adjudication threshold.", + relevantOverlays.length > 0 + ? `Matched rule overlays: ${relevantOverlays.map((overlay) => overlay.id).join(", ")}.` + : null, + findingClass ? `Primary ADE-native class: ${findingClass}.` : null, + ].filter((value): value is string => Boolean(value)).join(" "), + publicationEligible, + }; + + findings.push({ + id: randomUUID(), + runId: args.runId, + title: bestCandidate.title, + severity, + findingClass, + body: passes.length > 1 + ? `${bestCandidate.body} This risk was corroborated by ${passes.join(", ")}.` + : bestCandidate.body, + confidence, + evidence: mergedEvidence, + filePath: preferredAnchor.filePath, + line: preferredAnchor.line, + anchorState: preferredAnchor.anchorState, + sourcePass: "adjudicated" as ReviewSourcePass, + publicationState: "local_only" as ReviewPublicationState, + originatingPasses: passes, + adjudication, + }); + } + + const keptFindings = findings + .sort((left, right) => (right.adjudication?.score ?? 0) - (left.adjudication?.score ?? 0)) + .slice(0, args.budgets.maxFindings); + const keptIds = new Set(keptFindings.map((finding) => finding.id)); + for (const finding of findings) { + if (keptIds.has(finding.id)) continue; + rejected.push({ + candidateIds: finding.adjudication?.mergedFindingIds ?? [], + passKeys: finding.originatingPasses ?? [], + title: finding.title, + reason: "budget", + detail: "The finding cleared adjudication but was trimmed by the run-level budget.", + score: finding.adjudication?.score ?? 0, + }); + } + + const publicationEligibleCount = keptFindings.filter((finding) => finding.adjudication?.publicationEligible).length; + return { + summary: summarizeAdjudication({ + keptFindings, + rejected, + totalCandidateCount: allCandidates.length, + }), + findings: keptFindings, + rejected, + publicationEligibleCount, + totalCandidateCount: allCandidates.length, + }; +} + function tallySeveritySummary(findings: ReviewFinding[]): ReviewSeveritySummary { const summary = defaultSeveritySummary(); for (const finding of findings) { @@ -381,6 +1129,15 @@ function tallySeveritySummary(findings: ReviewFinding[]): ReviewSeveritySummary } function mapRunRow(row: ReviewRunRow): ReviewRun { + const config = safeJsonParse(row.config_json, { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: DEFAULT_REVIEW_MODEL_ID, + reasoningEffort: null, + budgets: DEFAULT_BUDGETS, + publishBehavior: "local_only", + }); return { id: row.id, projectId: row.project_id, @@ -389,15 +1146,10 @@ function mapRunRow(row: ReviewRunRow): ReviewRun { mode: "working_tree", laneId: row.lane_id, }), - config: safeJsonParse(row.config_json, { - compareAgainst: { kind: "default_branch" }, - selectionMode: "full_diff", - dirtyOnly: false, - modelId: DEFAULT_REVIEW_MODEL_ID, - reasoningEffort: null, - budgets: DEFAULT_BUDGETS, - publishBehavior: "local_only", - }), + config: { + ...config, + budgets: normalizeBudgetConfig(config.budgets), + }, targetLabel: row.target_label, compareTarget: safeJsonParse(row.compare_target_json, null), status: (row.status as ReviewRunStatus) ?? "failed", @@ -419,6 +1171,7 @@ function mapFindingRow(row: ReviewFindingRow): ReviewFinding { runId: row.run_id, title: row.title, severity: normalizeSeverity(row.severity), + findingClass: normalizeFindingClass(row.finding_class), body: row.body, confidence: clampNumber(Number(row.confidence ?? 0.5), 0, 1), evidence: safeJsonParse(row.evidence_json, []), @@ -427,6 +1180,8 @@ function mapFindingRow(row: ReviewFindingRow): ReviewFinding { anchorState: (row.anchor_state as ReviewFinding["anchorState"]) ?? "missing", sourcePass: (row.source_pass as ReviewSourcePass) ?? "single_pass", publicationState: (row.publication_state as ReviewPublicationState) ?? "local_only", + originatingPasses: safeJsonParse(row.originating_passes_json, []), + adjudication: safeJsonParse(row.adjudication_json, null), }; } @@ -479,6 +1234,9 @@ export function createReviewService({ gitService, agentChatService, sessionService, + sessionDeltaService, + testService, + issueInventoryService, prService, onEvent, }: { @@ -487,19 +1245,32 @@ export function createReviewService({ projectId: string; projectRoot: string; projectDefaultBranch: string | null; - laneService: Pick, "getLaneBaseAndBranch" | "list">; + laneService: Pick, "getLaneBaseAndBranch" | "getStateSnapshot" | "list">; gitService: Pick, "listRecentCommits">; agentChatService: Pick, "createSession" | "getSessionSummary" | "runSessionTurn">; sessionService: Pick, "updateMeta">; - prService?: Pick, "getReviewSnapshot" | "publishReviewPublication">; + sessionDeltaService: Pick, "listRecentLaneSessionDeltas">; + testService: Pick, "listRuns" | "getLogTail" | "listSuites">; + issueInventoryService: Pick, "getInventory">; + prService?: Pick, "getReviewSnapshot" | "getChecks" | "publishReviewPublication">; onEvent?: (event: ReviewEventPayload) => void; }) { const materializer = createReviewTargetMaterializer({ laneService, prService }); + const contextBuilder = createReviewContextBuilder({ + db, + projectId, + logger, + laneService, + sessionDeltaService, + testService, + issueInventoryService, + prService, + }); const activeRuns = new Set(); let disposed = false; const configuredDefaultModelId = getDefaultModelDescriptor("codex")?.id - ?? getDefaultModelDescriptor("unified")?.id + ?? getDefaultModelDescriptor("opencode")?.id ?? REVIEW_MODEL_FALLBACK_ID; const defaultReviewModelId = getModelById(configuredDefaultModelId)?.id ?? DEFAULT_REVIEW_MODEL_ID; @@ -594,7 +1365,17 @@ export function createReviewService({ ); } - function insertArtifact(runId: string, artifact: Omit): void { + function insertArtifact(runId: string, artifact: Omit): ReviewRunArtifact { + const record: ReviewRunArtifact = { + id: randomUUID(), + runId, + artifactType: artifact.artifactType, + title: artifact.title, + mimeType: artifact.mimeType, + contentText: artifact.contentText, + metadata: artifact.metadata ?? null, + createdAt: nowIso(), + }; db.run( `insert into review_run_artifacts ( id, @@ -607,16 +1388,17 @@ export function createReviewService({ created_at ) values (?, ?, ?, ?, ?, ?, ?, ?)`, [ - randomUUID(), - runId, - artifact.artifactType, - artifact.title, - artifact.mimeType, - artifact.contentText, - artifact.metadata ? JSON.stringify(artifact.metadata) : null, - nowIso(), + record.id, + record.runId, + record.artifactType, + record.title, + record.mimeType, + record.contentText, + record.metadata ? JSON.stringify(record.metadata) : null, + record.createdAt, ], ); + return record; } function insertPublication(publication: ReviewPublication): void { @@ -663,6 +1445,7 @@ export function createReviewService({ run_id, title, severity, + finding_class, body, confidence, evidence_json, @@ -670,13 +1453,16 @@ export function createReviewService({ line, anchor_state, source_pass, - publication_state - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + publication_state, + originating_passes_json, + adjudication_json + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ finding.id, finding.runId, finding.title, finding.severity, + finding.findingClass ?? null, finding.body, finding.confidence, JSON.stringify(finding.evidence), @@ -685,6 +1471,8 @@ export function createReviewService({ finding.anchorState, finding.sourcePass, finding.publicationState, + JSON.stringify(finding.originatingPasses ?? []), + finding.adjudication ? JSON.stringify(finding.adjudication) : null, ], ); } @@ -727,12 +1515,7 @@ export function createReviewService({ dirtyOnly: partial?.dirtyOnly ?? target.mode === "working_tree", modelId: partial?.modelId?.trim() || defaultReviewModelId, reasoningEffort: partial?.reasoningEffort?.trim() || null, - budgets: { - maxFiles: clampNumber(Number(partial?.budgets?.maxFiles ?? DEFAULT_BUDGETS.maxFiles), 1, 500), - maxDiffChars: clampNumber(Number(partial?.budgets?.maxDiffChars ?? DEFAULT_BUDGETS.maxDiffChars), 4_000, 1_000_000), - maxPromptChars: clampNumber(Number(partial?.budgets?.maxPromptChars ?? DEFAULT_BUDGETS.maxPromptChars), 4_000, 1_000_000), - maxFindings: clampNumber(Number(partial?.budgets?.maxFindings ?? DEFAULT_BUDGETS.maxFindings), 1, 50), - }, + budgets: normalizeBudgetConfig(partial?.budgets), publishBehavior: target.mode === "pr" && partial?.publishBehavior === "auto_publish" ? "auto_publish" : "local_only", @@ -752,6 +1535,11 @@ export function createReviewService({ return null; } + const publishableFindings = [...args.findings] + .filter((finding) => finding.sourcePass === "adjudicated" && finding.adjudication?.publicationEligible) + .sort((left, right) => (right.adjudication?.score ?? 0) - (left.adjudication?.score ?? 0)) + .slice(0, args.config.budgets.maxPublishedFindings ?? args.config.budgets.maxFindings); + insertArtifact(args.runId, { artifactType: "publication_request", title: "Review publication request", @@ -760,20 +1548,26 @@ export function createReviewService({ destination: args.publicationTarget, targetLabel: args.targetLabel, summary: args.summary, - findingIds: args.findings.map((finding) => finding.id), + findingIds: publishableFindings.map((finding) => finding.id), changedFiles: args.changedFiles, }, null, 2), metadata: { publishBehavior: args.config.publishBehavior, + findingCount: publishableFindings.length, + skipped: publishableFindings.length === 0, }, }); + if (publishableFindings.length === 0) { + return null; + } + const publication = await prService.publishReviewPublication({ runId: args.runId, destination: args.publicationTarget, targetLabel: args.targetLabel, summary: args.summary, - findings: args.findings, + findings: publishableFindings, changedFiles: args.changedFiles, }); insertPublication(publication); @@ -793,7 +1587,7 @@ export function createReviewService({ ...publication.inlineComments.map((comment) => comment.findingId), ...publication.summaryFindingIds, ]); - for (const finding of args.findings) { + for (const finding of publishableFindings) { if (!publishedFindingIds.has(finding.id)) continue; updateFindingPublicationState(args.runId, finding.id, "published"); } @@ -802,6 +1596,99 @@ export function createReviewService({ return publication; } + async function executePass(args: { + runId: string; + run: ReviewRun; + sessionId: string; + sessionTitle: string; + descriptorId: string; + pass: PassDefinition; + diffText: string; + changedFiles: MaterializedChangedFile[]; + changedFilesByPath: Map }>; + context: ReviewContextPacket; + contextArtifactIds: ReviewContextArtifactIds; + }): Promise { + const prompt = buildPassPrompt({ + run: args.run, + pass: args.pass, + diffText: args.diffText, + changedFiles: args.changedFiles, + context: args.context, + contextArtifactIds: args.contextArtifactIds, + }); + const promptArtifact = insertArtifact(args.runId, { + artifactType: "pass_prompt", + title: `${args.pass.label} prompt`, + mimeType: "text/plain", + contentText: prompt, + metadata: { + passKey: args.pass.key, + modelId: args.descriptorId, + reasoningEffort: args.run.config.reasoningEffort, + matchedRuleIds: args.context.rules.metadata.matchedRuleIds ?? [], + }, + }); + const result = await agentChatService.runSessionTurn({ + sessionId: args.sessionId, + text: prompt, + displayText: `${args.sessionTitle} · ${args.pass.label}`, + reasoningEffort: args.run.config.reasoningEffort, + timeoutMs: 15 * 60 * 1000, + }); + const outputArtifact = insertArtifact(args.runId, { + artifactType: "pass_output", + title: `${args.pass.label} output`, + mimeType: "application/json", + contentText: result.outputText, + metadata: { + passKey: args.pass.key, + provider: result.provider, + model: result.model, + modelId: result.modelId ?? args.descriptorId, + }, + }); + const parsed = extractJsonObject(result.outputText); + const normalized = normalizeParsedFindings({ + runId: args.runId, + passKey: args.pass.key, + parsed, + changedFilesByPath: args.changedFilesByPath, + }); + const candidates = [...normalized.findings] + .sort((left, right) => right.score - left.score) + .slice(0, args.run.config.budgets.maxFindingsPerPass ?? args.run.config.budgets.maxFindings); + const findingsArtifact = insertArtifact(args.runId, { + artifactType: "pass_findings", + title: `${args.pass.label} findings`, + mimeType: "application/json", + contentText: JSON.stringify({ + passKey: args.pass.key, + summary: normalized.summary, + totalParsedCount: normalized.findings.length, + keptCount: candidates.length, + budgetTrimmedCount: Math.max(0, normalized.findings.length - candidates.length), + candidates, + }, null, 2), + metadata: { + passKey: args.pass.key, + summary: normalized.summary, + totalParsedCount: normalized.findings.length, + keptCount: candidates.length, + budgetTrimmedCount: Math.max(0, normalized.findings.length - candidates.length), + }, + }); + return { + pass: args.pass, + summary: normalized.summary, + candidates, + promptArtifactId: promptArtifact.id, + outputArtifactId: outputArtifact.id, + findingsArtifactId: findingsArtifact.id, + budgetTrimmedCount: Math.max(0, normalized.findings.length - candidates.length), + }; + } + async function executeRun(runId: string): Promise { if (disposed || activeRuns.has(runId)) return; activeRuns.add(runId); @@ -876,44 +1763,71 @@ export function createReviewService({ chat_session_id: session.id, updated_at: nowIso(), }); - - const prompt = buildPrompt({ - run: { - ...run, - targetLabel: materialized.targetLabel, - compareTarget: materialized.compareTarget, + const effectiveRun: ReviewRun = { + ...run, + targetLabel: materialized.targetLabel, + compareTarget: materialized.compareTarget, + }; + const diffText = truncateText(materialized.fullPatchText, effectiveRun.config.budgets.maxDiffChars); + const changedFiles = materialized.changedFiles.slice(0, effectiveRun.config.budgets.maxFiles); + const reviewContext = await contextBuilder.buildContext({ + run: effectiveRun, + materialized: { + ...materialized, + changedFiles, }, - diffText: truncateText(materialized.fullPatchText, run.config.budgets.maxDiffChars), - changedFiles: materialized.changedFiles.slice(0, run.config.budgets.maxFiles), }); - insertArtifact(runId, { - artifactType: "prompt", - title: "Review prompt", - mimeType: "text/plain", - contentText: prompt, - metadata: { - modelId: descriptor.id, - reasoningEffort: run.config.reasoningEffort, - }, + const provenanceArtifact = insertArtifact(runId, { + artifactType: "provenance_brief", + title: "Provenance brief", + mimeType: "application/json", + contentText: JSON.stringify(reviewContext.provenance.payload, null, 2), + metadata: reviewContext.provenance.metadata, }); - - const result = await agentChatService.runSessionTurn({ - sessionId: session.id, - text: prompt, - displayText: sessionTitle, - reasoningEffort: run.config.reasoningEffort, - timeoutMs: 15 * 60 * 1000, + const rulesArtifact = insertArtifact(runId, { + artifactType: "rule_overlays", + title: "Rule overlays", + mimeType: "application/json", + contentText: JSON.stringify(reviewContext.rules.payload, null, 2), + metadata: reviewContext.rules.metadata, }); - if (disposed) return; + const validationArtifact = insertArtifact(runId, { + artifactType: "validation_signals", + title: "Validation signals", + mimeType: "application/json", + contentText: JSON.stringify(reviewContext.validation.payload, null, 2), + metadata: reviewContext.validation.metadata, + }); + const contextArtifactIds: ReviewContextArtifactIds = { + provenanceArtifactId: provenanceArtifact.id, + rulesArtifactId: rulesArtifact.id, + validationArtifactId: validationArtifact.id, + }; insertArtifact(runId, { - artifactType: "review_output", - title: "Reviewer output", + artifactType: "prompt", + title: "Review harness plan", mimeType: "application/json", - contentText: result.outputText, + contentText: JSON.stringify({ + targetLabel: materialized.targetLabel, + passKeys: REVIEW_PASSES.map((pass) => pass.key), + budgets: effectiveRun.config.budgets, + changedFiles: changedFiles.map((entry) => entry.filePath), + context: { + provenanceSummary: reviewContext.provenance.summary, + rulesSummary: reviewContext.rules.summary, + validationSummary: reviewContext.validation.summary, + matchedRuleIds: reviewContext.rules.metadata.matchedRuleIds ?? [], + contextArtifactIds, + }, + }, null, 2), metadata: { - provider: result.provider, - model: result.model, - modelId: result.modelId ?? descriptor.id, + modelId: descriptor.id, + reasoningEffort: effectiveRun.config.reasoningEffort, + passCount: REVIEW_PASSES.length, + matchedRuleCount: reviewContext.rules.metadata.matchedRuleCount ?? 0, + matchedRuleIds: reviewContext.rules.metadata.matchedRuleIds ?? [], + provenanceCount: reviewContext.provenance.metadata.provenanceCount ?? 0, + validationSignalCount: reviewContext.validation.metadata.signalCount ?? 0, }, }); @@ -925,13 +1839,84 @@ export function createReviewService({ diffPositionsByLine: entry.diffPositionsByLine, }, ])); - const parsed = extractJsonObject(result.outputText); - const normalized = normalizeParsedFindings({ + const passResults: PassExecutionResult[] = []; + for (const pass of REVIEW_PASSES) { + if (disposed) return; + const passResult = await executePass({ + runId, + run: effectiveRun, + sessionId: session.id, + sessionTitle, + descriptorId: descriptor.id, + pass, + diffText, + changedFiles, + changedFilesByPath, + context: reviewContext, + contextArtifactIds, + }); + passResults.push(passResult); + } + + if (disposed) return; + const adjudication = adjudicatePassFindings({ runId, - parsed, - changedFilesByPath, + passResults, + budgets: effectiveRun.config.budgets, + context: reviewContext, + artifactIds: contextArtifactIds, }); - const findings = normalized.findings.slice(0, run.config.budgets.maxFindings); + insertArtifact(runId, { + artifactType: "adjudication_result", + title: "Review adjudication", + mimeType: "application/json", + contentText: JSON.stringify({ + summary: adjudication.summary, + totalCandidateCount: adjudication.totalCandidateCount, + publicationEligibleCount: adjudication.publicationEligibleCount, + rejected: adjudication.rejected, + passSummaries: passResults.map((result) => ({ + passKey: result.pass.key, + summary: result.summary, + keptCount: result.candidates.length, + budgetTrimmedCount: result.budgetTrimmedCount, + findingsArtifactId: result.findingsArtifactId, + })), + }, null, 2), + metadata: { + acceptedCount: adjudication.findings.length, + rejectedCount: adjudication.rejected.length, + publicationEligibleCount: adjudication.publicationEligibleCount, + }, + }); + insertArtifact(runId, { + artifactType: "merged_findings", + title: "Merged review findings", + mimeType: "application/json", + contentText: JSON.stringify({ + summary: adjudication.summary, + findings: adjudication.findings, + }, null, 2), + metadata: { + findingCount: adjudication.findings.length, + publicationEligibleCount: adjudication.publicationEligibleCount, + }, + }); + insertArtifact(runId, { + artifactType: "review_output", + title: "Adjudicated review output", + mimeType: "application/json", + contentText: JSON.stringify({ + summary: adjudication.summary, + findings: adjudication.findings, + }, null, 2), + metadata: { + stage: "adjudicated", + findingCount: adjudication.findings.length, + }, + }); + + const findings = adjudication.findings; for (const finding of findings) { if (disposed) return; insertFinding(finding); @@ -940,8 +1925,8 @@ export function createReviewService({ await publishRun({ runId, targetLabel: materialized.targetLabel, - summary: normalized.summary, - config: run.config, + summary: adjudication.summary, + config: effectiveRun.config, findings, publicationTarget: materialized.publicationTarget, changedFiles: materialized.changedFiles.map((entry) => ({ @@ -954,7 +1939,7 @@ export function createReviewService({ const endedAt = nowIso(); updateRun(runId, { status: "completed", - summary: normalized.summary ?? (findings.length > 0 ? `Review completed with ${findings.length} finding(s).` : "No actionable findings."), + summary: adjudication.summary, error_message: null, finding_count: findings.length, severity_summary_json: serializeSeveritySummary(severitySummary), diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index d0caa87fb..2ea8c75a4 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -2993,6 +2993,7 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { run_id text not null, title text not null, severity text not null, + finding_class text, body text not null, confidence real not null default 0.5, evidence_json text, @@ -3001,6 +3002,8 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { anchor_state text not null, source_pass text not null, publication_state text not null, + originating_passes_json text, + adjudication_json text, foreign key(run_id) references review_runs(id) on delete cascade ) `); @@ -3042,6 +3045,9 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { ) `); db.run("create index if not exists idx_review_run_artifacts_run on review_run_artifacts(run_id, created_at)"); + try { db.run("alter table review_findings add column finding_class text"); } catch {} + try { db.run("alter table review_findings add column originating_passes_json text"); } catch {} + try { db.run("alter table review_findings add column adjudication_json text"); } catch {} // PR convergence loop: issue inventory tracking db.run(` diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index b0e9bf578..0212a512f 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -489,12 +489,17 @@ describe("PrDetailPane issue resolver CTA", () => { onNavigate, }); + expect(screen.getByRole("button", { name: /select model/i })).toBeTruthy(); await user.click(screen.getByRole("button", { name: /run ade review/i })); await waitFor(() => { expect(startReviewRun).toHaveBeenCalledWith({ target: { mode: "pr", laneId: "lane-1", prId: "pr-80" }, - config: { publishBehavior: "auto_publish" }, + config: { + publishBehavior: "auto_publish", + modelId: "openai/gpt-5.4-codex", + reasoningEffort: "high", + }, }); expect(onNavigate).toHaveBeenCalledWith("/review?runId=review-run-1"); }); diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index ee9039d6c..a06633f41 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -34,6 +34,7 @@ import { formatTimeAgo, formatTimestampFull } from "../shared/prFormatters"; import { describePrTargetDiff } from "../shared/laneBranchTargets"; import { findMatchingRebaseNeed, rebaseNeedItemKey } from "../shared/rebaseNeedUtils"; import { usePrs } from "../state/PrsContext"; +import { ReviewLaunchModelControls } from "../../shared/ReviewLaunchModelControls"; // ---- Sub-tab type ---- type DetailTab = "overview" | "convergence" | "files" | "checks" | "activity"; @@ -643,6 +644,8 @@ export function PrDetailPane({ const [actionBusy, setActionBusy] = React.useState(false); const [actionError, setActionError] = React.useState(null); const [actionResult, setActionResult] = React.useState(null); + const [reviewModelId, setReviewModelId] = React.useState(resolverModel); + const [reviewReasoningEffort, setReviewReasoningEffort] = React.useState(resolverReasoningLevel); const [commentDraft, setCommentDraft] = React.useState(""); const [editingTitle, setEditingTitle] = React.useState(false); const [titleDraft, setTitleDraft] = React.useState(""); @@ -685,6 +688,8 @@ export function PrDetailPane({ React.useEffect(() => { setActionError(null); setActionResult(null); + setReviewModelId(resolverModel); + setReviewReasoningEffort(resolverReasoningLevel); setIssueResolverError(null); setIssueResolverBusy(false); setIssueResolverCopyBusy(false); @@ -733,7 +738,7 @@ export function PrDetailPane({ inventoryLoadSeqRef.current += 1; convergenceLoadSeqRef.current += 1; }; - }, [applyConvergenceRuntime, loadConvergenceState, loadDetail, pr.id]); + }, [applyConvergenceRuntime, loadConvergenceState, loadDetail, pr.id, resolverModel, resolverReasoningLevel]); // Poll actionRuns + activity + reviewThreads every 60s so CI data stays fresh. // PrsContext polls checks/status/reviews/comments, but action runs are only loaded @@ -866,7 +871,11 @@ export function PrDetailPane({ } const result = await reviewBridge.startRun({ target: { mode: "pr", laneId: pr.laneId, prId: pr.id }, - config: { publishBehavior: "auto_publish" }, + config: { + publishBehavior: "auto_publish", + modelId: reviewModelId.trim(), + reasoningEffort: reviewReasoningEffort.trim() || null, + }, }); const nextRunId = typeof result === "string" ? result @@ -2033,6 +2042,8 @@ export function PrDetailPane({ pr={pr} detail={detail} status={status} checks={checks} actionRuns={actionRuns} reviews={reviews} comments={comments} detailBusy={detailBusy} aiSummary={aiSummary} aiSummaryBusy={aiSummaryBusy} adeReviewBusy={adeReviewBusy} actionBusy={actionBusy} mergeMethod={mergeMethod} + reviewModelId={reviewModelId} + reviewReasoningEffort={reviewReasoningEffort} commentDraft={commentDraft} setCommentDraft={setCommentDraft} editingBody={editingBody} setEditingBody={setEditingBody} bodyDraft={bodyDraft} setBodyDraft={setBodyDraft} @@ -2050,6 +2061,8 @@ export function PrDetailPane({ onClose={handleClosePr} onReopen={handleReopenPr} onAiSummary={handleAiSummary} onRunAdeReview={handleRunAdeReview} + onReviewModelChange={setReviewModelId} + onReviewReasoningEffortChange={setReviewReasoningEffort} onNavigate={onNavigate} onOpenRebaseTab={onOpenRebaseTab} matchingRebaseItemId={matchingRebaseItemId} @@ -2379,6 +2392,8 @@ type OverviewTabProps = { adeReviewBusy: boolean; actionBusy: boolean; mergeMethod: MergeMethod; + reviewModelId: string; + reviewReasoningEffort: string; commentDraft: string; setCommentDraft: (v: string) => void; editingBody: boolean; @@ -2409,6 +2424,8 @@ type OverviewTabProps = { onReopen: () => void; onAiSummary: () => void; onRunAdeReview: () => void; + onReviewModelChange: (value: string) => void; + onReviewReasoningEffortChange: (value: string) => void; onNavigate: (path: string) => void; onOpenRebaseTab?: (laneId?: string) => void; matchingRebaseItemId: string | null; @@ -3011,6 +3028,16 @@ function OverviewTab(props: OverviewTabProps) { {adeReviewBusy ? "Launching..." : "Run ADE review"} +
+ Review model + +
+
+
+ {toTargetModeLabel(launchDraft.targetMode)} + {launchDraft.targetMode === "lane_diff" ? ( + + {launchDraft.compareKind === "lane" ? "Lane to lane" : `Against ${defaultBranchLabel}`} + + ) : null} +
+
{launchScope.title}
+
{launchScope.description}
+
+ {launchDraft.targetMode === "lane_diff" ? (
- Default compares this lane against its upstream / default branch. You can switch to another lane when you want a lane-to-lane review. + Default compares this lane against the primary / default branch. Switch to another lane when you want a lane-to-lane review instead.
@@ -698,113 +1107,144 @@ export function ReviewPage() { {launchDraft.targetMode === "commit_range" ? (
- Review only the commits between the base and head revision. The base commit is excluded; the head commit is included. + Review only part of this lane's history. Commit lists are ordered from earlier to later so you can pick the start and end of the range without typing raw SHAs.
- - + handleCommitSelection("base", sha)} + /> + handleCommitSelection("head", sha)} + />
{launchDraft.laneId && selectedLaneCommits.length < 2 ? (
- At least two recent commits are needed to auto-fill this range. Enter the base and head SHAs manually or choose a lane with more history. + At least two recent commits are needed to review a commit range. Choose a lane with more history.
) : null} + {selectedLaneCommits.length >= 2 && commitRangeValidationMessage ? ( +
{commitRangeValidationMessage}
+ ) : null}
) : null} -
- - -
+ {launchDraft.targetMode === "working_tree" ? ( +
+
+ Review the current staged, unstaged, and untracked changes in the selected lane. This mode compares the working tree against the lane's current HEAD commit. It does not compare against another lane. +
+
+ ) : null} -
- - - - +
+ Model and reasoning + updateDraft("modelId", value)} + onReasoningEffortChange={(value) => updateDraft("reasoningEffort", value)} + disabled={launching} + />
+
+ +
+
+
Advanced review budgets
+
+ These limits keep runs bounded. Most reviews can keep the defaults. +
+
+ advanced +
+
+
+ + + + + + +
+
+
- Reviews are saved locally. The transcript is attached as supporting context. + Every review run is saved locally. Use the list below as the run picker, then inspect the selected run's findings, context, and transcript in the right pane.
void refreshRuns()} disabled={loadingRuns}> @@ -814,9 +1254,12 @@ export function ReviewPage() { )} >
+
+ Pick a saved run here to inspect it on the right. +
{runs.length === 0 ? (
- No saved review runs yet in this workspace. + No review runs yet in this workspace. New runs will show up here and open on the right.
) : runs.map((run) => { const active = run.id === selectedRunId; @@ -847,7 +1290,7 @@ export function ReviewPage() { {run.severitySummary ? Object.entries(run.severitySummary).slice(0, 2).map(([severity, count]) => ( {severity}:{count} )) : null} - {run.target.mode} + {toTargetModeLabel(run.target.mode)}
); @@ -866,8 +1309,8 @@ export function ReviewPage() {
{selectedRun.status} - {selectedRun.target.mode} - {selectedRun.config.selectionMode} + {toTargetModeLabel(selectedRun.target.mode)} + {toSelectionModeLabel(selectedRun.config.selectionMode)} {selectedRun.config.modelId}
@@ -898,19 +1341,156 @@ export function ReviewPage() { - +
- + - - - - - - + + + + + + + +
+ {selectedContextArtifacts.length > 0 ? ( + +
+
+ {selectedContextArtifacts.map((artifact) => { + const artifactType = String(artifact.artifactType); + const countValue = + artifactType === "provenance_brief" + ? readArtifactMetaCount(artifact, ["provenanceCount", "missionCount", "workerDigestCount", "sessionDeltaCount", "priorReviewCount"]) + : artifactType === "rule_overlays" + ? readArtifactMetaCount(artifact, ["ruleCount", "matchedRuleCount", "overlayCount", "pathCount"]) + : readArtifactMetaCount(artifact, ["signalCount", "checkCount", "testRunCount", "issueCount"]); + const detailChips = + artifactType === "provenance_brief" + ? [ + readArtifactMetaCount(artifact, ["missionCount", "missionsCount"]) ? `missions ${readArtifactMetaCount(artifact, ["missionCount", "missionsCount"])}` : null, + readArtifactMetaCount(artifact, ["workerDigestCount", "workerCount"]) ? `workers ${readArtifactMetaCount(artifact, ["workerDigestCount", "workerCount"])}` : null, + readArtifactMetaCount(artifact, ["sessionDeltaCount", "sessionCount"]) ? `sessions ${readArtifactMetaCount(artifact, ["sessionDeltaCount", "sessionCount"])}` : null, + ].filter((value): value is string => Boolean(value)) + : artifactType === "rule_overlays" + ? [ + readArtifactMetaCount(artifact, ["ruleCount", "matchedRuleCount"]) ? `rules ${readArtifactMetaCount(artifact, ["ruleCount", "matchedRuleCount"])}` : null, + readArtifactMetaCount(artifact, ["pathCount"]) ? `paths ${readArtifactMetaCount(artifact, ["pathCount"])}` : null, + ].filter((value): value is string => Boolean(value)) + : [ + readArtifactMetaCount(artifact, ["signalCount"]) ? `signals ${readArtifactMetaCount(artifact, ["signalCount"])}` : null, + readArtifactMetaCount(artifact, ["checkCount", "testRunCount"]) ? `checks ${readArtifactMetaCount(artifact, ["checkCount", "testRunCount"])}` : null, + readArtifactMetaCount(artifact, ["issueCount"]) ? `issues ${readArtifactMetaCount(artifact, ["issueCount"])}` : null, + ].filter((value): value is string => Boolean(value)); + + return ( +
+
+ {toContextArtifactLabel(artifactType)} + {countValue !== null ? {countValue} items : null} + {detailChips.map((chip) => ( + + {chip} + + ))} +
+
{artifact.title}
+
+ {readArtifactMetaString(artifact, "summary") ?? "Compact review context captured for this run."} +
+
+ + +
+ {artifact.contentText ? ( +
+                            {artifact.contentText}
+                          
+ ) : null} + {artifact.metadata ? ( +
+                            {JSON.stringify(artifact.metadata, null, 2)}
+                          
+ ) : null} +
+ ); + })} +
+
+
+ ) : null} + + {(selectedPassArtifacts.length > 0 || selectedAdjudicationArtifact || selectedMergedArtifact) ? ( + +
+ {selectedPassArtifacts.length > 0 ? ( +
+ {selectedPassArtifacts.map((artifact) => ( +
+
+ {toPassLabel(readArtifactMetaString(artifact, "passKey") ?? artifact.title)} + {readArtifactMetaNumber(artifact, "keptCount") ?? 0} kept + {(readArtifactMetaNumber(artifact, "budgetTrimmedCount") ?? 0) > 0 ? ( + trimmed {readArtifactMetaNumber(artifact, "budgetTrimmedCount")} + ) : null} +
+
+ {readArtifactMetaString(artifact, "summary") ?? "No summary recorded for this pass."} +
+
+ + +
+
+ ))} +
+ ) : null} + + {(selectedAdjudicationArtifact || selectedMergedArtifact) ? ( +
+ {selectedAdjudicationArtifact ? ( +
+
+ Adjudication + accepted {readArtifactMetaNumber(selectedAdjudicationArtifact, "acceptedCount") ?? 0} + rejected {readArtifactMetaNumber(selectedAdjudicationArtifact, "rejectedCount") ?? 0} +
+
+ Merged overlaps, filtered low-signal candidates, and applied the explicit run/publication budgets before findings became final. +
+
+ ) : null} + {selectedMergedArtifact ? ( +
+
+ Final result + findings {readArtifactMetaNumber(selectedMergedArtifact, "findingCount") ?? 0} + publishable {readArtifactMetaNumber(selectedMergedArtifact, "publicationEligibleCount") ?? 0} +
+
+ {selectedRun.summary ?? "No merged summary recorded."} +
+
+ ) : null} +
+ ) : null} +
+
+ ) : null} + {selectedDetail?.publications?.length ? (
@@ -949,12 +1529,18 @@ export function ReviewPage() {
{selectedDetail?.findings?.length ? selectedDetail.findings.map((finding, index) => { const evidence = normalizeEvidence(finding.evidence); + const findingClass = (finding as { findingClass?: string | null }).findingClass ?? null; return (
{finding.severity} + {findingClass ? ( + + {toFindingClassLabel(findingClass)} + + ) : null}
{finding.title}
{finding.body}
@@ -966,6 +1552,14 @@ export function ReviewPage() {
{finding.publicationState} + {finding.originatingPasses?.map((pass) => ( + {toPassLabel(pass)} + ))} + {finding.adjudication ? ( + + {finding.adjudication.publicationEligible ? "publication eligible" : "local only"} + + ) : null} {finding.filePath ? {finding.filePath}{finding.line ? `:${finding.line}` : ""} : null} {finding.filePath ? ( ) : null}
+ {finding.adjudication ? ( +
+ {finding.adjudication.rationale} +
+ ) : null} {evidence.length > 0 ? (
{evidence.map((entry, evidenceIndex) => ( @@ -1014,9 +1613,9 @@ export function ReviewPage() {
{artifact.title}
{artifact.mimeType}
- {artifact.contentText ?
{artifact.contentText}
: null} + {artifact.contentText ?
{artifact.contentText}
: null} {artifact.metadata ? ( -
+                    
                       {JSON.stringify(artifact.metadata, null, 2)}
                     
) : null} @@ -1070,13 +1669,13 @@ export function ReviewPage() { const paneConfigs: Record = { launch: { - title: "Launch and history", + title: "Launch and saved runs", icon: Sparkle, bodyClassName: "flex flex-col min-h-0", children: launchPane, }, detail: { - title: "Run detail", + title: "Selected run", icon: MagnifyingGlass, bodyClassName: "flex flex-col min-h-0", children: detailPane, @@ -1107,12 +1706,12 @@ export function ReviewPage() {
Review
-
Saved review runs, findings, publication records, and transcript history.
+
Launch a review on the left, then inspect the selected run on the right.
- {runs.length} saved + {runs.length} runs {activeRuns} active {totalFindings} findings {selectedLane ? selectedLane.name : "No lane selected"} diff --git a/apps/desktop/src/renderer/components/review/reviewTypes.ts b/apps/desktop/src/renderer/components/review/reviewTypes.ts index 115e2a39c..f16c89afb 100644 --- a/apps/desktop/src/renderer/components/review/reviewTypes.ts +++ b/apps/desktop/src/renderer/components/review/reviewTypes.ts @@ -7,10 +7,13 @@ export type { ReviewEvidence, ReviewEventPayload, ReviewFinding, + ReviewFindingAdjudication, + ReviewFindingClass, ReviewLaunchCommit, ReviewLaunchContext, ReviewLaunchLane, ReviewListRunsArgs, + ReviewPassKey, ReviewPublication, ReviewPublicationDestination, ReviewPublicationInlineComment, diff --git a/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx new file mode 100644 index 000000000..b98297f50 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import type { AiSettingsStatus } from "../../../shared/types"; +import { deriveConfiguredModelIds } from "../../lib/modelOptions"; +import { ProviderModelSelector } from "./ProviderModelSelector"; + +type ReviewLaunchModelControlsProps = { + modelId: string; + reasoningEffort: string; + onModelChange: (modelId: string) => void; + onReasoningEffortChange: (value: string) => void; + disabled?: boolean; + className?: string; +}; + +export function ReviewLaunchModelControls({ + modelId, + reasoningEffort, + onModelChange, + onReasoningEffortChange, + disabled = false, + className, +}: ReviewLaunchModelControlsProps) { + const navigate = useNavigate(); + const [availableModelIds, setAvailableModelIds] = React.useState([]); + + React.useEffect(() => { + let cancelled = false; + const aiBridge = (window as Window & { + ade?: { + ai?: { + getStatus?: () => Promise; + }; + }; + }).ade?.ai; + const getStatus = aiBridge?.getStatus; + if (typeof getStatus !== "function") { + setAvailableModelIds([]); + return; + } + void getStatus() + .then((status) => { + if (cancelled) return; + setAvailableModelIds(deriveConfiguredModelIds(status)); + }) + .catch(() => { + if (!cancelled) setAvailableModelIds([]); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( + onReasoningEffortChange(next ?? "")} + onOpenAiSettings={() => navigate("/settings?tab=ai#ai-providers")} + className={className} + /> + ); +} diff --git a/apps/desktop/src/shared/types/review.ts b/apps/desktop/src/shared/types/review.ts index a1e7661e5..90874d023 100644 --- a/apps/desktop/src/shared/types/review.ts +++ b/apps/desktop/src/shared/types/review.ts @@ -10,11 +10,21 @@ export type ReviewSeverity = "critical" | "high" | "medium" | "low" | "info"; export type ReviewAnchorState = "anchored" | "file_only" | "missing"; export type ReviewPublicationState = "local_only" | "published"; export type ReviewSourcePass = "single_pass" | "adjudicated"; +export type ReviewPassKey = "diff-risk" | "cross-file-impact" | "checks-and-tests"; +export type ReviewFindingClass = "intent_drift" | "incomplete_rollout" | "late_stage_regression"; export type ReviewSelectionMode = "full_diff" | "selected_commits" | "dirty_only"; export type ReviewPublishBehavior = "local_only" | "auto_publish"; export type ReviewPublicationStatus = "published" | "failed"; export type ReviewArtifactType = | "prompt" + | "pass_prompt" + | "pass_output" + | "pass_findings" + | "adjudication_result" + | "merged_findings" + | "provenance_brief" + | "rule_overlays" + | "validation_signals" | "diff_bundle" | "review_output" | "untracked_snapshot" @@ -78,6 +88,8 @@ export type ReviewRunBudgetConfig = { maxDiffChars: number; maxPromptChars: number; maxFindings: number; + maxFindingsPerPass?: number; + maxPublishedFindings?: number; }; export type ReviewRunConfig = { @@ -120,11 +132,20 @@ export type ReviewEvidence = { artifactId: string | null; }; +export type ReviewFindingAdjudication = { + score: number; + candidateCount: number; + mergedFindingIds: string[]; + rationale: string; + publicationEligible: boolean; +}; + export type ReviewFinding = { id: string; runId: string; title: string; severity: ReviewSeverity; + findingClass?: ReviewFindingClass | null; body: string; confidence: number; evidence: ReviewEvidence[]; @@ -133,6 +154,8 @@ export type ReviewFinding = { anchorState: ReviewAnchorState; sourcePass: ReviewSourcePass; publicationState: ReviewPublicationState; + originatingPasses?: ReviewPassKey[]; + adjudication?: ReviewFindingAdjudication | null; }; export type ReviewSeveritySummary = { diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index c4c7ff96b..9d40465f8 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -2356,6 +2356,7 @@ create table if not exists review_findings ( run_id text not null, title text not null, severity text not null, + finding_class text, body text not null, confidence real not null default 0.5, evidence_json text, @@ -2364,6 +2365,8 @@ create table if not exists review_findings ( anchor_state text not null, source_pass text not null, publication_state text not null, + originating_passes_json text, + adjudication_json text, foreign key(run_id) references review_runs(id) on delete cascade ); @@ -2405,6 +2408,12 @@ create table if not exists review_run_artifacts ( create index if not exists idx_review_run_artifacts_run on review_run_artifacts(run_id, created_at); +alter table review_findings add column finding_class text; + +alter table review_findings add column originating_passes_json text; + +alter table review_findings add column adjudication_json text; + create table if not exists pr_issue_inventory ( id text primary key, pr_id text not null, From e3f56105776e783de36eacf4076c46e840b4996f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:35:02 -0400 Subject: [PATCH 04/16] Allow external cwd for managed processes --- .../services/lanes/laneLaunchContext.test.ts | 23 ++++++ .../main/services/lanes/laneLaunchContext.ts | 11 +++ .../services/processes/processService.test.ts | 60 ++++++++++++++++ .../main/services/processes/processService.ts | 30 +++++--- .../src/main/services/pty/ptyService.test.ts | 59 +++++++++++++++ .../src/main/services/pty/ptyService.ts | 6 +- .../PrDetailPane.issueResolver.test.tsx | 53 -------------- .../components/prs/detail/PrDetailPane.tsx | 72 +------------------ .../components/run/AddCommandDialog.tsx | 2 +- apps/desktop/src/shared/types/sessions.ts | 4 ++ 10 files changed, 187 insertions(+), 133 deletions(-) diff --git a/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts b/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts index 9f631de0b..ecd5d16a5 100644 --- a/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts +++ b/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts @@ -156,6 +156,29 @@ describe("resolveLaneLaunchContext", () => { }); }); + describe("happy path: explicit absolute cwd outside worktree", () => { + it("allows an external absolute cwd when the caller opts in", () => { + mocks.statSync.mockReturnValue({ isDirectory: () => true }); + mocks.realpathSync + .mockReturnValueOnce("/real/lane/root") + .mockReturnValueOnce("/real/project/root"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "/real/project/root", + allowExternalCwd: true, + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/project/root", + }); + expect(mocks.resolvePathWithinRoot).not.toHaveBeenCalled(); + }); + }); + describe("error: lane has no worktree configured", () => { it("throws when worktreePath is empty string", () => { expect(() => diff --git a/apps/desktop/src/main/services/lanes/laneLaunchContext.ts b/apps/desktop/src/main/services/lanes/laneLaunchContext.ts index 406a3a37b..7ff5d31c4 100644 --- a/apps/desktop/src/main/services/lanes/laneLaunchContext.ts +++ b/apps/desktop/src/main/services/lanes/laneLaunchContext.ts @@ -29,6 +29,7 @@ export function resolveLaneLaunchContext(args: { laneService: ReturnType; laneId: string; requestedCwd?: string | null; + allowExternalCwd?: boolean; purpose: string; }): LaneLaunchContext { const laneId = String(args.laneId ?? "").trim(); @@ -51,6 +52,16 @@ export function resolveLaneLaunchContext(args: { }; } + if (args.allowExternalCwd === true && path.isAbsolute(requestedCwd)) { + return { + laneWorktreePath: laneRoot, + cwd: ensureDirectoryExists( + path.resolve(requestedCwd), + `Requested cwd '${requestedCwd}' is not an existing directory.`, + ), + }; + } + const requestedTarget = path.isAbsolute(requestedCwd) ? requestedCwd : path.resolve(laneRoot, requestedCwd); diff --git a/apps/desktop/src/main/services/processes/processService.test.ts b/apps/desktop/src/main/services/processes/processService.test.ts index 57647a02f..c1135e329 100644 --- a/apps/desktop/src/main/services/processes/processService.test.ts +++ b/apps/desktop/src/main/services/processes/processService.test.ts @@ -204,6 +204,7 @@ describe("processService PTY-backed run commands", () => { try { await service.start({ laneId: "lane-env", processId: "print-env" }); expect(ptyService.create).toHaveBeenCalledWith(expect.objectContaining({ + allowNewSessionId: true, env: expect.objectContaining({ PORT: "3001", PORT_RANGE_START: "3001", @@ -219,6 +220,65 @@ describe("processService PTY-backed run commands", () => { } }); + it("allows a managed process to use an explicit absolute cwd outside the lane worktree", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-absolute-cwd-")); + const laneRoot = path.join(projectRoot, ".ade", "worktrees", "lane-absolute"); + fs.mkdirSync(laneRoot, { recursive: true }); + const dbPath = path.join(projectRoot, "kv.sqlite"); + const projectId = "proj-absolute-cwd"; + const logger = createLogger(); + const db = await openKvDb(dbPath, createLogger()); + const now = "2026-03-24T12:00:00.000Z"; + const { ptyService, sessionService } = createPtyHarness(projectRoot); + + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, projectRoot, "test", "main", now, now], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-absolute", projectId, "Lane Absolute", null, "worktree", "main", "feature/absolute", laneRoot, null, 0, null, null, null, null, "active", now, null], + ); + + const config = makeMinimalConfig([ + { id: "absolute-proc", command: ["scripts/dogfood.sh", "code-review"], cwd: projectRoot }, + ]); + + const service = createProcessService({ + db, + projectId, + logger, + laneService: { + getLaneWorktreePath: () => laneRoot, + list: async () => [makeLaneSummary(laneRoot, "lane-absolute")], + } as any, + projectConfigService: { + get: () => config, + getEffective: () => config.effective, + getExecutableConfig: () => config.effective, + } as any, + sessionService, + ptyService, + broadcastEvent: () => {}, + }); + + try { + const resolvedProjectRoot = fs.realpathSync(projectRoot); + await service.start({ laneId: "lane-absolute", processId: "absolute-proc" }); + expect(ptyService.create).toHaveBeenCalledWith(expect.objectContaining({ + allowExternalCwd: true, + cwd: resolvedProjectRoot, + })); + } finally { + service.disposeAll(); + db.close(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("includes envPath and envShell in the process.start log entry", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-startlog-")); const dbPath = path.join(tmpDir, "kv.sqlite"); diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index 27759d898..675f2122f 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -574,17 +574,29 @@ export function createProcessService({ } const laneRoot = laneService.getLaneWorktreePath(laneId); - const configuredCwd = opts.overlay?.cwd?.trim() ? opts.overlay.cwd : definition.cwd; - const cwdCandidate = path.isAbsolute(configuredCwd) ? configuredCwd : path.join(laneRoot, configuredCwd); + const configuredCwd = opts.overlay?.cwd?.trim() ? opts.overlay.cwd.trim() : definition.cwd.trim(); + const allowExternalCwd = path.isAbsolute(configuredCwd); let cwd: string; - try { - cwd = resolvePathWithinRoot(laneRoot, cwdCandidate); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("Path does not exist")) { + if (allowExternalCwd) { + try { + const resolved = path.resolve(configuredCwd); + const stat = fs.statSync(resolved); + if (!stat.isDirectory()) throw new Error("Path is not a directory"); + cwd = fs.realpathSync(resolved); + } catch { throw new Error(`Process '${definition.id}' cwd does not exist: ${configuredCwd}`); } - throw new Error(`Process '${definition.id}' cwd must stay within the lane workspace`); + } else { + const cwdCandidate = path.join(laneRoot, configuredCwd); + try { + cwd = resolvePathWithinRoot(laneRoot, cwdCandidate); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Path does not exist")) { + throw new Error(`Process '${definition.id}' cwd does not exist: ${configuredCwd}`); + } + throw new Error(`Process '${definition.id}' cwd must stay within the lane workspace`); + } } const laneRuntimeEnv = (await getLaneRuntimeEnv?.(laneId)) ?? {}; @@ -620,6 +632,8 @@ export function createProcessService({ try { const result = await ptyService.create({ sessionId, + allowNewSessionId: true, + allowExternalCwd, laneId, cwd, cols: 120, diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 0cb956f6b..2f846a8f9 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -442,6 +442,25 @@ describe("ptyService", () => { expect(loadPty).not.toHaveBeenCalled(); }); + it("allows an explicit absolute cwd outside the selected lane when opted in", async () => { + mocks.existsSyncResults.set("/tmp/outside", true); + const { service, loadPty } = createHarness(); + await service.create({ + laneId: "lane-1", + cwd: "/tmp/outside", + allowExternalCwd: true, + title: "External cwd terminal", + cols: 80, + rows: 24, + }); + const spawnCall = loadPty.mock.results[0].value.spawn; + expect(spawnCall).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ cwd: "/tmp/outside" }), + ); + }); + it("rejects a cwd whose realpath hops outside the lane worktree", async () => { const childPath = "/tmp/test-worktree/hop-child"; mocks.existsSyncResults.set(childPath, true); @@ -613,6 +632,46 @@ describe("ptyService", () => { expect(sessionService.create).toHaveBeenCalledTimes(createCallsBeforeResume); }); + it("preserves the strict resume path when a requested session id does not exist", async () => { + const { service } = createHarness(); + + await expect(service.create({ + sessionId: "session-missing", + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + startupCommand: "codex --no-alt-screen resume thread-existing", + })).rejects.toThrow(/was not found/i); + }); + + it("creates a new tracked session when the caller explicitly pre-assigns a fresh session id", async () => { + const { service, sessionService } = createHarness(); + + const result = await service.create({ + sessionId: "session-process-1", + allowNewSessionId: true, + laneId: "lane-1", + title: "Run process", + cols: 80, + rows: 24, + toolType: "run-shell", + command: "npm", + args: ["run", "dev"], + }); + + expect(result.sessionId).toBe("session-process-1"); + expect(sessionService.create).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-process-1", + title: "Run process", + toolType: "run-shell", + }), + ); + expect(sessionService.reattach).not.toHaveBeenCalled(); + }); + it("rejects reattaching a session into the wrong lane", async () => { const { service, sessionService } = createHarness(); sessionService.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index d6080f1e3..807f65923 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -971,16 +971,18 @@ export function createPtyService({ laneService, laneId, requestedCwd: args.cwd, + allowExternalCwd: args.allowExternalCwd === true, purpose: "start a terminal session", }); const { laneWorktreePath: worktreePath, cwd } = launchContext; const { cols, rows } = clampDims(args.cols, args.rows); const requestedSessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; + const allowNewSessionId = args.allowNewSessionId === true; const existingSession = requestedSessionId.length ? sessionService.get(requestedSessionId) : null; - if (requestedSessionId.length && !existingSession) { + if (requestedSessionId.length && !existingSession && !allowNewSessionId) { throw new Error(`Terminal session '${requestedSessionId}' was not found.`); } if (existingSession && existingSession.laneId !== laneId) { @@ -994,7 +996,7 @@ export function createPtyService({ } const ptyId = randomUUID(); - const sessionId = existingSession?.id ?? randomUUID(); + const sessionId = (existingSession?.id ?? requestedSessionId) || randomUUID(); const startedAt = new Date().toISOString(); const tracked = existingSession?.tracked ?? (args.tracked !== false); const toolTypeHint = normalizeToolType(args.toolType ?? existingSession?.toolType ?? null); diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index 0212a512f..21c18dfa3 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -9,7 +9,6 @@ import type { GitUpstreamSyncStatus, IssueInventoryItem, LaneSummary, - LandResult, PrActivityEvent, PrAiResolutionEventPayload, PrCheck, @@ -320,7 +319,6 @@ function renderPane(args: { laneArchived: false, error: null, }); - const startReviewRun = vi.fn().mockResolvedValue({ runId: "review-run-1" }); const onRefresh = vi.fn().mockResolvedValue(undefined); let currentConvergence: PrConvergenceState | null = args.convergenceState ?? null; const loadConvergenceState = vi.fn().mockImplementation(async () => currentConvergence); @@ -385,9 +383,6 @@ function renderPane(args: { land, openInGitHub: vi.fn().mockResolvedValue(undefined), }, - review: { - startRun: startReviewRun, - }, lanes: { list: vi.fn().mockResolvedValue(laneList), }, @@ -428,7 +423,6 @@ function renderPane(args: { saveConvergenceState, resetConvergenceState, writeClipboardText, - startReviewRun, land, onRefresh, ...render( @@ -480,53 +474,6 @@ describe("PrDetailPane issue resolver CTA", () => { }); }); - it("launches a PR-backed review run and navigates to the saved Review history entry", async () => { - const user = userEvent.setup(); - const onNavigate = vi.fn(); - const { startReviewRun } = renderPane({ - checks: [makeCheck()], - reviewThreads: [], - onNavigate, - }); - - expect(screen.getByRole("button", { name: /select model/i })).toBeTruthy(); - await user.click(screen.getByRole("button", { name: /run ade review/i })); - - await waitFor(() => { - expect(startReviewRun).toHaveBeenCalledWith({ - target: { mode: "pr", laneId: "lane-1", prId: "pr-80" }, - config: { - publishBehavior: "auto_publish", - modelId: "openai/gpt-5.4-codex", - reasoningEffort: "high", - }, - }); - expect(onNavigate).toHaveBeenCalledWith("/review?runId=review-run-1"); - }); - }); - - it("keeps the ADE review label stable while another action holds the shared busy state", async () => { - const user = userEvent.setup(); - const mergePromise = new Promise(() => {}); - const { land } = renderPane({ - checks: [makeCheck({ conclusion: "success" })], - reviewThreads: [], - statusOverrides: { - checksStatus: "passing", - reviewStatus: "approved", - isMergeable: true, - mergeConflicts: false, - }, - }); - land.mockReturnValueOnce(mergePromise); - - await user.click(screen.getByRole("button", { name: /merge pull request/i })); - - await waitFor(() => expect(land).toHaveBeenCalled()); - expect(screen.getByRole("button", { name: /run ade review/i }).hasAttribute("disabled")).toBe(true); - expect(screen.queryByText("Launching...")).toBeNull(); - }); - it("shows the resolve action in the checks tab when issues are actionable", async () => { const user = userEvent.setup(); renderPane({ diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index a06633f41..8481bf3fc 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -34,7 +34,6 @@ import { formatTimeAgo, formatTimestampFull } from "../shared/prFormatters"; import { describePrTargetDiff } from "../shared/laneBranchTargets"; import { findMatchingRebaseNeed, rebaseNeedItemKey } from "../shared/rebaseNeedUtils"; import { usePrs } from "../state/PrsContext"; -import { ReviewLaunchModelControls } from "../../shared/ReviewLaunchModelControls"; // ---- Sub-tab type ---- type DetailTab = "overview" | "convergence" | "files" | "checks" | "activity"; @@ -519,7 +518,6 @@ export function PrDetailPane({ }, [prsTimelineRailsEnabled, activeTab]); const [aiSummary, setAiSummary] = React.useState(null); const [aiSummaryBusy, setAiSummaryBusy] = React.useState(false); - const [adeReviewBusy, setAdeReviewBusy] = React.useState(false); const [showIssueResolverModal, setShowIssueResolverModal] = React.useState(false); const [issueResolverBusy, setIssueResolverBusy] = React.useState(false); const [issueResolverCopyBusy, setIssueResolverCopyBusy] = React.useState(false); @@ -644,8 +642,6 @@ export function PrDetailPane({ const [actionBusy, setActionBusy] = React.useState(false); const [actionError, setActionError] = React.useState(null); const [actionResult, setActionResult] = React.useState(null); - const [reviewModelId, setReviewModelId] = React.useState(resolverModel); - const [reviewReasoningEffort, setReviewReasoningEffort] = React.useState(resolverReasoningLevel); const [commentDraft, setCommentDraft] = React.useState(""); const [editingTitle, setEditingTitle] = React.useState(false); const [titleDraft, setTitleDraft] = React.useState(""); @@ -688,8 +684,6 @@ export function PrDetailPane({ React.useEffect(() => { setActionError(null); setActionResult(null); - setReviewModelId(resolverModel); - setReviewReasoningEffort(resolverReasoningLevel); setIssueResolverError(null); setIssueResolverBusy(false); setIssueResolverCopyBusy(false); @@ -738,7 +732,7 @@ export function PrDetailPane({ inventoryLoadSeqRef.current += 1; convergenceLoadSeqRef.current += 1; }; - }, [applyConvergenceRuntime, loadConvergenceState, loadDetail, pr.id, resolverModel, resolverReasoningLevel]); + }, [applyConvergenceRuntime, loadConvergenceState, loadDetail, pr.id]); // Poll actionRuns + activity + reviewThreads every 60s so CI data stays fresh. // PrsContext polls checks/status/reviews/comments, but action runs are only loaded @@ -861,41 +855,6 @@ export function PrDetailPane({ } finally { setAiSummaryBusy(false); } }; - const handleRunAdeReview = async () => { - setAdeReviewBusy(true); - try { - await runAction(async () => { - const reviewBridge = window.ade.review; - if (!reviewBridge) { - throw new Error("Review bridge is unavailable."); - } - const result = await reviewBridge.startRun({ - target: { mode: "pr", laneId: pr.laneId, prId: pr.id }, - config: { - publishBehavior: "auto_publish", - modelId: reviewModelId.trim(), - reasoningEffort: reviewReasoningEffort.trim() || null, - }, - }); - const nextRunId = typeof result === "string" - ? result - : result && typeof result === "object" - ? ("runId" in result && typeof result.runId === "string" - ? result.runId - : "id" in result && typeof result.id === "string" - ? result.id - : null) - : null; - if (!nextRunId) { - throw new Error("Review launch did not return a run id."); - } - onNavigate(`/review?runId=${encodeURIComponent(nextRunId)}`); - }); - } finally { - setAdeReviewBusy(false); - } - }; - const laneForPr = React.useMemo( () => lanes.find((lane) => lane.id === pr.laneId && !lane.archivedAt) ?? null, [lanes, pr.laneId], @@ -2040,10 +1999,8 @@ export function PrDetailPane({ {activeTab === "overview" && !prsTimelineRailsEnabled && ( void; editingBody: boolean; @@ -2423,9 +2374,6 @@ type OverviewTabProps = { onClose: () => void; onReopen: () => void; onAiSummary: () => void; - onRunAdeReview: () => void; - onReviewModelChange: (value: string) => void; - onReviewReasoningEffortChange: (value: string) => void; onNavigate: (path: string) => void; onOpenRebaseTab?: (laneId?: string) => void; matchingRebaseItemId: string | null; @@ -2435,7 +2383,7 @@ type OverviewTabProps = { }; function OverviewTab(props: OverviewTabProps) { - const { pr, detail, status, checks, actionRuns, reviews, comments, aiSummary, aiSummaryBusy, adeReviewBusy, actionBusy, mergeMethod, activity, lanes } = props; + const { pr, detail, status, checks, actionRuns, reviews, comments, aiSummary, aiSummaryBusy, actionBusy, mergeMethod, activity, lanes } = props; const [checksExpanded, setChecksExpanded] = React.useState(false); const [localMergeMethod, setLocalMergeMethod] = React.useState(mergeMethod); const [allowBlockedMerge, setAllowBlockedMerge] = React.useState(false); @@ -3024,20 +2972,6 @@ function OverviewTab(props: OverviewTabProps) {
{/* Quick actions */}
- -
- Review model - -
diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 64f8472c8..5dd66acee 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -87,6 +87,10 @@ export type TerminalSessionDetail = TerminalSessionSummary & { export type PtyCreateArgs = { sessionId?: string; + /** Allow callers to pre-assign a new session id instead of only resuming an existing tracked session. */ + allowNewSessionId?: boolean; + /** Allow an explicit absolute cwd outside the selected lane worktree. */ + allowExternalCwd?: boolean; laneId: string; cwd?: string; cols: number; From 1a74b7d7a3dc047e32972a9ca9b6d09f7fa072ef Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:41:01 -0400 Subject: [PATCH 05/16] Add /shipLane command and portable ship-lane playbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces an autonomous PR-to-merge driver that runs automate → finalize once, then polls CI and review comments on a self-paced 12-min cadence, fixing valid comments and failing tests in place. Prefers TeamCreate agent teams when available, falls back to parallel Agent calls otherwise. Opens the PR via `ade prs create` when possible so it shows up in ADE's PR tracking; falls back to `gh pr create` only after the agent has genuinely exhausted the ADE path via `--help`-driven discovery. Also narrows /automate to run only the new and affected tests (not the full suite), and makes /finalize's 8-shard parallel run explicit so shards don't get chained serially. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit 68ef71aaea5a131f0a53619ad2e6caac957a9a2f) --- .claude/commands/automate.md | 59 ++--- .claude/commands/shipLane.md | 113 ++++++++++ AGENTS.md | 4 + docs/playbooks/ship-lane.md | 418 +++++++++++++++++++++++++++++++++++ 4 files changed, 555 insertions(+), 39 deletions(-) create mode 100644 .claude/commands/shipLane.md create mode 100644 docs/playbooks/ship-lane.md diff --git a/.claude/commands/automate.md b/.claude/commands/automate.md index 9dc1d00d1..bf956b59f 100644 --- a/.claude/commands/automate.md +++ b/.claude/commands/automate.md @@ -35,7 +35,7 @@ Phase 3: Parallel test writing (agents) ├── desktop-tester-1..N (desktop app tests) └── mcp-tester (mcp server tests, if applicable) Phase 4: Test reality check (lead, after all testers done) -Phase 5: Full test run (lead) +Phase 5: Scoped test run (new + affected) (lead) Phase 6: CI verification (lead) Phase 7: Summary (lead) ``` @@ -247,60 +247,40 @@ If issues are found, fix them directly. --- -## Phase 5: Full Test Run +## Phase 5: Scoped Test Run -After reality check passes, run ALL created tests to confirm everything passes together. +Verify the tests **this command just wrote** pass. Do NOT run the full suite — that is `/finalize`'s job, and running it here doubles the wait with no new signal. -### 5a. Desktop tests (all new test files) +### 5a. New test files together -Run new test files together first: +Run every test file created in Phase 3 in a single invocation: ```bash cd apps/desktop && npx vitest run [space-separated list of all new test files] ``` -### 5b. Desktop tests (full sharded run — match CI) +All new tests must pass. If any fail, fix in place and re-run only the failing files. -Run the full suite the same way CI does — sharded 8-way. Run all 8 shards in parallel: +### 5b. Affected existing tests -```bash -cd apps/desktop && npx vitest run --shard=1/8 -cd apps/desktop && npx vitest run --shard=2/8 -cd apps/desktop && npx vitest run --shard=3/8 -cd apps/desktop && npx vitest run --shard=4/8 -cd apps/desktop && npx vitest run --shard=5/8 -cd apps/desktop && npx vitest run --shard=6/8 -cd apps/desktop && npx vitest run --shard=7/8 -cd apps/desktop && npx vitest run --shard=8/8 -``` - -Or run a specific workspace project: - -```bash -cd apps/desktop && npx vitest run --project unit-main -cd apps/desktop && npx vitest run --project unit-renderer -cd apps/desktop && npx vitest run --project unit-shared -``` - -### 5c. MCP server tests (if applicable) - -```bash -cd apps/mcp-server && npm test -``` - -### 5d. Run affected existing tests - -If code changes could break existing tests (e.g., changed a service function's signature), run those existing test files too: +If the branch's source changes could break existing tests (e.g., changed a service function's signature, renamed an exported type, altered shared contracts), run those existing test files — NOT the full suite: ```bash cd apps/desktop && npx vitest run [affected existing test files] ``` +Scope "affected" narrowly — direct importers of touched modules and their test siblings. Do not expand to "everything in the same feature folder." + **If tests fail:** - Check if it's a flaky test (retry once) - If a specific test fails consistently, fix it and re-run only that file - Do NOT re-run all tests — only the failed ones +### 5c. Not this command's job + +- **Full sharded suite run:** `/finalize` runs all 8 shards (and `test-ade-cli`) the same way CI does. Skip it here. +- **Build / typecheck / lint:** also deferred to `/finalize`. + --- ## Phase 6: CI Verification @@ -354,9 +334,10 @@ Read `.github/workflows/ci.yml`. Verify: ### Test Files Created: - [List each file with test count] -### Full Suite Run: -- Desktop: PASS (X tests) -- MCP Server: PASS (X tests) +### Scoped Test Run: +- New test files: PASS (X tests across Y files) +- Affected existing tests: PASS (X tests) or N/A +- NOTE: Full sharded suite run is deferred to `/finalize`. ### CI Coverage: - vitest.workspace.ts: All new tests matched by include patterns @@ -394,7 +375,7 @@ Mark as **"completed"** ONLY if ALL of the following are true: 1. ALL tests pass 2. All applicable test types were created per gap tracker -3. Full test run passed (Phase 5) +3. Scoped test run passed (Phase 5 — new + affected only; full suite deferred to /finalize) 4. CI covers all new test files (Phase 6) 5. No tests with silent null guards 6. No tests that mock the thing being tested diff --git a/.claude/commands/shipLane.md b/.claude/commands/shipLane.md new file mode 100644 index 000000000..46a660d84 --- /dev/null +++ b/.claude/commands/shipLane.md @@ -0,0 +1,113 @@ +--- +name: shipLane +description: 'Autonomously drive a lane through CI + review until merged (automate → finalize → poll/fix loop, self-paced wake-ups, max 5 iterations)' +--- + +# Ship Lane Command + +Drive the current lane from "work is ready" to "merged on main" without manual shepherding. + +**Usage:** +- `/shipLane` — auto-detects state (existing PR on current branch, or needs initial push) +- `/shipLane ` — operate on a specific PR (useful if you checked out a different branch mid-loop) + +**Arguments:** $ARGUMENTS + +--- + +## Source of truth + +**Follow the playbook at `docs/playbooks/ship-lane.md`.** All phase logic, state schema, commands, decision rules, and bot-ping rules live there. This wrapper only defines how Claude Code's team + wake-up primitives map onto the playbook. + +If you are re-invoked by a scheduled wake-up, read `.ade/shipLane/.json` first. If `status == running`, skip Phase 0 and go straight to Phase 1. + +--- + +## Execution mode: autonomous + +This command runs end-to-end without user interaction. Do NOT: +- Ask the user to confirm, choose, or approve anything. +- Pause between phases to request direction. +- Stop on non-fatal warnings — log them and continue. +- Ask whether to apply a fix — apply, verify, commit. + +The only user-visible output is the per-iteration summary and the final Phase 5 exit summary. + +--- + +## Concurrency: TeamCreate is MANDATORY + +Check the available tools. If `TeamCreate` is in scope, you MUST use it. Do not fall back to `Agent` calls when a team is available. + +### Team composition + +Create one team at the start of the invocation, reuse it across iterations. + +``` +ship-lane team +├── lead (this session's main agent) +├── poll-agent — runs every iteration, returns structured summary only +├── rebase-agent — spawned only when behindMain or conflicts exist +├── ci-fix-agent — spawned only when CI failures exist +├── review-fix-agent — spawned only when new valid comments exist +└── conflict-resolver — spawned by rebase-agent for >5-file conflicts +``` + +Initial team setup should also create: +- `automate-agent` — invoked once in Phase 0 (only when there is no existing PR) +- `finalize-agent` — invoked once in Phase 0 (only when there is no existing PR) + +### Delegation rules + +- The lead NEVER reads raw CI logs or full comment threads. It reads the poll-agent's structured summary (see playbook §1.3). +- Fix agents get minimum scope: failing test paths + error snippets, or comment bodies + file anchors. +- Fix agents edit files directly; they do not commit. +- The lead commits and pushes after verifying `git diff`. +- Rebase-agent runs alone when active — no concurrent file edits from other agents. + +### Fallback (TeamCreate not available) + +If `TeamCreate` is genuinely not in scope for this session: + +- Use parallel `Agent` tool calls for independent work (poll, ci-fix + review-fix in the same iteration). +- Use serial `Agent` calls for rebase (must run alone) and Phase 0 setup (automate then finalize). +- Same delegation rules apply — keep the lead's context clean by summarizing sub-agent output aggressively. + +--- + +## Scheduling wake-ups + +Use `ScheduleWakeup` at the end of each iteration (playbook §5.3) with the same command re-invocation as the `prompt`: + +``` +ScheduleWakeup({ + delaySeconds: <270 | 720 | 1800 per playbook>, + reason: "shipLane iter : ", + prompt: "/shipLane $ARGUMENTS" +}) +``` + +Pass `$ARGUMENTS` through so a PR-number argument is preserved across wake-ups. + +Do NOT schedule a wake if `status` is `done-clean`, `done-max`, or `blocked` — print the summary and stop. + +--- + +## Phase 0 safety rails (Claude Code specific) + +Before running `automate-agent` and `finalize-agent` in Phase 0: + +1. Confirm `$ARGUMENTS` is empty OR matches a PR number on the current branch. If the PR number is for a different branch, `git checkout` to that branch first. +2. Confirm `git status` is clean of foreign changes you don't expect. If the working tree has staged changes, commit them with `ship: checkpoint before automate/finalize` so the automate/finalize pipeline runs against a known baseline. +3. Confirm `origin` is a GitHub remote (`git remote get-url origin`) — `gh pr create` needs it. + +If any rail fails, exit `blocked` with a clear reason in the state file and stop. + +--- + +## References + +- `docs/playbooks/ship-lane.md` — full phase logic (source of truth). +- `.claude/commands/automate.md` — invoked by `automate-agent` in Phase 0. +- `.claude/commands/finalize.md` — invoked by `finalize-agent` in Phase 0. +- `.github/workflows/ci.yml` — CI job names and shard count (`8`) that the local fallback tests mirror. diff --git a/AGENTS.md b/AGENTS.md index 93a391099..47ee1e05b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,10 @@ - The ADE MCP server lives in `apps/mcp-server` and shares core services with the desktop app. - State is primarily stored under `.ade/` inside the active project, with runtime metadata in SQLite and machine-local files under `.ade/secrets`, `.ade/cache`, and `.ade/artifacts`. +## Playbooks + +- `docs/playbooks/ship-lane.md` — autonomous PR-to-merge driver (automate → finalize → poll-fix loop). Any agent CLI can follow it directly; Claude Code wraps it as `/shipLane`. + ## Working norms - Preserve existing desktop app patterns before introducing new abstractions. diff --git a/docs/playbooks/ship-lane.md b/docs/playbooks/ship-lane.md new file mode 100644 index 000000000..d422fc5c8 --- /dev/null +++ b/docs/playbooks/ship-lane.md @@ -0,0 +1,418 @@ +# Ship Lane — Autonomous PR-to-Merge Playbook + +This playbook drives a single lane (branch) from "work is ready" to "merged on `main`" without human shepherding. Any agent CLI (Claude Code, Codex, etc.) can follow it. Claude Code invokes it via `/shipLane`; other CLIs can invoke it directly by reading this file. + +## When to use + +Run this playbook once per lane, when the code on the branch is done (or nearly done) and you want the agent to handle: + +- First-time commit + push + PR creation (if no PR exists yet) +- Polling CI and review comments +- Fixing valid review comments and CI failures +- Rebasing when teammates merge into `main` ahead of you +- Repeating until the PR is merged, clean, or a human is required + +## Execution contract + +- **Autonomous.** Do not pause for user confirmation mid-loop. +- **Bounded.** Hard cap: 5 iterations. Exit earlier if clean or blocked. +- **Scoped checks.** Never run the full test suite between iterations — only failing test files and touched shards. +- **Idempotent resume.** All state lives in `.ade/shipLane/.json`. A re-invocation reads that file and picks up where it left off. + +## Concurrency model + +Pick the richest available and **use it fully**: + +1. **Agent teams** (e.g., Claude Code `TeamCreate` with `AGENT_TEAMS` enabled): **MANDATORY when available.** Spawn a team with a lead and role-specific sub-agents (poll, rebase, ci-fix, review-fix, conflict-resolver). Sub-agents return structured summaries so the lead's context never ingests full CI logs or comment threads. +2. **Parallel subagents** (e.g., Claude Code `Agent` tool, other CLIs with parallel task spawning): fall back here if teams aren't available. Spawn discrete subagents for poll, ci-fix, review-fix within a single iteration. Same context-keeping rules apply. +3. **Serial** (any CLI): absolute last resort. Run phases in order, in-process. Compact aggressively. + +**Rule:** the lead reads poll-agent summaries, not raw API output. Fix agents receive minimum scope (failing test paths + error snippets, or comment bodies + file anchors) and return patches or direct edits. The lead commits and pushes; fix agents do not. + +## State file + +Path: `.ade/shipLane/.json` (sanitize by replacing `/` with `__`). + +```json +{ + "branch": "ade/chat-title-summaries-xyz", + "prNumber": 1234, + "iteration": 2, + "lastPushSha": "abc123...", + "addressedCommentIds": [987654, 987655], + "status": "running", + "startedAt": "2026-04-23T14:30:00Z", + "lastPolledAt": "2026-04-23T15:12:00Z", + "exitReason": null +} +``` + +`status` values: `running`, `done-clean`, `done-max`, `blocked`. + +--- + +## Phase 0 — Setup (first invocation only) + +Skip this phase if `.ade/shipLane/.json` exists with `status: running`. + +### 0.1 Detect current state + +```bash +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +[ "$CURRENT_BRANCH" = "main" ] && { echo "refusing to ship main"; exit 1; } + +gh pr view --json number,state,headRefOid,baseRefName 2>/dev/null +``` + +If a PR exists for the current branch, skip to 0.4 (bot pings) with `prNumber` captured. + +### 0.2 Pre-push preparation (no existing PR) + +Run two sub-agents **serially** (automate first, then finalize): + +1. **automate-agent** — follows `.claude/commands/automate.md` (or wherever its sibling lives for non-Claude CLIs). Generates tests for untested new code on the branch. +2. **finalize-agent** — follows `.claude/commands/finalize.md`. Runs simplification, doc updates, lock-file sync, typecheck, lint, sharded tests, build. + +If either exits with failure, abort Phase 0 and record `status: blocked`, `exitReason: "phase-0-gate-failed"`. + +### 0.3 Commit + push + create PR + +Commit and push first — both paths below need the remote branch to exist: + +```bash +git add -A +git diff --cached --quiet || git commit -m "ship: prepare lane for review" +git push -u origin "$CURRENT_BRANCH" +``` + +**PR creation: prefer the ADE CLI.** Opening the PR via `ade` registers it in ADE's PR tracking (lane ↔ PR link, check/comment inventory, review-thread state). `gh pr create` is the fallback, not the default. Falling back too eagerly defeats the purpose — do it only after you've genuinely confirmed the ADE path is broken. + +### Discovery protocol (for the agent — not a script) + +The `ade` surface evolves. Don't assume flag names or output shapes from this playbook; discover them live. + +1. **Is `ade` on PATH?** `command -v ade`. If not, skip to the fallback. +2. **Confirm the PR subcommand exists.** `ade --help` (or `ade -h`). Look for `prs` (or whatever the current noun is — the help text is authoritative, this playbook is not). +3. **Read the exact create invocation.** `ade prs --help` then `ade prs create --help`. Note the actual required flags — expect something like `--lane `, `--base `, and an output format flag (`--json`, `--text`, or global `--json`). Do not trust any specific invocation this playbook gives you; trust the help output. +4. **Resolve current branch → lane id.** + - `ade lanes list --json` (or whatever the help shows) and filter by the field that holds the branch name (commonly `branchRef` or `branch`). + - If the branch isn't a registered lane, read `ade lanes --help` to find the import/register subcommand (commonly `ade lanes import --branch `). Run it, then re-list. + - If import's flag names differ from what you expect, re-read the help. Don't guess. +5. **Create the PR** with the exact invocation the help documented. Read any error carefully — they mean different things: + - **Usage error** (unknown flag, missing arg) → re-read `--help`, fix, retry. Do not fall back yet. + - **"PR already exists for lane"** → recover the existing PR number via `ade prs list --lane ` (or equivalent) and skip to Phase 0.4. + - **Auth error** (token expired, permission denied) → exit with `status: blocked`, `exitReason: "ade-auth-failed"`. Do NOT silently fall back; surface to the user. + - **Genuine internal error, reproducible after retry** → only now fall back. +6. **Capture the PR number.** The create command's output format varies (JSON, plain number, URL). If you can't extract it reliably, run `ade prs list --lane --json` as a cross-check. If ADE's output is opaque after reasonable effort, `gh pr view --json number -q .number` is a safe cross-check since the PR now exists on GitHub. + +Only after steps 1–6 have been genuinely attempted should the fallback run: + +```bash +gh pr create --base main --head "$CURRENT_BRANCH" --fill +PR_NUMBER=$(gh pr view --json number -q .number) +``` + +Record in the state file which path was used (`prCreatedVia: "ade" | "gh"`). If the `gh` path fired, mention it in the final summary so the user can run `ade prs inventory ` to reconcile. + +### 0.4 Post initial bot pings + +See Phase 4 rules — always `@copilot review but do not make fixes`; add `@greptile` and `@coderabbit` if the diff touches more than 250 files. + +### 0.5 Write initial state + +```json +{ + "branch": "", + "prNumber": , + "iteration": 0, + "lastPushSha": "", + "addressedCommentIds": [], + "status": "running", + "startedAt": "", + "lastPolledAt": null, + "exitReason": null +} +``` + +Then schedule the first wake-up (see Phase 5). + +--- + +## Phase 1 — Poll + +Runs on every wake-up. Delegate to a **poll-agent** so the lead's context stays clean. The poll-agent runs these calls and returns a single structured summary. + +### 1.1 PR and CI state + +```bash +gh pr view "$PR_NUMBER" --json state,mergeable,mergeStateStatus,headRefOid,baseRefOid,isDraft +gh pr checks "$PR_NUMBER" --json name,state,conclusion,link +``` + +If any check's `state` is `IN_PROGRESS` or `QUEUED`, note CI as still running. + +### 1.2 Fetch new comments since last push + +Use the commit timestamp of `lastPushSha` as the `since` filter (ISO 8601). Fall back to the state file's `lastPolledAt` if the commit isn't locally available. + +```bash +SINCE=$(git show -s --format=%cI "$LAST_PUSH_SHA" 2>/dev/null) + +gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER/comments" \ + --paginate -q "[.[] | select(.created_at > \"$SINCE\")]" + +gh api "repos/{owner}/{repo}/issues/$PR_NUMBER/comments" \ + --paginate -q "[.[] | select(.created_at > \"$SINCE\")]" + +gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER/reviews" \ + --paginate -q "[.[] | select(.submitted_at > \"$SINCE\")]" +``` + +Filter out any comment whose `id` is in `addressedCommentIds`. + +### 1.3 Return structured summary + +```json +{ + "merged": false, + "behindMain": true, + "isDraft": false, + "ciRunning": false, + "ciFailed": [ + { "name": "test-desktop (3)", "link": "https://github.com/.../runs/123" } + ], + "newComments": [ + { + "id": 987700, + "author": "copilot-pull-request-reviewer", + "body": "Consider guarding against null here.", + "path": "apps/desktop/src/main/services/x.ts", + "line": 42, + "type": "diff-line" + } + ], + "pollHeadSha": "" +} +``` + +`behindMain` is derived from `mergeStateStatus` being `BEHIND` or `DIRTY`, or from `git merge-base --is-ancestor origin/main HEAD` returning non-zero. + +--- + +## Phase 2 — Decide + +Pure logic on the poll summary: + +| Condition | Action | +| --- | --- | +| `merged == true` | Exit `done-clean`; clear state file. | +| `behindMain == true` | Go to Phase 3a (rebase), then restart Phase 1. | +| `ciFailed` empty, `newComments` empty, `ciRunning == true` | No fix work. Go to Phase 5 (schedule next wake). | +| `ciFailed` empty, `newComments` empty, `ciRunning == false` | Exit `done-clean`. | +| Otherwise | Go to Phase 3b (fix). | + +--- + +## Phase 3a — Rebase / merge + +```bash +git fetch origin +git rebase origin/main +``` + +**On conflict:** the lead resolves using full repo context. The agent has the codebase; it reads both sides of each conflict and produces a merged result. If the conflict spans many files or touches shared contracts (IPC types, DB schema, sync payloads), the lead spawns a **conflict-resolver sub-agent** with: + +- The conflicted file list +- The two divergent diffs per file (`git diff :1: :2:` base→ours, `git diff :1: :3:` base→theirs) +- The branch's feature context (`git log main..HEAD --oneline`) +- Explicit instruction to preserve both sides' intent rather than picking one + +If rebase becomes unrecoverable (agent's own judgment): + +```bash +git rebase --abort +git merge origin/main +``` + +Resolve merge conflicts the same way. If the merge is **still** unrecoverable, exit `blocked` with `exitReason: "conflict-unrecoverable"` and post a PR comment flagging a human, listing the files involved. + +### Post-resolution validation + +Before pushing, run tests scoped to touched files only: + +```bash +# Touched since rebase started +CHANGED=$(git diff --name-only ORIG_HEAD HEAD | grep -E '\.(ts|tsx)$') + +# Run colocated test files that exist +for f in $CHANGED; do + TEST="${f%.ts}.test.ts" + [ -f "$TEST" ] && echo "$TEST" +done | sort -u | xargs -r -I{} sh -c 'cd apps/desktop && npx vitest run {}' + +# If typescript touched, typecheck the package +echo "$CHANGED" | grep -q "^apps/desktop/" && (cd apps/desktop && npx tsc --noEmit -p .) +echo "$CHANGED" | grep -q "^apps/ade-cli/" && (cd apps/ade-cli && npm run typecheck) +``` + +Then: + +```bash +git push --force-with-lease +``` + +Post bot pings (Phase 4), update state (Phase 5), restart Phase 1 immediately — do not schedule a wake, because we just pushed and want a fresh poll soon. + +--- + +## Phase 3b — Fix + +### 3b.1 Parse failed CI + +For each failed check: + +```bash +RUN_ID=$(gh run list --branch "$CURRENT_BRANCH" --limit 1 --json databaseId -q '.[0].databaseId') +gh run view "$RUN_ID" --log-failed +``` + +Extract: + +- Failing test file paths (grep for `FAIL` in vitest output, `error` in tsc/eslint output) +- Exact error snippets (stack trace + surrounding lines) +- Which shard (e.g., `test-desktop (3)` → failing files only ran on shard 3) + +### 3b.2 Dispatch fix sub-agents in parallel + +If both CI fixes and review-comment fixes are needed, spawn them **in parallel** (each scoped to its own minimum input): + +**ci-fix-agent** input: +- Failing test file paths +- Error snippets (not full logs) +- Allowed to read any source file, but MUST NOT rewrite tests unless the test is genuinely wrong +- Must verify each fix with `cd && npx vitest run ` before reporting done + +**review-fix-agent** input: +- List of new comments: `{id, author, body, path, line}` +- Comment filter: address **every** comment that touches code — bot bugs, bot nits, human change-requests, human style nits. Skip only: + - Pure questions with no change requested + - Praise + - Comments whose referenced line no longer exists in the current diff + - IDs already in `addressedCommentIds` +- Record every comment id it addressed (for the lead to merge into state) + +Both agents edit files directly. They do not commit; only the lead commits. + +### 3b.3 Lead verification + commit + +```bash +git status +git diff --stat +``` + +The lead reviews the combined diff. If anything is surprising (unrelated files touched, enormous diffs), the lead can revert specific hunks with `git checkout -- ` before committing. + +Re-run scoped checks on touched files (same commands as Phase 3a validation). + +Commit with a message that lists what was addressed: + +```bash +git commit -m "ship: iteration $N — fix $CI_JOBS, address #$COMMENT_IDS" +git push +``` + +Post bot pings (Phase 4), update state (Phase 5), restart Phase 1 immediately. + +--- + +## Phase 4 — Post-push bot pings + +Runs after **any** push (initial or re-push). Always: + +```bash +gh pr comment "$PR_NUMBER" --body "@copilot review but do not make fixes" +``` + +If the PR touches more than 250 files: + +```bash +FILE_COUNT=$(gh pr diff "$PR_NUMBER" --name-only | wc -l | tr -d ' ') +if [ "$FILE_COUNT" -gt 250 ]; then + gh pr comment "$PR_NUMBER" --body "@greptile review" + gh pr comment "$PR_NUMBER" --body "@coderabbit review" +fi +``` + +These are separate comments (not a single body) so each bot handler parses its own mention reliably. + +--- + +## Phase 5 — Bookkeeping + schedule next wake + +### 5.1 Update state + +```json +{ + "iteration": , + "lastPushSha": "", + "addressedCommentIds": [, ], + "lastPolledAt": "", + "status": "running" +} +``` + +### 5.2 Decide exit vs next wake + +- `iteration >= 5` → set `status: done-max`, `exitReason: "iteration-cap-reached"`, post a PR comment listing remaining unaddressed items, exit. +- `merged == true` (observed during this iteration) → set `status: done-clean`, exit. +- Otherwise → schedule next wake. + +### 5.3 Self-pace the next wake + +Agent-CLI-agnostic guidance (Claude Code maps this to `ScheduleWakeup`; other CLIs map it to their equivalent sleep/resume): + +- Just pushed, CI hasn't started yet → **270 seconds** (stay in prompt cache) +- CI running → **720 seconds** (12 min — the user's spec) +- CI done, waiting on human review → **1800 seconds** (30 min; cost-efficient) +- Unknown → **720 seconds** default + +The cadence is a hint, not a contract. If the agent knows CI finishes faster, wake sooner; if reviewers are slow, wake later. + +--- + +## Exit states + +| status | meaning | next action | +| --- | --- | --- | +| `done-clean` | PR merged OR green + no unaddressed comments | clear state file; print summary | +| `done-max` | 5 iterations exhausted | leave state file; post PR comment to human | +| `blocked` | Unrecoverable conflict, gate failure, or API error | leave state file; post PR comment with reason | + +## Summary output (always print on exit) + +``` +## Ship Lane Summary + +- PR: # +- Branch: <branch> +- Iterations: <0..5> +- Status: <done-clean | done-max | blocked> +- Reason: <one line> + +### Per-iteration log +1. pushed <sha-short> — fixed <job list>, addressed <count> comments +2. rebased onto main, pushed <sha-short> +3. ... + +### Unaddressed items (if done-max / blocked) +- <comment id or CI job>: <one-line reason> +``` + +--- + +## Notes for non-Claude agent CLIs + +- Replace `TeamCreate` with your native team/task spawning primitive. If none exists, run phases serially and compact aggressively between phases. +- Replace `ScheduleWakeup` with your native resume mechanism (cron, setTimeout-equivalent, agent checkpoint). +- Everything else — `gh`, `git`, `npx vitest`, `eslint`, `tsc` — is shell, and should work identically. +- This repo's shard count is **8** (`.github/workflows/ci.yml`). Always mirror that locally. From 4f97ef7c6b0330a3e592dd8eb0e5b2f833bcf187 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:12:26 -0400 Subject: [PATCH 06/16] Drop trailing slash on node_modules gitignore pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane worktrees use symlinks named node_modules pointing at the main checkout's installed copies. The previous `node_modules/` pattern only matched directories, so git saw the symlinks as untracked — which in turn blocked `ade prs create` preflight and forced /shipLane to fall back to `gh pr create`. Without the slash, the pattern matches both directories (main checkout) and symlinks (worktrees). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 07623ee6ad201e16d35c1db93c21416cddfb21e6) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index dbe6c19fa..c17fa95db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .DS_Store *.pem -node_modules/ +node_modules /.npm-cache /apps/desktop/release-stable /apps/desktop/.cache From 5e397c2c44c5c3be573551a7a235e14a58a04588 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:18:24 -0400 Subject: [PATCH 07/16] Add Phase 3j cleanup of lingering worker processes to /finalize The 8-shard vitest run, parallel tsup builds, and typecheck fans sometimes leave worker pools behind after the phase exits. They don't fail CI but accumulate across runs and can hold file locks. New Phase 3j kills them scoped to apps/ paths so vitest instances the user may have running elsewhere aren't affected. Adds a Cleanup line to the Phase 4 summary and the completion checklist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 3a5fbe5cea4ce540090aee266092ec3fc574c9df) --- .claude/commands/finalize.md | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md index 99edd4bde..89f73e591 100644 --- a/.claude/commands/finalize.md +++ b/.claude/commands/finalize.md @@ -310,6 +310,39 @@ git diff --name-only | sort > /tmp/finalize-session-files.txt If Phase 3e fails only inside files the simplifier touched, revert the simplifier's edits to those files and re-run. Do NOT rewrite the test suite in Phase 3 — tests that drift because the feature branch refactored UI are a separate follow-up. +### 3j. Cleanup lingering processes + +The parallel shards, typecheck, lint, and build commands in Phase 3 sometimes leave worker processes hanging after the phase exits — most commonly vitest worker pools from the 8-shard run, and tsup/esbuild workers from `npm run build`. They don't fail the CI check, but they sit in memory, can hold file locks, and pile up across repeated `/finalize` runs. + +After the rest of Phase 3 passes, kill orphaned workers. Match on the project path so you only catch processes from **this** finalize run — don't nuke vitest instances the user may have running in another terminal or editor: + +```bash +# List what's lingering (agent: read this before killing anything) +pgrep -fa "vitest|tsup|tsc --noEmit|eslint" | grep -E "apps/(desktop|ade-cli|web)" || echo " (no orphans)" + +# Kill vitest workers scoped to this project +pgrep -f "vitest.*apps/(desktop|ade-cli)" | xargs -r kill 2>/dev/null + +# Kill hung build / typecheck processes scoped to this project +pgrep -f "tsup.*apps/(desktop|ade-cli|web)|tsc --noEmit.*apps/(desktop|ade-cli|web)" | xargs -r kill 2>/dev/null + +# Give them 2s to exit cleanly, then SIGKILL anything stubborn in the project path +sleep 2 +pgrep -f "vitest.*apps/(desktop|ade-cli)|tsup.*apps/(desktop|ade-cli|web)" | xargs -r kill -9 2>/dev/null || true +``` + +Never use a bare `pkill -f vitest` or `pkill -f node` — that would kill processes outside this finalize run. Always scope the pattern to `apps/desktop`, `apps/ade-cli`, or `apps/web` so only ADE-spawned workers are targeted. + +Also watch for orphaned node-pty or Electron helper processes if the tests spawned subprocesses (rare, but happens): + +```bash +pgrep -fa "node-pty|Electron Helper" | grep -E "apps/desktop" | head +``` + +Kill selectively only if the parent is clearly gone (PPID == 1 on macOS/Linux). + +Report killed PIDs in the Phase 4 summary under "Cleanup" so the user can see what happened. + --- ## Phase 4: Summary @@ -337,6 +370,9 @@ If Phase 3e fails only inside files the simplifier touched, revert the simplifie - Build (all apps): PASS - Doc validation: PASS +### Cleanup: +- Orphan processes killed: N (PIDs: [list] or "none") + ### Status: Ready to push / Issues found ``` @@ -354,3 +390,4 @@ Before marking complete: - [ ] All tests passed (desktop sharded 8-way + mcp-server) - [ ] All apps build successfully - [ ] Doc validation passed +- [ ] Orphan worker processes cleaned up (vitest/tsup/tsc) — scoped to apps/ paths only From e2d726db08689f830cb3d471b8d00e810974cf84 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:38:13 -0400 Subject: [PATCH 08/16] Add feedback, suppression memory, tool-backed evidence, inline diff to review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Feedback loop: per-finding acknowledge/dismiss/snooze/suppress with reason capture, persisted to review_finding_feedback and displayed as badges on finding cards. - Suppression memory (ADE-27 flagship): new review_suppressions table plus reviewSuppressionService that embeds suppressed titles via the existing embedding service and KNN-matches candidate findings (embedding cosine with Jaccard fallback). Scope tiers: repo / path (glob) / global. Pre-publish filter skips matched findings and tags them with scope + similarity. Active suppressions inspectable and removable in a new Learnings panel. - Tool-backed evidence (ADE-26 finishing): reviewToolEvidence promotes CI, test, and session-failure signals from the validation context into first-class tool_signal Evidence entries on findings, capped at three per finding and gated on file or title overlap. - Inline diff excerpts: reviewDiffContext builds a highlighted ±8 line window around the anchored line from the materialized patch; rendered in the new ReviewFindingCard with add/del tinting and focus-line border. - Finding class UX: intent_drift / incomplete_rollout / late_stage_regression chips with explanatory tooltips and distinctive tones. - Edge cases: severity filter chips, filtered-count toggle, cancel-run control for running/queued reviews, retry for failed runs, dedicated No-findings empty state. - Learnings tab: quality metrics (runs, noise rate, addressed, dismissed, published, by-class), active suppressions list with hit counts, recent feedback log. - Backend: review service exposes cancelRun, recordFeedback, listSuppressions, deleteSuppression, qualityReport; new IPC channels + preload bridge + browser mocks. - Tests: 13 new tests (diff-context, tool-evidence, suppression service) plus schema updates to keep existing review service + UI suites green; 46 review-related tests now pass. - Merge fix: narrow ptyService resume-guard to startupCommand paths so terminal create-with-provided-id keeps working; keep strict behavior for codex-style resume attempts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/desktop/src/main/main.ts | 1 + .../src/main/services/ipc/registerIpc.ts | 25 + .../src/main/services/pty/ptyService.ts | 4 +- .../services/review/reviewDiffContext.test.ts | 57 ++ .../main/services/review/reviewDiffContext.ts | 119 ++++ .../services/review/reviewService.test.ts | 37 +- .../src/main/services/review/reviewService.ts | 335 +++++++++- .../review/reviewSuppressionService.test.ts | 206 ++++++ .../review/reviewSuppressionService.ts | 311 +++++++++ .../review/reviewToolEvidence.test.ts | 83 +++ .../services/review/reviewToolEvidence.ts | 147 ++++ apps/desktop/src/main/services/state/kvDb.ts | 44 ++ apps/desktop/src/preload/global.d.ts | 9 + apps/desktop/src/preload/preload.ts | 14 + apps/desktop/src/renderer/browserMock.ts | 30 + .../components/review/ReviewFindingCard.tsx | 632 ++++++++++++++++++ .../review/ReviewLearningsPanel.tsx | 267 ++++++++ .../components/review/ReviewPage.test.tsx | 16 +- .../renderer/components/review/ReviewPage.tsx | 237 ++++--- .../renderer/components/review/reviewApi.ts | 44 ++ .../renderer/components/review/reviewTypes.ts | 11 + apps/desktop/src/shared/ipc.ts | 5 + apps/desktop/src/shared/types/review.ts | 134 +++- 23 files changed, 2675 insertions(+), 93 deletions(-) create mode 100644 apps/desktop/src/main/services/review/reviewDiffContext.test.ts create mode 100644 apps/desktop/src/main/services/review/reviewDiffContext.ts create mode 100644 apps/desktop/src/main/services/review/reviewSuppressionService.test.ts create mode 100644 apps/desktop/src/main/services/review/reviewSuppressionService.ts create mode 100644 apps/desktop/src/main/services/review/reviewToolEvidence.test.ts create mode 100644 apps/desktop/src/main/services/review/reviewToolEvidence.ts create mode 100644 apps/desktop/src/renderer/components/review/ReviewFindingCard.tsx create mode 100644 apps/desktop/src/renderer/components/review/ReviewLearningsPanel.tsx diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index b7ad35098..9e037b3ab 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2349,6 +2349,7 @@ app.whenReady().then(async () => { testService, issueInventoryService, prService, + embeddingService, onEvent: (event) => emitProjectEvent(projectRoot, IPC.reviewEvent, event), }); const automationIngressService = createAutomationIngressService({ diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 051dac3d7..dafcf12bf 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -2817,6 +2817,31 @@ export function registerIpc({ return ctx.reviewService.rerun(arg?.runId ?? ""); }); + ipcMain.handle(IPC.reviewCancelRun, async (_event, arg: { runId: string }) => { + const ctx = getCtx(); + return ctx.reviewService.cancelRun({ runId: arg?.runId ?? "" }); + }); + + ipcMain.handle(IPC.reviewRecordFeedback, async (_event, arg: import("../../../shared/types").ReviewRecordFeedbackArgs) => { + const ctx = getCtx(); + return ctx.reviewService.recordFeedback(arg); + }); + + ipcMain.handle(IPC.reviewListSuppressions, async (_event, arg: import("../../../shared/types").ReviewListSuppressionsArgs | undefined) => { + const ctx = getCtx(); + return ctx.reviewService.listSuppressions(arg ?? {}); + }); + + ipcMain.handle(IPC.reviewDeleteSuppression, async (_event, arg: { suppressionId: string }) => { + const ctx = getCtx(); + return ctx.reviewService.deleteSuppression({ suppressionId: arg?.suppressionId ?? "" }); + }); + + ipcMain.handle(IPC.reviewQualityReport, async () => { + const ctx = getCtx(); + return ctx.reviewService.qualityReport(); + }); + ipcMain.handle(IPC.adeActionsListRegistry, async (): Promise<AdeActionRegistryEntry[]> => { const ctx = getCtx(); const services = getAdeActionDomainServices(ctx as unknown as AdeRuntime); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index a633db1e1..74623c3de 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -1010,10 +1010,12 @@ export function createPtyService({ const requestedSessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; const allowNewSessionId = args.allowNewSessionId === true; + const isResumeAttempt = + typeof args.startupCommand === "string" && args.startupCommand.trim().length > 0; const existingSession = requestedSessionId.length ? sessionService.get(requestedSessionId) : null; - if (requestedSessionId.length && !existingSession && !allowNewSessionId) { + if (requestedSessionId.length && !existingSession && isResumeAttempt && !allowNewSessionId) { throw new Error(`Terminal session '${requestedSessionId}' was not found.`); } if (existingSession && existingSession.laneId !== laneId) { diff --git a/apps/desktop/src/main/services/review/reviewDiffContext.test.ts b/apps/desktop/src/main/services/review/reviewDiffContext.test.ts new file mode 100644 index 000000000..f95f8f9ab --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewDiffContext.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { buildDiffContextForFinding } from "./reviewDiffContext"; + +const SAMPLE_PATCH = `diff --git a/src/auth.ts b/src/auth.ts +--- a/src/auth.ts ++++ b/src/auth.ts +@@ -10,6 +10,10 @@ + function validateToken(token: string) { + if (!token) return false; +- return token === SECRET; ++ if (token.length < 8) return false; ++ return verifySignature(token); ++} ++function verifySignature(token: string) { ++ return cryptoCompare(token, SECRET); + }`; + +describe("buildDiffContextForFinding", () => { + it("returns null when file path is missing", () => { + expect(buildDiffContextForFinding({ filePath: null, anchoredLine: 12, patches: [] })).toBeNull(); + }); + + it("returns null when no patch matches the file", () => { + const result = buildDiffContextForFinding({ + filePath: "src/other.ts", + anchoredLine: 10, + patches: [{ filePath: "src/auth.ts", excerpt: SAMPLE_PATCH }], + }); + expect(result).toBeNull(); + }); + + it("highlights the anchored line and slices window around it", () => { + const result = buildDiffContextForFinding({ + filePath: "src/auth.ts", + anchoredLine: 13, + patches: [{ filePath: "src/auth.ts", excerpt: SAMPLE_PATCH }], + }); + expect(result).not.toBeNull(); + expect(result!.filePath).toBe("src/auth.ts"); + expect(result!.anchoredLine).toBe(13); + const highlighted = result!.lines.filter((line) => line.highlighted); + expect(highlighted.length).toBe(1); + expect(highlighted[0]?.line).toBe(13); + expect(result!.lines.some((line) => line.kind === "add")).toBe(true); + }); + + it("falls back to the first hunk when no anchor is provided", () => { + const result = buildDiffContextForFinding({ + filePath: "src/auth.ts", + anchoredLine: null, + patches: [{ filePath: "src/auth.ts", excerpt: SAMPLE_PATCH }], + }); + expect(result).not.toBeNull(); + expect(result!.lines.length).toBeGreaterThan(0); + expect(result!.lines.find((line) => line.highlighted)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/services/review/reviewDiffContext.ts b/apps/desktop/src/main/services/review/reviewDiffContext.ts new file mode 100644 index 000000000..809494090 --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewDiffContext.ts @@ -0,0 +1,119 @@ +import type { ReviewDiffContext } from "../../../shared/types"; + +type FilePatchSource = { + filePath: string; + excerpt: string; +}; + +const CONTEXT_RADIUS = 8; + +function extractFileHunks(patchText: string): string[] { + if (!patchText) return []; + const lines = patchText.split("\n"); + const hunks: string[][] = []; + let current: string[] = []; + for (const line of lines) { + if (line.startsWith("@@")) { + if (current.length > 0) hunks.push(current); + current = [line]; + continue; + } + if (current.length > 0) current.push(line); + } + if (current.length > 0) hunks.push(current); + return hunks.map((hunk) => hunk.join("\n")); +} + +type ParsedHunk = { + newStart: number; + newLength: number; + lines: ReviewDiffContext["lines"]; +}; + +function parseHunk(hunk: string): ParsedHunk | null { + const rawLines = hunk.split("\n"); + const header = rawLines.shift(); + if (!header) return null; + const match = header.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (!match) return null; + const newStart = Number(match[1] ?? "0"); + const newLength = Number(match[2] ?? "1"); + + const output: ReviewDiffContext["lines"] = [ + { line: null, kind: "meta", text: header, highlighted: false }, + ]; + let runningLine = newStart; + for (const raw of rawLines) { + if (raw.length === 0) continue; + if (raw.startsWith("\\")) { + output.push({ line: null, kind: "meta", text: raw, highlighted: false }); + continue; + } + const ch = raw[0]!; + const text = raw.slice(1); + if (ch === "+") { + output.push({ line: runningLine, kind: "add", text, highlighted: false }); + runningLine += 1; + } else if (ch === "-") { + output.push({ line: null, kind: "del", text, highlighted: false }); + } else { + output.push({ line: runningLine, kind: "context", text, highlighted: false }); + runningLine += 1; + } + } + return { newStart, newLength, lines: output }; +} + +function sliceAroundAnchor(parsed: ParsedHunk, anchor: number): ParsedHunk { + const windowStart = Math.max(parsed.newStart, anchor - CONTEXT_RADIUS); + const windowEnd = anchor + CONTEXT_RADIUS; + const filtered: ReviewDiffContext["lines"] = []; + for (const entry of parsed.lines) { + if (entry.kind === "meta") { + filtered.push(entry); + continue; + } + if (entry.line == null) { + filtered.push(entry); + continue; + } + if (entry.line >= windowStart && entry.line <= windowEnd) { + filtered.push({ ...entry, highlighted: entry.line === anchor }); + } + } + return { ...parsed, lines: filtered }; +} + +export function buildDiffContextForFinding(args: { + filePath: string | null; + anchoredLine: number | null; + patches: FilePatchSource[]; +}): ReviewDiffContext | null { + if (!args.filePath) return null; + const patch = args.patches.find((entry) => entry.filePath === args.filePath); + if (!patch || !patch.excerpt) return null; + const hunks = extractFileHunks(patch.excerpt).map(parseHunk).filter((h): h is ParsedHunk => h != null); + if (hunks.length === 0) return null; + + let chosen: ParsedHunk | null = null; + if (args.anchoredLine != null) { + chosen = + hunks.find((hunk) => args.anchoredLine! >= hunk.newStart && args.anchoredLine! < hunk.newStart + hunk.newLength) ?? + null; + } + if (!chosen) chosen = hunks[0] ?? null; + if (!chosen) return null; + + const sliced = args.anchoredLine != null ? sliceAroundAnchor(chosen, args.anchoredLine) : chosen; + const lineNumbers = sliced.lines.filter((entry) => entry.line != null).map((entry) => entry.line!); + const startLine = lineNumbers.length ? Math.min(...lineNumbers) : chosen.newStart; + const endLine = lineNumbers.length ? Math.max(...lineNumbers) : chosen.newStart + chosen.newLength - 1; + + return { + filePath: args.filePath, + startLine, + endLine, + anchoredLine: args.anchoredLine, + lines: sliced.lines, + }; +} diff --git a/apps/desktop/src/main/services/review/reviewService.test.ts b/apps/desktop/src/main/services/review/reviewService.test.ts index b3f2739b0..ac7ef3d75 100644 --- a/apps/desktop/src/main/services/review/reviewService.test.ts +++ b/apps/desktop/src/main/services/review/reviewService.test.ts @@ -96,7 +96,42 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { source_pass text not null, publication_state text not null, originating_passes_json text, - adjudication_json text + adjudication_json text, + diff_context_json text, + suppression_match_json text + ) + `); + raw.run(` + create table review_finding_feedback( + id text primary key, + finding_id text not null, + run_id text not null, + project_id text not null, + kind text not null, + reason text, + note text, + snooze_until text, + created_at text not null + ) + `); + raw.run(` + create table review_suppressions( + id text primary key, + project_id text not null, + scope text not null, + repo_key text, + path_pattern text, + title text not null, + title_norm text not null, + finding_class text, + severity text, + reason text, + note text, + embedding_json text, + source_finding_id text, + hit_count integer not null default 0, + created_at text not null, + last_matched_at text ) `); raw.run(` diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index 9570a3c4e..93c463f45 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -1,15 +1,23 @@ import { randomUUID } from "node:crypto"; import type { ReviewArtifactType, + ReviewDiffContext, + ReviewDismissReason, ReviewEventPayload, ReviewEvidence, + ReviewFeedbackKind, + ReviewFeedbackRecord, ReviewFinding, ReviewFindingAdjudication, ReviewFindingClass, + ReviewFindingSuppressionMatch, + ReviewListSuppressionsArgs, ReviewPublication, ReviewPublicationDestination, ReviewPublicationInlineComment, ReviewPublicationState, + ReviewQualityReport, + ReviewRecordFeedbackArgs, ReviewResolvedCompareTarget, ReviewRun, ReviewRunArtifact, @@ -21,6 +29,8 @@ import type { ReviewSeveritySummary, ReviewSourcePass, ReviewStartRunArgs, + ReviewSuppression, + ReviewSuppressionScope, ReviewTarget, ReviewListRunsArgs, ReviewLaunchContext, @@ -46,6 +56,10 @@ import { overlayMatchesPath, type MatchedReviewRuleOverlay, } from "./reviewRuleRegistry"; +import { createReviewSuppressionService, type ReviewSuppressionService } from "./reviewSuppressionService"; +import { buildDiffContextForFinding } from "./reviewDiffContext"; +import { buildToolBackedEvidence } from "./reviewToolEvidence"; +import type { EmbeddingService } from "../memory/embeddingService"; type ReviewRunRow = { id: string; @@ -83,6 +97,20 @@ type ReviewFindingRow = { publication_state: string; originating_passes_json: string | null; adjudication_json: string | null; + diff_context_json: string | null; + suppression_match_json: string | null; +}; + +type ReviewFindingFeedbackRow = { + id: string; + finding_id: string; + run_id: string; + project_id: string; + kind: string; + reason: string | null; + note: string | null; + snooze_until: string | null; + created_at: string; }; type ReviewRunArtifactRow = { @@ -1120,6 +1148,15 @@ function adjudicatePassFindings(args: { }; } +function deriveRepoKey(target: ReviewPublicationDestination | null, fallbackProjectId: string): string { + if (target && target.kind === "github_pr_review") { + if (target.repoOwner && target.repoName) { + return `${target.repoOwner}/${target.repoName}`.toLowerCase(); + } + } + return `project:${fallbackProjectId}`; +} + function tallySeveritySummary(findings: ReviewFinding[]): ReviewSeveritySummary { const summary = defaultSeveritySummary(); for (const finding of findings) { @@ -1182,6 +1219,21 @@ function mapFindingRow(row: ReviewFindingRow): ReviewFinding { publicationState: (row.publication_state as ReviewPublicationState) ?? "local_only", originatingPasses: safeJsonParse<ReviewPassKey[]>(row.originating_passes_json, []), adjudication: safeJsonParse<ReviewFindingAdjudication | null>(row.adjudication_json, null), + diffContext: safeJsonParse<ReviewDiffContext | null>(row.diff_context_json, null), + suppressionMatch: safeJsonParse<ReviewFindingSuppressionMatch | null>(row.suppression_match_json, null), + }; +} + +function mapFeedbackRow(row: ReviewFindingFeedbackRow): ReviewFeedbackRecord { + return { + id: row.id, + findingId: row.finding_id, + runId: row.run_id, + kind: (row.kind as ReviewFeedbackKind) ?? "acknowledge", + reason: (row.reason as ReviewDismissReason | null) ?? null, + note: row.note, + snoozeUntil: row.snooze_until, + createdAt: row.created_at, }; } @@ -1238,6 +1290,7 @@ export function createReviewService({ testService, issueInventoryService, prService, + embeddingService, onEvent, }: { db: AdeDb; @@ -1253,6 +1306,7 @@ export function createReviewService({ testService: Pick<ReturnType<typeof createTestService>, "listRuns" | "getLogTail" | "listSuites">; issueInventoryService: Pick<ReturnType<typeof createIssueInventoryService>, "getInventory">; prService?: Pick<ReturnType<typeof createPrService>, "getReviewSnapshot" | "getChecks" | "publishReviewPublication">; + embeddingService?: Pick<EmbeddingService, "embed"> | null; onEvent?: (event: ReviewEventPayload) => void; }) { const materializer = createReviewTargetMaterializer({ laneService, prService }); @@ -1266,7 +1320,14 @@ export function createReviewService({ issueInventoryService, prService, }); + const suppressionService: ReviewSuppressionService = createReviewSuppressionService({ + db, + logger, + projectId, + embeddingService: embeddingService ?? null, + }); const activeRuns = new Set<string>(); + const cancelledRuns = new Set<string>(); let disposed = false; const configuredDefaultModelId = getDefaultModelDescriptor("codex")?.id @@ -1455,8 +1516,10 @@ export function createReviewService({ source_pass, publication_state, originating_passes_json, - adjudication_json - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + adjudication_json, + diff_context_json, + suppression_match_json + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ finding.id, finding.runId, @@ -1473,6 +1536,8 @@ export function createReviewService({ finding.publicationState, JSON.stringify(finding.originatingPasses ?? []), finding.adjudication ? JSON.stringify(finding.adjudication) : null, + finding.diffContext ? JSON.stringify(finding.diffContext) : null, + finding.suppressionMatch ? JSON.stringify(finding.suppressionMatch) : null, ], ); } @@ -1916,18 +1981,78 @@ export function createReviewService({ }, }); - const findings = adjudication.findings; + const repoKey = deriveRepoKey(materialized.publicationTarget, projectId); + const enrichedFindings: ReviewFinding[] = []; + for (const finding of adjudication.findings) { + if (disposed) return; + const diffContext = buildDiffContextForFinding({ + filePath: finding.filePath, + anchoredLine: finding.line, + patches: materialized.changedFiles.map((entry) => ({ filePath: entry.filePath, excerpt: entry.excerpt })), + }); + const toolEvidence = buildToolBackedEvidence({ + finding, + validation: reviewContext.validation.payload, + artifactIdByKey: { validation_signals: contextArtifactIds.validationArtifactId }, + }); + const suppressionMatch = await suppressionService.match({ finding, repoKey }).catch((error) => { + logger.warn("review.suppression.match_failed", { + findingId: finding.id, + error: error instanceof Error ? error.message : String(error), + }); + return null; + }); + enrichedFindings.push({ + ...finding, + evidence: toolEvidence.length > 0 ? [...finding.evidence, ...toolEvidence] : finding.evidence, + diffContext, + suppressionMatch, + }); + } + const findings = enrichedFindings; for (const finding of findings) { if (disposed) return; insertFinding(finding); } if (disposed) return; + if (cancelledRuns.has(runId)) { + cancelledRuns.delete(runId); + const endedAt = nowIso(); + updateRun(runId, { + status: "cancelled", + summary: "Run cancelled before publication.", + error_message: null, + finding_count: findings.length, + severity_summary_json: serializeSeveritySummary(tallySeveritySummary(findings)), + ended_at: endedAt, + updated_at: endedAt, + }); + emit({ type: "run-completed", runId, laneId: run.laneId, status: "cancelled" }); + emit({ type: "runs-updated", runId, laneId: run.laneId, status: "cancelled" }); + return; + } + const publishableFindings = findings.filter((finding) => finding.suppressionMatch == null); + const suppressedCount = findings.length - publishableFindings.length; + if (suppressedCount > 0) { + insertArtifact(runId, { + artifactType: "tool_evidence", + title: "Suppression filter summary", + mimeType: "application/json", + contentText: JSON.stringify({ + suppressedCount, + suppressedFindingIds: findings + .filter((finding) => finding.suppressionMatch != null) + .map((finding) => finding.id), + }, null, 2), + metadata: { suppressedCount }, + }); + } await publishRun({ runId, targetLabel: materialized.targetLabel, summary: adjudication.summary, config: effectiveRun.config, - findings, + findings: publishableFindings, publicationTarget: materialized.publicationTarget, changedFiles: materialized.changedFiles.map((entry) => ({ filePath: entry.filePath, @@ -2048,7 +2173,7 @@ export function createReviewService({ const row = getRunRow(args.runId); if (!row) return null; const run = mapRunRow(row); - const findings = db.all<ReviewFindingRow>( + const rawFindings = db.all<ReviewFindingRow>( `select * from review_findings where run_id = ? order by @@ -2064,6 +2189,19 @@ export function createReviewService({ title asc`, [args.runId], ).map(mapFindingRow); + const feedbackByFinding = new Map<string, ReviewFeedbackRecord>(); + const feedbackRows = db.all<ReviewFindingFeedbackRow>( + "select * from review_finding_feedback where run_id = ? order by created_at asc", + [args.runId], + ); + for (const feedbackRow of feedbackRows) { + const record = mapFeedbackRow(feedbackRow); + feedbackByFinding.set(record.findingId, record); + } + const findings = rawFindings.map((finding) => ({ + ...finding, + feedback: feedbackByFinding.get(finding.id) ?? null, + })); const artifacts = db.all<ReviewRunArtifactRow>( "select * from review_run_artifacts where run_id = ? order by created_at asc", [args.runId], @@ -2084,15 +2222,202 @@ export function createReviewService({ }; } + async function cancelRun(args: { runId: string }): Promise<ReviewRun | null> { + assertNotDisposed(); + const row = getRunRow(args.runId); + if (!row) return null; + if (row.status === "completed" || row.status === "failed" || row.status === "cancelled") { + return mapRunRow(row); + } + cancelledRuns.add(args.runId); + const endedAt = nowIso(); + updateRun(args.runId, { + status: "cancelled", + summary: row.summary ?? "Cancellation requested; finishing current pass.", + ended_at: endedAt, + updated_at: endedAt, + }); + const refreshed = getRunRow(args.runId); + if (refreshed) { + emit({ type: "runs-updated", runId: args.runId, laneId: refreshed.lane_id, status: "cancelled" }); + } + return refreshed ? mapRunRow(refreshed) : null; + } + + async function recordFeedback(args: ReviewRecordFeedbackArgs): Promise<ReviewFeedbackRecord> { + assertNotDisposed(); + const findingRow = db.get<ReviewFindingRow & { project_id?: string }>( + `select rf.*, rr.project_id as project_id + from review_findings rf + join review_runs rr on rr.id = rf.run_id + where rf.id = ? limit 1`, + [args.findingId], + ); + if (!findingRow) throw new Error(`Finding '${args.findingId}' was not found.`); + if (findingRow.project_id !== projectId) { + throw new Error("Cannot record feedback for a finding outside this project."); + } + const snoozeUntil = args.snoozeDurationMs && args.snoozeDurationMs > 0 + ? new Date(Date.now() + Math.min(args.snoozeDurationMs, 1000 * 60 * 60 * 24 * 365)).toISOString() + : null; + const record: ReviewFeedbackRecord = { + id: `rfb_${randomUUID()}`, + findingId: args.findingId, + runId: findingRow.run_id, + kind: args.kind, + reason: args.reason ?? null, + note: args.note ?? null, + snoozeUntil, + createdAt: nowIso(), + }; + db.run( + `insert into review_finding_feedback ( + id, finding_id, run_id, project_id, kind, reason, note, snooze_until, created_at + ) values (?,?,?,?,?,?,?,?,?)`, + [ + record.id, + record.findingId, + record.runId, + projectId, + record.kind, + record.reason, + record.note, + record.snoozeUntil, + record.createdAt, + ], + ); + + if (args.kind === "suppress") { + const scope: ReviewSuppressionScope = args.suppression?.scope ?? "repo"; + const pathPattern = args.suppression?.pathPattern ?? (scope === "path" ? findingRow.file_path : null); + const finding = mapFindingRow(findingRow); + const publicationRow = db.get<ReviewRunPublicationRow>( + "select * from review_run_publications where run_id = ? order by created_at desc limit 1", + [findingRow.run_id], + ); + const destination = publicationRow ? mapPublicationRow(publicationRow).destination : null; + const repoKey = deriveRepoKey(destination, projectId); + await suppressionService + .create({ + scope, + title: finding.title, + repoKey: scope === "global" ? null : repoKey, + pathPattern, + findingClass: finding.findingClass ?? null, + severity: finding.severity, + reason: args.reason ?? null, + note: args.note ?? null, + sourceFindingId: finding.id, + seedText: `${finding.title}\n${finding.body}`, + }) + .catch((error) => { + logger.warn("review.suppression.create_failed", { + findingId: finding.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + emit({ type: "suppressions-updated" }); + } + + emit({ type: "feedback-updated", findingId: args.findingId, runId: findingRow.run_id }); + return record; + } + + async function listSuppressions(args: ReviewListSuppressionsArgs = {}): Promise<ReviewSuppression[]> { + assertNotDisposed(); + return suppressionService.list({ limit: args.limit ?? null, scope: args.scope ?? null }); + } + + async function deleteSuppression(args: { suppressionId: string }): Promise<boolean> { + assertNotDisposed(); + const removed = suppressionService.remove(args.suppressionId); + if (removed) emit({ type: "suppressions-updated" }); + return removed; + } + + async function qualityReport(): Promise<ReviewQualityReport> { + assertNotDisposed(); + const totalRunsRow = db.get<{ n: number }>( + "select count(*) as n from review_runs where project_id = ?", + [projectId], + ); + const totalFindingsRow = db.get<{ n: number }>( + `select count(*) as n from review_findings rf + join review_runs rr on rr.id = rf.run_id + where rr.project_id = ?`, + [projectId], + ); + const publishedRow = db.get<{ n: number }>( + `select count(*) as n from review_findings rf + join review_runs rr on rr.id = rf.run_id + where rr.project_id = ? and rf.publication_state = 'published'`, + [projectId], + ); + const feedbackCounts = db.all<{ kind: string; n: number }>( + `select kind, count(*) as n from review_finding_feedback + where project_id = ? group by kind`, + [projectId], + ); + const kindMap = new Map(feedbackCounts.map((row) => [row.kind, Number(row.n ?? 0)])); + const byClassRows = db.all<{ finding_class: string | null; total: number; addressed: number }>( + `select rf.finding_class as finding_class, + count(*) as total, + sum(case when fb.kind = 'acknowledge' then 1 else 0 end) as addressed + from review_findings rf + join review_runs rr on rr.id = rf.run_id + left join review_finding_feedback fb on fb.finding_id = rf.id + where rr.project_id = ? + group by rf.finding_class + order by total desc + limit 20`, + [projectId], + ); + const recentFeedback = db.all<ReviewFindingFeedbackRow>( + "select * from review_finding_feedback where project_id = ? order by created_at desc limit 20", + [projectId], + ).map(mapFeedbackRow); + + const totalFindings = Number(totalFindingsRow?.n ?? 0); + const dismissedCount = kindMap.get("dismiss") ?? 0; + const suppressedCount = kindMap.get("suppress") ?? 0; + const addressedCount = kindMap.get("acknowledge") ?? 0; + const snoozedCount = kindMap.get("snooze") ?? 0; + const noiseRate = totalFindings > 0 ? Number(((dismissedCount + suppressedCount) / totalFindings).toFixed(3)) : 0; + + return { + projectId, + totalRuns: Number(totalRunsRow?.n ?? 0), + totalFindings, + addressedCount, + dismissedCount, + snoozedCount, + suppressedCount, + publishedCount: Number(publishedRow?.n ?? 0), + noiseRate, + recentFeedback, + byClass: byClassRows.map((row) => ({ + findingClass: (row.finding_class as ReviewFindingClass | null) ?? "uncategorized", + total: Number(row.total ?? 0), + addressed: Number(row.addressed ?? 0), + })), + }; + } + return { listLaunchContext, startRun, rerun, + cancelRun, listRuns, getRunDetail, + recordFeedback, + listSuppressions, + deleteSuppression, + qualityReport, dispose() { disposed = true; activeRuns.clear(); + cancelledRuns.clear(); }, }; } diff --git a/apps/desktop/src/main/services/review/reviewSuppressionService.test.ts b/apps/desktop/src/main/services/review/reviewSuppressionService.test.ts new file mode 100644 index 000000000..fda2fc9c9 --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewSuppressionService.test.ts @@ -0,0 +1,206 @@ +import path from "node:path"; +import { createRequire } from "node:module"; +import initSqlJs from "sql.js"; +import type { Database, SqlJsStatic } from "sql.js"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { createReviewSuppressionService } from "./reviewSuppressionService"; + +type SqlValue = string | number | null | Uint8Array; +type AdeDb = { + run: (sql: string, params?: SqlValue[]) => void; + get: <T extends Record<string, unknown> = Record<string, unknown>>(sql: string, params?: SqlValue[]) => T | null; + all: <T extends Record<string, unknown> = Record<string, unknown>>(sql: string, params?: SqlValue[]) => T[]; +}; + +function mapExecRows(rows: { columns: string[]; values: unknown[][] }[]): Record<string, unknown>[] { + const first = rows[0]; + if (!first) return []; + return first.values.map((row) => { + const out: Record<string, unknown> = {}; + first.columns.forEach((column, index) => { + out[column] = row[index]; + }); + return out; + }); +} + +let SQL: SqlJsStatic; + +beforeAll(async () => { + const require = createRequire(import.meta.url); + const wasmPath = require.resolve("sql.js/dist/sql-wasm.wasm"); + const wasmDir = path.dirname(wasmPath); + SQL = await initSqlJs({ + locateFile: (file) => path.join(wasmDir, file), + }); +}); + +function createDb(): { raw: Database; db: AdeDb } { + const raw = new SQL.Database(); + raw.run(` + create table review_suppressions( + id text primary key, + project_id text not null, + scope text not null, + repo_key text, + path_pattern text, + title text not null, + title_norm text not null, + finding_class text, + severity text, + reason text, + note text, + embedding_json text, + source_finding_id text, + hit_count integer not null default 0, + created_at text not null, + last_matched_at text + ) + `); + const db: AdeDb = { + run: (sql, params = []) => raw.run(sql, params), + all: <T extends Record<string, unknown>>(sql: string, params: SqlValue[] = []) => + mapExecRows(raw.exec(sql, params)) as T[], + get: <T extends Record<string, unknown>>(sql: string, params: SqlValue[] = []) => + (mapExecRows(raw.exec(sql, params))[0] ?? null) as T | null, + }; + return { raw, db }; +} + +const logger = { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, + trace: () => undefined, + child: () => logger, +} as unknown as Parameters<typeof createReviewSuppressionService>[0]["logger"]; + +describe("reviewSuppressionService", () => { + let dbHandle: ReturnType<typeof createDb>; + beforeEach(() => { + dbHandle = createDb(); + }); + + it("creates a suppression and lists it back", async () => { + const svc = createReviewSuppressionService({ + db: dbHandle.db as unknown as import("../state/kvDb").AdeDb, + logger, + projectId: "proj-1", + }); + await svc.create({ + scope: "repo", + title: "prefer async/await over raw promises", + repoKey: "arul28/ade", + reason: "style_only", + }); + const list = svc.list(); + expect(list.length).toBe(1); + expect(list[0]?.title).toContain("async/await"); + expect(list[0]?.scope).toBe("repo"); + }); + + it("matches a near-duplicate finding by title tokens", async () => { + const svc = createReviewSuppressionService({ + db: dbHandle.db as unknown as import("../state/kvDb").AdeDb, + logger, + projectId: "proj-1", + }); + await svc.create({ + scope: "repo", + title: "Prefer async await over raw promise chains", + repoKey: "arul28/ade", + reason: "low_value_noise", + }); + const hit = await svc.match({ + finding: { + title: "Prefer async await instead of raw promise chains", + body: "...", + filePath: "src/foo.ts", + findingClass: null, + severity: "low", + }, + repoKey: "arul28/ade", + }); + expect(hit).not.toBeNull(); + expect(hit!.similarity).toBeGreaterThanOrEqual(0.55); + expect(hit!.scope).toBe("repo"); + }); + + it("does not match unrelated findings", async () => { + const svc = createReviewSuppressionService({ + db: dbHandle.db as unknown as import("../state/kvDb").AdeDb, + logger, + projectId: "proj-1", + }); + await svc.create({ + scope: "repo", + title: "Inline styles instead of Tailwind classes", + repoKey: "arul28/ade", + reason: "style_only", + }); + const hit = await svc.match({ + finding: { + title: "Race condition in auth middleware", + body: "Concurrent requests can corrupt the session store", + filePath: "src/auth/middleware.ts", + findingClass: null, + severity: "high", + }, + repoKey: "arul28/ade", + }); + expect(hit).toBeNull(); + }); + + it("respects path-scoped patterns", async () => { + const svc = createReviewSuppressionService({ + db: dbHandle.db as unknown as import("../state/kvDb").AdeDb, + logger, + projectId: "proj-1", + }); + await svc.create({ + scope: "path", + title: "Magic number usage — prefer named constant", + pathPattern: "src/math/**", + reason: "low_value_noise", + }); + const hit = await svc.match({ + finding: { + title: "magic number usage, prefer named constant", + body: "", + filePath: "src/math/util.ts", + findingClass: null, + severity: "low", + }, + repoKey: "whatever", + }); + expect(hit?.scope).toBe("path"); + + const miss = await svc.match({ + finding: { + title: "magic number usage, prefer named constant", + body: "", + filePath: "src/other.ts", + findingClass: null, + severity: "low", + }, + repoKey: "whatever", + }); + expect(miss).toBeNull(); + }); + + it("removes a suppression", async () => { + const svc = createReviewSuppressionService({ + db: dbHandle.db as unknown as import("../state/kvDb").AdeDb, + logger, + projectId: "proj-1", + }); + const created = await svc.create({ + scope: "global", + title: "dead code removal nit", + reason: "low_value_noise", + }); + expect(svc.remove(created.id)).toBe(true); + expect(svc.list()).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/services/review/reviewSuppressionService.ts b/apps/desktop/src/main/services/review/reviewSuppressionService.ts new file mode 100644 index 000000000..a9029367b --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewSuppressionService.ts @@ -0,0 +1,311 @@ +import { randomUUID } from "node:crypto"; +import type { + ReviewDismissReason, + ReviewFinding, + ReviewFindingSuppressionMatch, + ReviewSuppression, + ReviewSuppressionScope, +} from "../../../shared/types"; +import type { Logger } from "../logging/logger"; +import type { AdeDb } from "../state/kvDb"; +import { nowIso, safeJsonParse } from "../shared/utils"; +import type { EmbeddingService } from "../memory/embeddingService"; + +const TITLE_SIM_THRESHOLD = 0.55; +const EMBEDDING_SIM_THRESHOLD = 0.78; +const PATH_GLOB_CACHE = new Map<string, RegExp>(); + +function globToRegExp(pattern: string): RegExp { + const cached = PATH_GLOB_CACHE.get(pattern); + if (cached) return cached; + let source = ""; + let i = 0; + while (i < pattern.length) { + const ch = pattern[i]; + if (ch === "*") { + if (pattern[i + 1] === "*") { + source += ".*"; + i += 2; + } else { + source += "[^/]*"; + i += 1; + } + continue; + } + if (ch === "?") { + source += "."; + i += 1; + continue; + } + if ("/.+()|^$[]{}\\".includes(ch)) { + source += `\\${ch}`; + } else { + source += ch; + } + i += 1; + } + const re = new RegExp(`^${source}$`); + PATH_GLOB_CACHE.set(pattern, re); + return re; +} + +function normalizeTitle(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function jaccard(left: Set<string>, right: Set<string>): number { + if (left.size === 0 && right.size === 0) return 0; + let intersection = 0; + for (const token of left) if (right.has(token)) intersection += 1; + const union = left.size + right.size - intersection; + return union === 0 ? 0 : intersection / union; +} + +function tokenize(value: string): Set<string> { + const tokens = normalizeTitle(value).split(" ").filter((token) => token.length >= 3); + return new Set(tokens); +} + +function cosine(a: number[] | null, b: number[] | null): number { + if (!a || !b || a.length === 0 || a.length !== b.length) return 0; + let dot = 0; + let an = 0; + let bn = 0; + for (let i = 0; i < a.length; i += 1) { + dot += a[i]! * b[i]!; + an += a[i]! * a[i]!; + bn += b[i]! * b[i]!; + } + if (an === 0 || bn === 0) return 0; + return dot / (Math.sqrt(an) * Math.sqrt(bn)); +} + +function pathMatchesScope( + candidatePath: string | null, + scope: ReviewSuppressionScope, + pattern: string | null, +): boolean { + if (scope === "global") return true; + if (scope === "repo") return true; + if (scope === "path") { + if (!pattern) return false; + if (!candidatePath) return false; + return globToRegExp(pattern).test(candidatePath); + } + return true; +} + +type ReviewSuppressionRow = { + id: string; + project_id: string; + scope: string; + repo_key: string | null; + path_pattern: string | null; + title: string; + title_norm: string; + finding_class: string | null; + severity: string | null; + reason: string | null; + note: string | null; + embedding_json: string | null; + source_finding_id: string | null; + hit_count: number; + created_at: string; + last_matched_at: string | null; +}; + +function mapRow(row: ReviewSuppressionRow): ReviewSuppression { + return { + id: row.id, + scope: (row.scope as ReviewSuppressionScope) ?? "repo", + repoKey: row.repo_key, + pathPattern: row.path_pattern, + title: row.title, + findingClass: (row.finding_class as ReviewSuppression["findingClass"]) ?? null, + severity: (row.severity as ReviewSuppression["severity"]) ?? null, + reason: (row.reason as ReviewDismissReason | null) ?? null, + note: row.note, + embedding: safeJsonParse<number[] | null>(row.embedding_json, null), + sourceFindingId: row.source_finding_id, + hitCount: Number(row.hit_count ?? 0), + createdAt: row.created_at, + lastMatchedAt: row.last_matched_at, + }; +} + +export type ReviewSuppressionService = ReturnType<typeof createReviewSuppressionService>; + +export function createReviewSuppressionService({ + db, + logger, + projectId, + embeddingService, +}: { + db: AdeDb; + logger: Logger; + projectId: string; + embeddingService?: Pick<EmbeddingService, "embed"> | null; +}) { + async function tryEmbed(text: string): Promise<number[] | null> { + if (!embeddingService || !text.trim()) return null; + try { + const vector = await embeddingService.embed(text); + return Array.from(vector); + } catch (error) { + logger.warn("review.suppression.embed_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + async function create(args: { + scope: ReviewSuppressionScope; + title: string; + repoKey?: string | null; + pathPattern?: string | null; + findingClass?: ReviewSuppression["findingClass"]; + severity?: ReviewSuppression["severity"]; + reason?: ReviewDismissReason | null; + note?: string | null; + sourceFindingId?: string | null; + seedText?: string | null; + }): Promise<ReviewSuppression> { + const embeddingText = args.seedText ?? args.title; + const embedding = await tryEmbed(embeddingText); + const row: ReviewSuppression = { + id: `rsup_${randomUUID()}`, + scope: args.scope, + repoKey: args.repoKey ?? null, + pathPattern: args.pathPattern ?? null, + title: args.title, + findingClass: args.findingClass ?? null, + severity: args.severity ?? null, + reason: args.reason ?? null, + note: args.note ?? null, + embedding, + sourceFindingId: args.sourceFindingId ?? null, + hitCount: 0, + createdAt: nowIso(), + lastMatchedAt: null, + }; + db.run( + `insert into review_suppressions ( + id, project_id, scope, repo_key, path_pattern, title, title_norm, + finding_class, severity, reason, note, embedding_json, source_finding_id, + hit_count, created_at, last_matched_at + ) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + row.id, + projectId, + row.scope, + row.repoKey, + row.pathPattern, + row.title, + normalizeTitle(row.title), + row.findingClass, + row.severity, + row.reason, + row.note, + embedding ? JSON.stringify(embedding) : null, + row.sourceFindingId, + 0, + row.createdAt, + null, + ], + ); + return row; + } + + function list(args: { + limit?: number | null; + scope?: ReviewSuppressionScope | null; + } = {}): ReviewSuppression[] { + const limit = Math.max(1, Math.min(500, args.limit ?? 200)); + const where: string[] = ["project_id = ?"]; + const params: Array<string | number | null> = [projectId]; + if (args.scope) { + where.push("scope = ?"); + params.push(args.scope); + } + const rows = db.all<ReviewSuppressionRow>( + `select * from review_suppressions where ${where.join(" and ")} order by created_at desc limit ${limit}`, + params, + ); + return rows.map(mapRow); + } + + function remove(suppressionId: string): boolean { + const row = db.get<{ id: string }>( + "select id from review_suppressions where id = ? and project_id = ? limit 1", + [suppressionId, projectId], + ); + if (!row) return false; + db.run("delete from review_suppressions where id = ?", [suppressionId]); + return true; + } + + function recordHit(suppressionId: string): void { + db.run( + "update review_suppressions set hit_count = hit_count + 1, last_matched_at = ? where id = ?", + [nowIso(), suppressionId], + ); + } + + async function match(args: { + finding: Pick<ReviewFinding, "title" | "body" | "filePath" | "findingClass" | "severity">; + repoKey?: string | null; + }): Promise<ReviewFindingSuppressionMatch | null> { + const rows = db.all<ReviewSuppressionRow>( + "select * from review_suppressions where project_id = ? order by created_at desc limit 500", + [projectId], + ); + if (rows.length === 0) return null; + + const findingText = `${args.finding.title} ${args.finding.body}`.trim(); + const candidateEmbedding = await tryEmbed(findingText); + const findingTokens = tokenize(args.finding.title); + + let best: { row: ReviewSuppression; similarity: number } | null = null; + for (const raw of rows) { + const suppression = mapRow(raw); + if (!pathMatchesScope(args.finding.filePath, suppression.scope, suppression.pathPattern)) continue; + if (suppression.scope === "repo" && suppression.repoKey && args.repoKey && suppression.repoKey !== args.repoKey) { + continue; + } + if (suppression.findingClass && args.finding.findingClass && suppression.findingClass !== args.finding.findingClass) { + continue; + } + + let score = 0; + if (candidateEmbedding && suppression.embedding) { + score = cosine(candidateEmbedding, suppression.embedding); + } + if (score < EMBEDDING_SIM_THRESHOLD) { + const titleScore = jaccard(findingTokens, tokenize(suppression.title)); + if (titleScore > score) score = titleScore; + } + + const threshold = candidateEmbedding && suppression.embedding ? EMBEDDING_SIM_THRESHOLD : TITLE_SIM_THRESHOLD; + if (score < threshold) continue; + + if (!best || score > best.similarity) { + best = { row: suppression, similarity: score }; + } + } + + if (!best) return null; + recordHit(best.row.id); + return { + suppressionId: best.row.id, + similarity: Number(best.similarity.toFixed(4)), + reason: best.row.reason, + scope: best.row.scope, + }; + } + + return { create, list, remove, match }; +} diff --git a/apps/desktop/src/main/services/review/reviewToolEvidence.test.ts b/apps/desktop/src/main/services/review/reviewToolEvidence.test.ts new file mode 100644 index 000000000..912867946 --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewToolEvidence.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { ReviewContextValidationPayload } from "./reviewContextBuilder"; +import { buildToolBackedEvidence } from "./reviewToolEvidence"; + +function emptyPayload( + overrides: Partial<ReviewContextValidationPayload> = {}, +): ReviewContextValidationPayload { + return { + linkedPr: null, + reviewSnapshot: null, + checks: [], + suites: [], + testRuns: [], + issueInventory: [], + sessionFailures: [], + signals: [], + ...overrides, + }; +} + +describe("buildToolBackedEvidence", () => { + it("returns no evidence when validation payload is null", () => { + const out = buildToolBackedEvidence({ + finding: { filePath: "src/api.ts", title: "x", body: "", line: 1 }, + validation: null, + }); + expect(out).toEqual([]); + }); + + it("maps a validation signal when the file path overlaps", () => { + const out = buildToolBackedEvidence({ + finding: { filePath: "src/api.ts", title: "Handler drops errors", body: "...", line: 20 }, + validation: emptyPayload({ + signals: [ + { + kind: "test_run_failure", + summary: "api integration test failing in src/api.ts", + filePaths: ["src/api.ts"], + sourceId: "suite:integration", + }, + ], + }), + }); + expect(out.length).toBe(1); + expect(out[0]?.kind).toBe("tool_signal"); + expect(out[0]?.toolSignal?.kind).toBe("test"); + expect(out[0]?.toolSignal?.status).toBe("fail"); + }); + + it("caps evidence at three entries", () => { + const signals = Array.from({ length: 6 }, (_, i) => ({ + kind: "pr_check_failure" as const, + summary: `check ${i} error in src/api.ts`, + filePaths: ["src/api.ts"], + sourceId: `check-${i}`, + })); + const out = buildToolBackedEvidence({ + finding: { filePath: "src/api.ts", title: "Issue", body: "", line: 1 }, + validation: emptyPayload({ signals }), + }); + expect(out.length).toBeLessThanOrEqual(3); + }); + + it("includes a failing CI check that mentions the title keywords", () => { + const out = buildToolBackedEvidence({ + finding: { filePath: null, title: "Typecheck regression in shared types", body: "", line: null }, + validation: emptyPayload({ + checks: [ + { + name: "typecheck", + status: "completed", + conclusion: "failure", + detailsUrl: "https://ci/run/1", + startedAt: null, + completedAt: null, + }, + ], + }), + }); + expect(out.length).toBe(1); + expect(out[0]?.toolSignal?.kind).toBe("typecheck"); + }); +}); diff --git a/apps/desktop/src/main/services/review/reviewToolEvidence.ts b/apps/desktop/src/main/services/review/reviewToolEvidence.ts new file mode 100644 index 000000000..518d95cd1 --- /dev/null +++ b/apps/desktop/src/main/services/review/reviewToolEvidence.ts @@ -0,0 +1,147 @@ +import type { + ReviewEvidence, + ReviewFinding, + ReviewToolSignalKind, +} from "../../../shared/types"; +import type { ReviewContextValidationPayload } from "./reviewContextBuilder"; + +type ValidationSignalSource = ReviewContextValidationPayload["signals"][number]; + +const STATUS_BY_KIND: Record<ReviewToolSignalKind, "pass" | "fail" | "warn" | "info"> = { + typecheck: "fail", + test: "fail", + lint: "fail", + build: "fail", + ci_check: "fail", + validation: "warn", +}; + +function classifyCheck(name: string): ReviewToolSignalKind { + const lower = name.toLowerCase(); + if (/(typecheck|tsc|types|type-check)/.test(lower)) return "typecheck"; + if (/(test|vitest|jest|spec)/.test(lower)) return "test"; + if (/(lint|eslint|stylelint|prettier|tsfmt)/.test(lower)) return "lint"; + if (/(build|bundle|compile|webpack|vite|rollup|tsup)/.test(lower)) return "build"; + return "ci_check"; +} + +function signalKindFromPayloadSignal(signal: ValidationSignalSource): ReviewToolSignalKind { + switch (signal.kind) { + case "pr_check_failure": + return "ci_check"; + case "test_run_failure": + return "test"; + case "review_feedback": + return "validation"; + case "session_failure": + return "validation"; + default: + return "validation"; + } +} + +function pathMatchesFinding(paths: string[], findingPath: string | null): boolean { + if (!findingPath || paths.length === 0) return false; + const normalized = findingPath.replace(/^\.+\//, ""); + return paths.some((candidate) => { + const normalizedCandidate = candidate.replace(/^\.+\//, ""); + return normalizedCandidate === normalized + || normalizedCandidate.endsWith(`/${normalized}`) + || normalized.endsWith(`/${normalizedCandidate}`); + }); +} + +function titleMatchesSignal(findingTitle: string, findingBody: string, summary: string): boolean { + const haystack = `${findingTitle} ${findingBody}`.toLowerCase(); + const tokens = summary + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length >= 4); + if (tokens.length === 0) return false; + let hits = 0; + for (const token of tokens) { + if (haystack.includes(token)) hits += 1; + if (hits >= 2) return true; + } + return false; +} + +export function buildToolBackedEvidence(args: { + finding: Pick<ReviewFinding, "filePath" | "title" | "body" | "line">; + validation: ReviewContextValidationPayload | null; + artifactIdByKey?: Partial<Record<"validation_signals", string>>; +}): ReviewEvidence[] { + if (!args.validation) return []; + const out: ReviewEvidence[] = []; + const artifactId = args.artifactIdByKey?.validation_signals ?? null; + + for (const signal of args.validation.signals) { + const matches = pathMatchesFinding(signal.filePaths, args.finding.filePath); + const titleHit = !matches && titleMatchesSignal(args.finding.title, args.finding.body, signal.summary); + if (!matches && !titleHit) continue; + const kind: ReviewToolSignalKind = signalKindFromPayloadSignal(signal); + out.push({ + kind: "tool_signal", + summary: signal.summary, + filePath: signal.filePaths[0] ?? null, + line: null, + quote: null, + artifactId, + toolSignal: { + kind, + source: signal.sourceId, + status: STATUS_BY_KIND[kind], + detail: signal.summary, + }, + }); + if (out.length >= 3) return out; + } + + for (const check of args.validation.checks) { + if (check.conclusion && check.conclusion !== "failure" && check.conclusion !== "action_required") continue; + const kind = classifyCheck(check.name); + const detail = `${check.name} (${check.status}${check.conclusion ? ` / ${check.conclusion}` : ""})`; + const matchesTitle = titleMatchesSignal(args.finding.title, args.finding.body, check.name); + if (!matchesTitle && out.length > 0) continue; + out.push({ + kind: "tool_signal", + summary: `CI check ${detail}`, + filePath: null, + line: null, + quote: null, + artifactId, + toolSignal: { + kind, + source: check.detailsUrl ?? check.name, + status: check.conclusion === "failure" ? "fail" : "warn", + detail, + }, + }); + if (out.length >= 3) return out; + } + + for (const testRun of args.validation.testRuns) { + if (testRun.status !== "failed" && testRun.status !== "error") continue; + const hitLog = testRun.logExcerpt + ? titleMatchesSignal(args.finding.title, args.finding.body, testRun.logExcerpt) + : false; + if (!hitLog && out.length > 0) continue; + out.push({ + kind: "tool_signal", + summary: `Test run ${testRun.suiteName} — ${testRun.status}${testRun.exitCode != null ? ` (exit ${testRun.exitCode})` : ""}`, + filePath: null, + line: null, + quote: testRun.logExcerpt, + artifactId, + toolSignal: { + kind: "test", + source: `test-run:${testRun.runId}`, + status: "fail", + detail: testRun.logExcerpt ?? null, + }, + }); + if (out.length >= 3) return out; + } + + return out; +} diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 0b8afe031..61c0a4349 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -3197,6 +3197,50 @@ function migrate(db: MigrationDb) { try { db.run("alter table review_findings add column finding_class text"); } catch {} try { db.run("alter table review_findings add column originating_passes_json text"); } catch {} try { db.run("alter table review_findings add column adjudication_json text"); } catch {} + try { db.run("alter table review_findings add column diff_context_json text"); } catch {} + try { db.run("alter table review_findings add column suppression_match_json text"); } catch {} + + // Per-finding feedback — powers the learning loop. + db.run(` + create table if not exists review_finding_feedback ( + id text primary key, + finding_id text not null, + run_id text not null, + project_id text not null, + kind text not null, + reason text, + note text, + snooze_until text, + created_at text not null, + foreign key(finding_id) references review_findings(id) on delete cascade + ) + `); + db.run("create index if not exists idx_review_feedback_finding on review_finding_feedback(finding_id)"); + db.run("create index if not exists idx_review_feedback_project_created on review_finding_feedback(project_id, created_at desc)"); + + // Durable suppressions — Greptile-style learned filter. + db.run(` + create table if not exists review_suppressions ( + id text primary key, + project_id text not null, + scope text not null, + repo_key text, + path_pattern text, + title text not null, + title_norm text not null, + finding_class text, + severity text, + reason text, + note text, + embedding_json text, + source_finding_id text, + hit_count integer not null default 0, + created_at text not null, + last_matched_at text + ) + `); + db.run("create index if not exists idx_review_suppressions_project on review_suppressions(project_id, created_at desc)"); + db.run("create index if not exists idx_review_suppressions_repo on review_suppressions(project_id, repo_key)"); // PR convergence loop: issue inventory tracking db.run(` diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 20417091b..e4788d169 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -784,6 +784,15 @@ declare global { getRunDetail: (runId: string) => Promise<ReviewRunDetail | null>; startRun: (args: ReviewStartRunArgs) => Promise<ReviewRun>; rerun: (runId: string) => Promise<ReviewRun>; + cancelRun: (runId: string) => Promise<ReviewRun | null>; + recordFeedback: ( + args: import("../shared/types").ReviewRecordFeedbackArgs, + ) => Promise<import("../shared/types").ReviewFeedbackRecord>; + listSuppressions: ( + args?: import("../shared/types").ReviewListSuppressionsArgs, + ) => Promise<import("../shared/types").ReviewSuppression[]>; + deleteSuppression: (suppressionId: string) => Promise<boolean>; + qualityReport: () => Promise<import("../shared/types").ReviewQualityReport>; onEvent: (cb: (ev: ReviewEventPayload) => void) => () => void; }; actions: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 98b340f23..288508bd5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -916,6 +916,20 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.reviewStartRun, args), rerun: async (runId: string): Promise<ReviewRun> => ipcRenderer.invoke(IPC.reviewRerun, { runId }), + cancelRun: async (runId: string): Promise<ReviewRun | null> => + ipcRenderer.invoke(IPC.reviewCancelRun, { runId }), + recordFeedback: async ( + args: import("../shared/types").ReviewRecordFeedbackArgs, + ): Promise<import("../shared/types").ReviewFeedbackRecord> => + ipcRenderer.invoke(IPC.reviewRecordFeedback, args), + listSuppressions: async ( + args: import("../shared/types").ReviewListSuppressionsArgs = {}, + ): Promise<import("../shared/types").ReviewSuppression[]> => + ipcRenderer.invoke(IPC.reviewListSuppressions, args), + deleteSuppression: async (suppressionId: string): Promise<boolean> => + ipcRenderer.invoke(IPC.reviewDeleteSuppression, { suppressionId }), + qualityReport: async (): Promise<import("../shared/types").ReviewQualityReport> => + ipcRenderer.invoke(IPC.reviewQualityReport), onEvent: (cb: (ev: ReviewEventPayload) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: ReviewEventPayload) => cb(payload); ipcRenderer.on(IPC.reviewEvent, listener); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index f46e8ae2f..797d97514 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2240,6 +2240,36 @@ if (typeof window !== "undefined" && !(window as any).ade) { endedAt: null, updatedAt: now, }), + cancelRun: resolvedArg(null), + recordFeedback: resolvedArg({ + id: "rfb_mock", + findingId: "mock-finding", + runId: "review-run-1", + kind: "acknowledge" as const, + reason: null, + note: null, + snoozeUntil: null, + createdAt: now, + }), + listSuppressions: resolvedArg([]), + deleteSuppression: resolvedArg(true), + qualityReport: resolved({ + projectId: MOCK_PROJECT.id, + totalRuns: 3, + totalFindings: 14, + addressedCount: 6, + dismissedCount: 3, + snoozedCount: 1, + suppressedCount: 2, + publishedCount: 5, + noiseRate: 0.35, + recentFeedback: [], + byClass: [ + { findingClass: "intent_drift" as const, total: 4, addressed: 2 }, + { findingClass: "incomplete_rollout" as const, total: 5, addressed: 3 }, + { findingClass: "late_stage_regression" as const, total: 2, addressed: 1 }, + ], + }), onEvent: noop, }, actions: { diff --git a/apps/desktop/src/renderer/components/review/ReviewFindingCard.tsx b/apps/desktop/src/renderer/components/review/ReviewFindingCard.tsx new file mode 100644 index 000000000..636058ff4 --- /dev/null +++ b/apps/desktop/src/renderer/components/review/ReviewFindingCard.tsx @@ -0,0 +1,632 @@ +import React from "react"; +import { + ArrowSquareOut, + BellSimpleSlash, + CaretDown, + CaretRight, + CheckCircle, + FileText, + MagnifyingGlass, + Prohibit, + Shield, + ShieldCheck, + X, +} from "@phosphor-icons/react"; +import { Button } from "../ui/Button"; +import { Chip } from "../ui/Chip"; +import { cn } from "../ui/cn"; +import type { + ReviewDiffContext, + ReviewDismissReason, + ReviewEvidence, + ReviewFeedbackKind, + ReviewFeedbackRecord, + ReviewFinding, + ReviewFindingClass, + ReviewFindingSuppressionMatch, + ReviewSuppressionScope, +} from "./reviewTypes"; + +export type FindingActionRequest = { + finding: ReviewFinding; + kind: ReviewFeedbackKind; + reason?: ReviewDismissReason | null; + note?: string | null; + snoozeDurationMs?: number | null; + suppression?: { scope: ReviewSuppressionScope; pathPattern?: string | null } | null; +}; + +type ReviewFindingCardProps = { + finding: ReviewFinding; + onRequestAction: (request: FindingActionRequest) => Promise<void> | void; + onOpenInFiles?: (finding: ReviewFinding) => void; + onOpenInEditor?: (finding: ReviewFinding) => void; + disabled?: boolean; +}; + +const FINDING_CLASS_DESCRIPTION: Record<ReviewFindingClass, string> = { + intent_drift: "Implementation may diverge from the stated goal or prompt for this lane.", + incomplete_rollout: "Only part of a cross-surface change landed — check paired files.", + late_stage_regression: "A risky change appeared after a failed validation or late fix cycle.", +}; + +const PASS_LABEL: Record<string, string> = { + "diff-risk": "Diff risk", + "cross-file-impact": "Cross-file", + "checks-and-tests": "Tests + CI", +}; + +function toSeverityTone(severity: string): string { + const n = severity.toLowerCase(); + if (n.includes("crit")) return "border-red-400/30 bg-red-400/[0.12] text-red-200"; + if (n.includes("high")) return "border-orange-400/30 bg-orange-400/[0.12] text-orange-200"; + if (n.includes("medium")) return "border-amber-400/30 bg-amber-400/[0.12] text-amber-200"; + if (n.includes("low")) return "border-sky-400/30 bg-sky-400/[0.12] text-sky-200"; + return "border-zinc-400/25 bg-zinc-400/[0.10] text-zinc-200"; +} + +function toFindingClassTone(value: ReviewFindingClass | null | undefined): string { + if (value === "intent_drift") return "border-fuchsia-400/30 bg-fuchsia-400/[0.12] text-fuchsia-200"; + if (value === "incomplete_rollout") return "border-cyan-400/30 bg-cyan-400/[0.12] text-cyan-200"; + if (value === "late_stage_regression") return "border-rose-400/30 bg-rose-400/[0.12] text-rose-200"; + return "border-zinc-400/25 bg-zinc-400/[0.10] text-zinc-200"; +} + +function toFindingClassLabel(value: ReviewFindingClass | null | undefined): string { + if (!value) return "general"; + return value.replaceAll("_", " "); +} + +function formatConfidence(value: number | string): string { + if (typeof value === "number") { + if (value <= 1) return `${Math.round(value * 100)}%`; + return `${Math.round(value)}%`; + } + return value; +} + +function describeSuppression(match: ReviewFindingSuppressionMatch | null | undefined): string | null { + if (!match) return null; + const pct = Math.round((match.similarity ?? 0) * 100); + const reasonLabel = match.reason ? `(${match.reason.replaceAll("_", " ")})` : ""; + return `Filtered by ${match.scope} suppression · ${pct}% match ${reasonLabel}`.trim(); +} + +function describeFeedback(record: ReviewFeedbackRecord | null | undefined): { label: string; tone: string } | null { + if (!record) return null; + switch (record.kind) { + case "acknowledge": + return { label: "Acknowledged", tone: "border-emerald-400/30 bg-emerald-400/[0.10] text-emerald-200" }; + case "dismiss": + return { + label: `Dismissed${record.reason ? ` · ${record.reason.replaceAll("_", " ")}` : ""}`, + tone: "border-zinc-500/25 bg-zinc-500/[0.12] text-zinc-200", + }; + case "snooze": { + const until = record.snoozeUntil ? new Date(record.snoozeUntil).toLocaleDateString() : "later"; + return { label: `Snoozed until ${until}`, tone: "border-amber-400/30 bg-amber-400/[0.10] text-amber-200" }; + } + case "suppress": + return { + label: `Suppressed${record.reason ? ` · ${record.reason.replaceAll("_", " ")}` : ""}`, + tone: "border-violet-400/30 bg-violet-400/[0.10] text-violet-200", + }; + default: + return null; + } +} + +function DiffContextBlock({ context }: { context: ReviewDiffContext | null | undefined }) { + if (!context || context.lines.length === 0) { + return ( + <div className="rounded-lg border border-white/[0.06] bg-black/20 p-3 text-[11px] text-[#94A3B8]"> + No inline diff excerpt available for this finding. + </div> + ); + } + return ( + <div className="overflow-hidden rounded-lg border border-white/[0.08] bg-black/30"> + <div className="flex items-center justify-between border-b border-white/[0.06] bg-white/[0.02] px-3 py-1.5"> + <span className="truncate text-[11px] text-[#93A4B8]">{context.filePath}</span> + <span className="text-[10px] text-[#6E7F92]"> + L{context.startLine}–{context.endLine} + {context.anchoredLine ? ` · focus L${context.anchoredLine}` : ""} + </span> + </div> + <pre className="overflow-x-auto px-0 py-1 text-[11px] leading-[1.45]"> + {context.lines.map((line, idx) => { + const base = "flex gap-2 px-3"; + const lineColor = line.kind === "add" + ? "bg-emerald-400/[0.08] text-emerald-100" + : line.kind === "del" + ? "bg-red-400/[0.08] text-red-100" + : line.kind === "meta" + ? "bg-white/[0.02] text-[#6E7F92] italic" + : line.highlighted + ? "bg-amber-400/[0.12] text-amber-100" + : "text-[#CBD5E1]"; + const marker = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "meta" ? "@" : " "; + return ( + <span + key={`${line.line ?? "x"}-${idx}`} + className={cn(base, lineColor, line.highlighted ? "border-l-2 border-amber-400/70" : "border-l-2 border-transparent")} + > + <span className="w-10 shrink-0 text-right font-mono text-[#4B5B71]">{line.line ?? ""}</span> + <span className="w-3 shrink-0 font-mono text-[#6E7F92]">{marker}</span> + <span className="whitespace-pre-wrap font-mono text-[11px]">{line.text || " "}</span> + </span> + ); + })} + </pre> + </div> + ); +} + +function ToolSignalBlock({ evidence }: { evidence: ReviewEvidence[] }) { + const toolSignals = evidence.filter((entry) => entry.kind === "tool_signal" && entry.toolSignal); + if (toolSignals.length === 0) return null; + return ( + <div className="space-y-1.5"> + {toolSignals.map((entry, idx) => { + const sig = entry.toolSignal!; + const tone = sig.status === "fail" + ? "border-red-400/30 bg-red-400/[0.10] text-red-200" + : sig.status === "warn" + ? "border-amber-400/30 bg-amber-400/[0.10] text-amber-200" + : "border-sky-400/25 bg-sky-400/[0.08] text-sky-200"; + return ( + <div + key={`${sig.source}-${idx}`} + className={cn("rounded-lg border p-2 text-[11px]", tone)} + > + <div className="flex flex-wrap items-center gap-2"> + <span className="font-mono uppercase tracking-[0.08em]">{sig.kind.replaceAll("_", " ")}</span> + <span className="truncate">{entry.summary}</span> + </div> + {entry.quote ? ( + <pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed"> + {entry.quote} + </pre> + ) : null} + {sig.source && !entry.quote ? ( + <div className="mt-1 text-[10px] text-[#8FA1B8]">source · {sig.source}</div> + ) : null} + </div> + ); + })} + </div> + ); +} + +type DismissModalProps = { + open: boolean; + initialKind: Exclude<ReviewFeedbackKind, "acknowledge">; + finding: ReviewFinding; + onClose: () => void; + onSubmit: (args: { + kind: Exclude<ReviewFeedbackKind, "acknowledge">; + reason: ReviewDismissReason; + note: string; + snoozeDurationMs: number | null; + suppressionScope?: ReviewSuppressionScope; + }) => Promise<void> | void; +}; + +const DISMISS_REASONS: Array<{ value: ReviewDismissReason; label: string; hint: string }> = [ + { value: "not_a_bug", label: "Not a bug", hint: "The flagged behavior is intentional or already correct." }, + { value: "out_of_scope", label: "Out of scope", hint: "True but belongs in a different PR or project." }, + { value: "style_only", label: "Style only", hint: "Stylistic nit handled elsewhere (linter, formatter)." }, + { value: "duplicate", label: "Duplicate", hint: "Same issue already flagged by another finding or tool." }, + { value: "wont_fix", label: "Won't fix", hint: "Known limitation we are deliberately not addressing." }, + { value: "low_value_noise", label: "Low-value noise", hint: "Generic warning that adds cost but rarely catches bugs." }, + { value: "other", label: "Other (explain)", hint: "Free-form reason in the note field below." }, +]; + +function DismissModal({ open, initialKind, finding, onClose, onSubmit }: DismissModalProps) { + const [kind, setKind] = React.useState<Exclude<ReviewFeedbackKind, "acknowledge">>(initialKind); + const [reason, setReason] = React.useState<ReviewDismissReason>("low_value_noise"); + const [note, setNote] = React.useState(""); + const [snoozeDays, setSnoozeDays] = React.useState(7); + const [suppressionScope, setSuppressionScope] = React.useState<ReviewSuppressionScope>("repo"); + const [submitting, setSubmitting] = React.useState(false); + + React.useEffect(() => { + if (open) { + setKind(initialKind); + setReason("low_value_noise"); + setNote(""); + setSnoozeDays(7); + setSuppressionScope("repo"); + setSubmitting(false); + } + }, [open, initialKind]); + + if (!open) return null; + + const handleSubmit = async () => { + setSubmitting(true); + try { + await onSubmit({ + kind, + reason, + note: note.trim(), + snoozeDurationMs: kind === "snooze" ? snoozeDays * 24 * 60 * 60 * 1000 : null, + suppressionScope: kind === "suppress" ? suppressionScope : undefined, + }); + } finally { + setSubmitting(false); + } + }; + + const kindOptions: Array<{ value: Exclude<ReviewFeedbackKind, "acknowledge">; label: string; icon: React.ReactNode }> = [ + { value: "dismiss", label: "Dismiss this finding", icon: <X size={14} /> }, + { value: "snooze", label: "Snooze for a while", icon: <BellSimpleSlash size={14} /> }, + { value: "suppress", label: "Suppress similar findings", icon: <Shield size={14} /> }, + ]; + + return ( + <div + className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8 backdrop-blur-sm" + onClick={onClose} + > + <div + className="w-full max-w-lg overflow-hidden rounded-2xl border border-white/[0.08] bg-[#0B141F] shadow-2xl" + onClick={(e) => e.stopPropagation()} + > + <div className="flex items-center justify-between border-b border-white/[0.06] bg-white/[0.02] px-5 py-3"> + <div className="min-w-0"> + <div className="text-[10px] uppercase tracking-[0.18em] text-[#6E7F92]">Feedback</div> + <div className="truncate text-sm font-semibold text-[#F5FAFF]">{finding.title}</div> + </div> + <button type="button" onClick={onClose} className="rounded-md p-1 text-[#93A4B8] hover:bg-white/[0.06]"> + <X size={16} /> + </button> + </div> + <div className="space-y-4 p-5"> + <div className="grid grid-cols-3 gap-2"> + {kindOptions.map((opt) => ( + <button + key={opt.value} + type="button" + onClick={() => setKind(opt.value)} + className={cn( + "flex flex-col items-start gap-1 rounded-xl border px-3 py-2.5 text-left text-xs transition", + kind === opt.value + ? "border-sky-400/40 bg-sky-400/[0.10] text-[#F5FAFF]" + : "border-white/[0.08] bg-white/[0.02] text-[#B7C4D7] hover:border-white/[0.16]", + )} + > + <span className="flex items-center gap-1.5">{opt.icon}<span className="font-medium">{opt.label.split(" ")[0]}</span></span> + <span className="text-[10px] leading-tight text-[#93A4B8]">{opt.label}</span> + </button> + ))} + </div> + + {kind === "snooze" ? ( + <div> + <label className="text-[10px] uppercase tracking-[0.18em] text-[#6E7F92]">Hide for</label> + <div className="mt-1 flex items-center gap-2"> + <input + type="number" + min={1} + max={180} + value={snoozeDays} + onChange={(e) => setSnoozeDays(Math.max(1, Math.min(180, Number(e.target.value) || 1)))} + className="w-20 rounded-md border border-white/[0.08] bg-black/30 px-2 py-1 text-sm text-[#F5FAFF]" + /> + <span className="text-xs text-[#93A4B8]">days</span> + </div> + </div> + ) : null} + + {kind === "suppress" ? ( + <div> + <label className="text-[10px] uppercase tracking-[0.18em] text-[#6E7F92]">Suppression scope</label> + <div className="mt-1 grid grid-cols-3 gap-2"> + {(["repo", "path", "global"] as const).map((scope) => ( + <button + key={scope} + type="button" + onClick={() => setSuppressionScope(scope)} + className={cn( + "rounded-md border px-2 py-1.5 text-[11px] transition", + suppressionScope === scope + ? "border-violet-400/40 bg-violet-400/[0.10] text-[#F5FAFF]" + : "border-white/[0.08] bg-white/[0.02] text-[#B7C4D7] hover:border-white/[0.16]", + )} + > + {scope === "repo" ? "This repo" : scope === "path" ? (finding.filePath ? `Path (${finding.filePath.split("/").slice(-1)[0]})` : "Path") : "Global"} + </button> + ))} + </div> + <p className="mt-2 text-[10px] text-[#6E7F92]"> + Future runs skip findings semantically similar to this one within the chosen scope. You can remove suppressions later from the Learnings panel. + </p> + </div> + ) : null} + + <div> + <label className="text-[10px] uppercase tracking-[0.18em] text-[#6E7F92]">Reason</label> + <div className="mt-1 grid grid-cols-2 gap-1.5"> + {DISMISS_REASONS.map((opt) => ( + <button + key={opt.value} + type="button" + onClick={() => setReason(opt.value)} + title={opt.hint} + className={cn( + "rounded-md border px-2.5 py-1.5 text-left text-[11px] transition", + reason === opt.value + ? "border-sky-400/40 bg-sky-400/[0.10] text-[#F5FAFF]" + : "border-white/[0.06] bg-white/[0.02] text-[#93A4B8] hover:border-white/[0.16]", + )} + > + {opt.label} + </button> + ))} + </div> + </div> + + <div> + <label className="text-[10px] uppercase tracking-[0.18em] text-[#6E7F92]"> + Note (optional{reason === "other" ? ", required for \"Other\"" : ""}) + </label> + <textarea + value={note} + onChange={(e) => setNote(e.target.value)} + rows={2} + placeholder="Why is this finding wrong, noisy, or out of scope?" + className="mt-1 w-full resize-y rounded-md border border-white/[0.08] bg-black/30 px-2 py-1.5 text-xs text-[#F5FAFF] placeholder:text-[#4B5B71]" + /> + </div> + </div> + <div className="flex items-center justify-end gap-2 border-t border-white/[0.06] bg-white/[0.02] px-5 py-3"> + <Button variant="ghost" onClick={onClose} disabled={submitting}>Cancel</Button> + <Button + onClick={handleSubmit} + disabled={submitting || (reason === "other" && !note.trim())} + > + {submitting ? "Saving…" : kind === "suppress" ? "Suppress" : kind === "snooze" ? "Snooze" : "Dismiss"} + </Button> + </div> + </div> + </div> + ); +} + +export function ReviewFindingCard({ + finding, + onRequestAction, + onOpenInFiles, + onOpenInEditor, + disabled, +}: ReviewFindingCardProps) { + const [expanded, setExpanded] = React.useState(false); + const [modalKind, setModalKind] = React.useState<Exclude<ReviewFeedbackKind, "acknowledge"> | null>(null); + + const feedback = finding.feedback ?? null; + const feedbackBadge = describeFeedback(feedback); + const suppression = finding.suppressionMatch ?? null; + const isSuppressed = suppression != null; + const findingClass = finding.findingClass ?? null; + const nonToolEvidence = (finding.evidence ?? []).filter((entry) => entry.kind !== "tool_signal"); + const toolSignalCount = (finding.evidence ?? []).filter((entry) => entry.kind === "tool_signal").length; + + const handleAcknowledge = async () => { + await onRequestAction({ finding, kind: "acknowledge" }); + }; + + const handleModalSubmit = async (args: { + kind: Exclude<ReviewFeedbackKind, "acknowledge">; + reason: ReviewDismissReason; + note: string; + snoozeDurationMs: number | null; + suppressionScope?: ReviewSuppressionScope; + }) => { + await onRequestAction({ + finding, + kind: args.kind, + reason: args.reason, + note: args.note || null, + snoozeDurationMs: args.snoozeDurationMs, + suppression: args.kind === "suppress" && args.suppressionScope + ? { + scope: args.suppressionScope, + pathPattern: args.suppressionScope === "path" ? finding.filePath ?? null : null, + } + : null, + }); + setModalKind(null); + }; + + return ( + <article + className={cn( + "rounded-xl border p-3 transition", + isSuppressed + ? "border-violet-500/20 bg-violet-500/[0.04] opacity-75 hover:opacity-100" + : feedback?.kind === "acknowledge" + ? "border-emerald-400/25 bg-emerald-400/[0.04]" + : feedback?.kind === "dismiss" || feedback?.kind === "snooze" + ? "border-white/[0.06] bg-white/[0.02] opacity-70" + : "border-white/[0.08] bg-white/[0.04] hover:border-white/[0.14]", + )} + > + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-1.5"> + <Chip className={cn("text-[9px]", toSeverityTone(finding.severity))}>{finding.severity}</Chip> + {findingClass ? ( + <span + title={FINDING_CLASS_DESCRIPTION[findingClass]} + className={cn("rounded-full border px-1.5 py-0.5 text-[9px] uppercase tracking-[0.08em] cursor-help", toFindingClassTone(findingClass))} + > + {toFindingClassLabel(findingClass)} + </span> + ) : null} + {feedbackBadge ? ( + <Chip className={cn("text-[9px]", feedbackBadge.tone)}>{feedbackBadge.label}</Chip> + ) : null} + {isSuppressed ? ( + <Chip className="text-[9px] border-violet-400/30 bg-violet-400/[0.10] text-violet-200">filtered</Chip> + ) : null} + </div> + <div className="mt-1.5 text-sm font-semibold leading-snug text-[#F5FAFF]">{finding.title}</div> + <div className="mt-1 text-xs leading-relaxed text-[#93A4B8]">{finding.body}</div> + </div> + <div className="flex shrink-0 flex-col items-end gap-1 text-[10px] text-[#94A3B8]"> + <span>conf {formatConfidence(finding.confidence)}</span> + {finding.filePath ? ( + <span className="max-w-[200px] truncate font-mono text-[10px]" title={finding.filePath}> + {finding.filePath.split("/").slice(-2).join("/")}{finding.line ? `:${finding.line}` : ""} + </span> + ) : null} + </div> + </div> + + {isSuppressed ? ( + <div className="mt-2 rounded-lg border border-violet-400/20 bg-violet-400/[0.06] px-2.5 py-1.5 text-[11px] text-violet-100"> + {describeSuppression(suppression)} + </div> + ) : null} + + <div className="mt-3 flex flex-wrap items-center gap-1.5"> + {finding.originatingPasses?.map((pass) => ( + <Chip key={`${finding.id}-${pass}`} className="text-[9px]">{PASS_LABEL[pass] ?? pass}</Chip> + ))} + {toolSignalCount > 0 ? ( + <Chip className="text-[9px] border-emerald-400/25 bg-emerald-400/[0.08] text-emerald-200"> + <ShieldCheck size={10} /> tool-backed · {toolSignalCount} + </Chip> + ) : null} + {finding.adjudication ? ( + <Chip className="text-[9px]"> + {finding.adjudication.publicationEligible ? "publication eligible" : "local only"} + </Chip> + ) : null} + <button + type="button" + onClick={() => setExpanded((prev) => !prev)} + className="ml-auto inline-flex items-center gap-1 rounded-md border border-white/[0.08] bg-white/[0.02] px-2 py-1 text-[10px] text-[#B7C4D7] hover:bg-white/[0.06]" + > + {expanded ? <CaretDown size={10} /> : <CaretRight size={10} />} + {expanded ? "Hide details" : "Show details"} + </button> + </div> + + {expanded ? ( + <div className="mt-3 space-y-3 border-t border-white/[0.06] pt-3"> + {finding.diffContext ? ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-[10px] uppercase tracking-[0.12em] text-[#6E7F92]"> + <FileText size={10} /> inline diff + </div> + <DiffContextBlock context={finding.diffContext} /> + </div> + ) : null} + + {toolSignalCount > 0 ? ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-[10px] uppercase tracking-[0.12em] text-[#6E7F92]"> + <ShieldCheck size={10} /> tool-backed evidence + </div> + <ToolSignalBlock evidence={finding.evidence ?? []} /> + </div> + ) : null} + + {nonToolEvidence.length > 0 ? ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-[10px] uppercase tracking-[0.12em] text-[#6E7F92]"> + <MagnifyingGlass size={10} /> evidence trail + </div> + <div className="space-y-1.5"> + {nonToolEvidence.map((entry, idx) => ( + <div key={`${finding.id}-${idx}`} className="rounded-lg border border-white/[0.06] bg-black/20 p-2"> + <div className="flex flex-wrap items-center gap-2 text-[11px] text-[#8FA1B8]"> + <span className="font-mono uppercase tracking-[0.08em]">{entry.kind}</span> + {entry.summary ? <span className="text-[#CBD5E1]">{entry.summary}</span> : null} + {entry.filePath ? ( + <span className="font-mono text-[10px]">{entry.filePath}{entry.line ? `:${entry.line}` : ""}</span> + ) : null} + </div> + {entry.quote ? ( + <pre className="mt-1 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-[#D8E3F2]"> + {entry.quote} + </pre> + ) : null} + </div> + ))} + </div> + </div> + ) : null} + + {finding.adjudication?.rationale ? ( + <div className="rounded-lg border border-white/[0.06] bg-black/20 p-2 text-[11px] leading-relaxed text-[#B7C4D7]"> + <span className="text-[10px] uppercase tracking-[0.12em] text-[#6E7F92]">Adjudication — </span> + {finding.adjudication.rationale} + </div> + ) : null} + </div> + ) : null} + + <div className="mt-3 flex flex-wrap items-center gap-1.5"> + {finding.filePath && onOpenInFiles ? ( + <Button size="sm" variant="ghost" onClick={() => onOpenInFiles(finding)}> + <FileText size={12} /> Open in files + </Button> + ) : null} + {finding.filePath && onOpenInEditor ? ( + <Button size="sm" variant="ghost" onClick={() => onOpenInEditor(finding)}> + <ArrowSquareOut size={12} /> Open editor + </Button> + ) : null} + <div className="ml-auto flex flex-wrap items-center gap-1.5"> + <Button + size="sm" + variant="ghost" + onClick={handleAcknowledge} + disabled={disabled || feedback?.kind === "acknowledge"} + title="Mark this finding as useful. Strengthens future findings like it." + > + <CheckCircle size={12} /> Useful + </Button> + <Button + size="sm" + variant="ghost" + onClick={() => setModalKind("dismiss")} + disabled={disabled} + title="Dismiss with a reason." + > + <X size={12} /> Dismiss + </Button> + <Button + size="sm" + variant="ghost" + onClick={() => setModalKind("snooze")} + disabled={disabled} + title="Hide this class of finding for a while." + > + <BellSimpleSlash size={12} /> Snooze + </Button> + <Button + size="sm" + variant="ghost" + onClick={() => setModalKind("suppress")} + disabled={disabled} + title="Teach the engine to skip similar findings in the future." + > + <Prohibit size={12} /> Suppress + </Button> + </div> + </div> + + <DismissModal + open={modalKind != null} + initialKind={modalKind ?? "dismiss"} + finding={finding} + onClose={() => setModalKind(null)} + onSubmit={handleModalSubmit} + /> + </article> + ); +} diff --git a/apps/desktop/src/renderer/components/review/ReviewLearningsPanel.tsx b/apps/desktop/src/renderer/components/review/ReviewLearningsPanel.tsx new file mode 100644 index 000000000..e7f6f3b90 --- /dev/null +++ b/apps/desktop/src/renderer/components/review/ReviewLearningsPanel.tsx @@ -0,0 +1,267 @@ +import React from "react"; +import { + ArrowClockwise, + Brain, + ChartLine, + Shield, + Trash, + X, +} from "@phosphor-icons/react"; +import { Button } from "../ui/Button"; +import { Chip } from "../ui/Chip"; +import { cn } from "../ui/cn"; +import { EmptyState } from "../ui/EmptyState"; +import type { ReviewQualityReport, ReviewSuppression } from "./reviewTypes"; +import { + deleteReviewSuppression, + fetchReviewQualityReport, + listReviewSuppressions, + onReviewEvent, +} from "./reviewApi"; + +const SCOPE_LABEL: Record<ReviewSuppression["scope"], string> = { + repo: "Repo", + path: "Path", + global: "Global", +}; + +function toReasonLabel(reason: ReviewSuppression["reason"]): string { + if (!reason) return "—"; + return reason.replaceAll("_", " "); +} + +function relativeTime(value: string | null): string { + if (!value) return "—"; + const ts = Date.parse(value); + if (Number.isNaN(ts)) return value; + const diffMs = Date.now() - ts; + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(ts).toLocaleDateString(); +} + +function QualityMetric({ label, value, tone }: { label: string; value: string | number; tone?: string }) { + return ( + <div className={cn("rounded-xl border border-white/[0.06] bg-white/[0.03] px-3 py-2", tone)}> + <div className="text-[10px] uppercase tracking-[0.14em] text-[#6E7F92]">{label}</div> + <div className="mt-0.5 text-lg font-semibold text-[#F5FAFF]">{value}</div> + </div> + ); +} + +export function ReviewLearningsPanel({ + onClose, +}: { + onClose?: () => void; +}) { + const [suppressions, setSuppressions] = React.useState<ReviewSuppression[] | null>(null); + const [report, setReport] = React.useState<ReviewQualityReport | null>(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState<string | null>(null); + + const refresh = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const [list, quality] = await Promise.all([ + listReviewSuppressions({ limit: 100 }), + fetchReviewQualityReport(), + ]); + setSuppressions(list); + setReport(quality); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load learnings"); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void refresh(); + }, [refresh]); + + React.useEffect(() => { + const unsub = onReviewEvent((event) => { + if (event.type === "suppressions-updated" || event.type === "feedback-updated") { + void refresh(); + } + }); + return () => { + try { unsub(); } catch { /* ignore */ } + }; + }, [refresh]); + + const handleRemove = React.useCallback(async (suppressionId: string) => { + try { + await deleteReviewSuppression(suppressionId); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to remove suppression"); + } + }, [refresh]); + + const totalNoisy = (report?.dismissedCount ?? 0) + (report?.suppressedCount ?? 0); + const noisePct = report ? Math.round((report.noiseRate ?? 0) * 100) : 0; + + return ( + <section className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#08111C]"> + <header className="flex items-center justify-between border-b border-white/[0.06] bg-white/[0.02] px-4 py-3"> + <div className="flex items-center gap-2"> + <Brain size={18} className="text-violet-200" /> + <div> + <div className="text-[10px] uppercase tracking-[0.18em] text-[#6E7F92]">Review learnings</div> + <div className="text-sm font-semibold text-[#F5FAFF]">Suppressions & quality</div> + </div> + </div> + <div className="flex items-center gap-2"> + <Button size="sm" variant="ghost" onClick={() => void refresh()} disabled={loading}> + <ArrowClockwise size={12} /> + {loading ? "Refreshing…" : "Refresh"} + </Button> + {onClose ? ( + <button + type="button" + onClick={onClose} + className="rounded-md p-1.5 text-[#93A4B8] hover:bg-white/[0.06]" + aria-label="Close learnings panel" + > + <X size={16} /> + </button> + ) : null} + </div> + </header> + + {error ? ( + <div className="border-b border-red-500/20 bg-red-500/[0.08] px-4 py-2 text-xs text-red-200">{error}</div> + ) : null} + + <div className="flex-1 overflow-y-auto"> + <div className="space-y-5 p-4"> + <section className="space-y-2"> + <div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.14em] text-[#6E7F92]"> + <ChartLine size={11} /> quality over time + </div> + <div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> + <QualityMetric label="Runs" value={report?.totalRuns ?? "—"} /> + <QualityMetric label="Findings" value={report?.totalFindings ?? "—"} /> + <QualityMetric label="Addressed" value={report?.addressedCount ?? "—"} tone="border-emerald-400/20 bg-emerald-400/[0.05]" /> + <QualityMetric + label="Noise" + value={report ? `${noisePct}%` : "—"} + tone={noisePct > 40 ? "border-amber-400/30 bg-amber-400/[0.08]" : "border-white/[0.06] bg-white/[0.03]"} + /> + <QualityMetric label="Published" value={report?.publishedCount ?? "—"} /> + <QualityMetric label="Dismissed" value={report?.dismissedCount ?? "—"} /> + <QualityMetric label="Snoozed" value={report?.snoozedCount ?? "—"} /> + <QualityMetric label="Suppressed" value={report?.suppressedCount ?? "—"} tone="border-violet-400/20 bg-violet-400/[0.06]" /> + </div> + {report?.byClass.length ? ( + <div className="space-y-1.5 pt-2"> + <div className="text-[10px] uppercase tracking-[0.14em] text-[#6E7F92]">By finding class</div> + <div className="flex flex-wrap gap-1.5"> + {report.byClass.map((row) => { + const pct = row.total > 0 ? Math.round((row.addressed / row.total) * 100) : 0; + return ( + <Chip key={row.findingClass} className="text-[10px]"> + <span className="font-mono uppercase">{row.findingClass}</span> + <span className="text-[#93A4B8]">{row.total} · {pct}% addressed</span> + </Chip> + ); + })} + </div> + </div> + ) : null} + <p className="text-[10px] leading-relaxed text-[#6E7F92]"> + Noise = dismissed + suppressed over total findings. {totalNoisy > 0 && report?.totalFindings ? ( + <>That's {totalNoisy} of {report.totalFindings} findings the team didn't action.</> + ) : null} + </p> + </section> + + <section className="space-y-2"> + <div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.14em] text-[#6E7F92]"> + <Shield size={11} /> active suppressions {suppressions?.length ? `(${suppressions.length})` : ""} + </div> + {suppressions == null ? ( + <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-xs text-[#94A3B8]">Loading…</div> + ) : suppressions.length === 0 ? ( + <EmptyState + icon={Shield} + title="No suppressions yet" + description="Use the Suppress action on noisy findings to teach the engine. Suppressions persist across runs and are matched semantically via embeddings." + /> + ) : ( + <div className="space-y-1.5"> + {suppressions.map((sup) => ( + <div + key={sup.id} + className="flex items-start gap-3 rounded-xl border border-white/[0.06] bg-white/[0.03] p-3" + > + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-1.5"> + <Chip className="text-[9px] border-violet-400/25 bg-violet-400/[0.08] text-violet-200"> + {SCOPE_LABEL[sup.scope]} + </Chip> + {sup.findingClass ? ( + <Chip className="text-[9px]">{sup.findingClass.replaceAll("_", " ")}</Chip> + ) : null} + {sup.severity ? <Chip className="text-[9px]">{sup.severity}</Chip> : null} + <Chip className="text-[9px]">{toReasonLabel(sup.reason)}</Chip> + {sup.hitCount > 0 ? ( + <Chip className="text-[9px] border-emerald-400/25 bg-emerald-400/[0.08] text-emerald-200"> + filtered {sup.hitCount}× + </Chip> + ) : null} + </div> + <div className="mt-1 truncate text-sm font-medium text-[#F5FAFF]">{sup.title}</div> + {sup.pathPattern ? ( + <div className="mt-0.5 truncate font-mono text-[10px] text-[#93A4B8]">{sup.pathPattern}</div> + ) : null} + {sup.note ? ( + <div className="mt-1 text-xs leading-relaxed text-[#B7C4D7]">{sup.note}</div> + ) : null} + <div className="mt-1 text-[10px] text-[#6E7F92]"> + added {relativeTime(sup.createdAt)} + {sup.lastMatchedAt ? ` · last match ${relativeTime(sup.lastMatchedAt)}` : ""} + </div> + </div> + <button + type="button" + onClick={() => void handleRemove(sup.id)} + className="inline-flex items-center gap-1 rounded-md border border-white/[0.08] bg-white/[0.02] px-2 py-1 text-[10px] text-[#B7C4D7] hover:border-red-400/40 hover:bg-red-400/[0.08] hover:text-red-200" + > + <Trash size={10} /> remove + </button> + </div> + ))} + </div> + )} + </section> + + {report?.recentFeedback?.length ? ( + <section className="space-y-2"> + <div className="text-[10px] uppercase tracking-[0.14em] text-[#6E7F92]">Recent feedback</div> + <div className="space-y-1.5"> + {report.recentFeedback.slice(0, 8).map((fb) => ( + <div key={fb.id} className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-1.5 text-[11px] text-[#B7C4D7]"> + <div className="flex items-center gap-2"> + <span className="font-mono uppercase tracking-[0.1em] text-[#93A4B8]">{fb.kind}</span> + {fb.reason ? <Chip className="text-[9px]">{fb.reason.replaceAll("_", " ")}</Chip> : null} + <span className="ml-auto text-[10px] text-[#6E7F92]">{relativeTime(fb.createdAt)}</span> + </div> + {fb.note ? <div className="mt-0.5 text-[#CBD5E1]">{fb.note}</div> : null} + </div> + ))} + </div> + </section> + ) : null} + </div> + </div> + </section> + ); +} diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx index 529d9c6b1..e63daa9ae 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.test.tsx @@ -85,7 +85,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.5-codex", reasoningEffort: "medium", budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", @@ -115,7 +115,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "lane", laneId: "lane-review" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.5-codex", reasoningEffort: "high", budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", @@ -145,7 +145,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.5-codex", reasoningEffort: "medium", budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", @@ -325,7 +325,7 @@ describe("ReviewPage", () => { { sha: "def456abc1237890", shortSha: "def456a", subject: "Second commit", authoredAt: "2026-04-02T12:00:00.000Z", pushed: true }, ], }, - recommendedModelId: "openai/gpt-5.4-codex", + recommendedModelId: "openai/gpt-5.5-codex", })), listRuns: vi.fn(async () => runs), getRunDetail: vi.fn(async (runId: string) => details.get(runId) ?? null), @@ -438,7 +438,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.5-codex", reasoningEffort: "medium", publishBehavior: "local_only", }); @@ -475,7 +475,7 @@ describe("ReviewPage", () => { expect(config).toMatchObject({ selectionMode: "selected_commits", dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.5-codex", }); }); @@ -491,7 +491,7 @@ describe("ReviewPage", () => { { sha: "abc123def4567890", shortSha: "abc123d", subject: "Only commit", authoredAt: "2026-04-01T12:00:00.000Z", pushed: true }, ], }, - recommendedModelId: "openai/gpt-5.4-codex", + recommendedModelId: "openai/gpt-5.5-codex", }); render( @@ -528,7 +528,7 @@ describe("ReviewPage", () => { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: "openai/gpt-5.4-codex", + modelId: "openai/gpt-5.5-codex", reasoningEffort: "medium", budgets: { maxFiles: 25, maxDiffChars: 120000, maxPromptChars: 60000, maxFindings: 8, maxFindingsPerPass: 6, maxPublishedFindings: 6 }, publishBehavior: "local_only", diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.tsx index bae75b7c0..c84646edf 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.tsx @@ -23,13 +23,17 @@ import { PaneTilingLayout, type PaneConfig, type PaneSplit } from "../ui/PaneTil import { AgentChatPane } from "../chat/AgentChatPane"; import { ReviewLaunchModelControls } from "../shared/ReviewLaunchModelControls"; import { + cancelReviewRun, listReviewLaunchContext, listReviewRuns, getReviewRunDetail, onReviewEvent, + recordReviewFeedback, rerunReview, startReviewRun, } from "./reviewApi"; +import { ReviewFindingCard, type FindingActionRequest } from "./ReviewFindingCard"; +import { ReviewLearningsPanel } from "./ReviewLearningsPanel"; import type { ReviewArtifact, ReviewEvidenceEntry, @@ -707,8 +711,10 @@ export function ReviewPage() { React.useEffect(() => { const unsub = onReviewEvent((event) => { + if (event.type === "suppressions-updated") return; void refreshRuns(); - if (event.runId === selectedRunId) { + const eventRunId = "runId" in event ? event.runId : undefined; + if (eventRunId === selectedRunId) { void loadDetail(selectedRunId); } }); @@ -989,6 +995,43 @@ export function ReviewPage() { void (appBridge?.openPathInEditor?.({ rootPath: resolved.rootPath, target: resolved.target }) ?? Promise.resolve()).catch(() => {}); }, [resolveFindingTarget]); + const [showLearnings, setShowLearnings] = React.useState(false); + const [severityFilter, setSeverityFilter] = React.useState<"all" | "critical" | "high" | "medium" | "low" | "info">("all"); + const [showSuppressed, setShowSuppressed] = React.useState(false); + const [feedbackError, setFeedbackError] = React.useState<string | null>(null); + const [cancelInFlight, setCancelInFlight] = React.useState(false); + + const handleFindingAction = React.useCallback(async (req: FindingActionRequest) => { + setFeedbackError(null); + try { + await recordReviewFeedback({ + findingId: req.finding.id, + kind: req.kind, + reason: req.reason ?? null, + note: req.note ?? null, + snoozeDurationMs: req.snoozeDurationMs ?? null, + suppression: req.suppression ?? null, + }); + if (selectedRunId) await loadDetail(selectedRunId); + } catch (err) { + setFeedbackError(err instanceof Error ? err.message : String(err)); + } + }, [loadDetail, selectedRunId]); + + const handleCancelRun = React.useCallback(async (run: NormalizedRun) => { + if (run.status !== "running" && run.status !== "queued") return; + setCancelInFlight(true); + try { + await cancelReviewRun(run.id); + await refreshRuns(); + if (selectedRunId === run.id) await loadDetail(run.id); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setCancelInFlight(false); + } + }, [loadDetail, refreshRuns, selectedRunId]); + const launchPane = ( <div className="flex h-full min-h-0 flex-col gap-3 overflow-hidden"> <SectionCard @@ -1247,10 +1290,16 @@ export function ReviewPage() { title="Review runs" icon={ClockCounterClockwise} action={( - <Button size="sm" variant="ghost" onClick={() => void refreshRuns()} disabled={loadingRuns}> - <ArrowsClockwise size={12} weight="regular" className={cn(loadingRuns && "animate-spin")} /> - Refresh - </Button> + <div className="flex items-center gap-1.5"> + <Button size="sm" variant="ghost" onClick={() => setShowLearnings((prev) => !prev)}> + <GitBranch size={12} /> + {showLearnings ? "Hide learnings" : "Learnings"} + </Button> + <Button size="sm" variant="ghost" onClick={() => void refreshRuns()} disabled={loadingRuns}> + <ArrowsClockwise size={12} weight="regular" className={cn(loadingRuns && "animate-spin")} /> + Refresh + </Button> + </div> )} > <div className="space-y-2"> @@ -1300,7 +1349,11 @@ export function ReviewPage() { </div> ); - const detailPane = ( + const detailPane = showLearnings ? ( + <div className="flex h-full min-h-0 flex-col overflow-hidden p-5"> + <ReviewLearningsPanel onClose={() => setShowLearnings(false)} /> + </div> + ) : ( <div className="flex h-full min-h-0 flex-col overflow-hidden"> {selectedRun ? ( <div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto px-5 py-5"> @@ -1526,82 +1579,112 @@ export function ReviewPage() { ) : null} <SectionCard title={`Findings (${selectedRun.findingCount})`} icon={MagnifyingGlass}> - <div className="space-y-2"> - {selectedDetail?.findings?.length ? selectedDetail.findings.map((finding, index) => { - const evidence = normalizeEvidence(finding.evidence); - const findingClass = (finding as { findingClass?: string | null }).findingClass ?? null; - return ( - <article key={finding.id ?? `${finding.title}-${index}`} className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3"> - <div className="flex items-start justify-between gap-2"> - <div className="min-w-0"> - <div className="flex flex-wrap items-center gap-2"> - <Chip className={cn("text-[9px]", toSeverityTone(finding.severity))}>{finding.severity}</Chip> - {findingClass ? ( - <Chip className={cn("text-[9px]", toFindingClassTone(findingClass))}> - {toFindingClassLabel(findingClass)} - </Chip> - ) : null} - <div className="truncate text-sm font-semibold text-[#F5FAFF]">{finding.title}</div> - </div> - <div className="mt-1 text-xs text-[#93A4B8]">{finding.body}</div> - </div> - <div className="flex flex-col items-end gap-1 text-[11px] text-[#94A3B8]"> - <span>confidence {formatConfidence(finding.confidence)}</span> - <span>{finding.anchorState} · {finding.sourcePass}</span> - </div> + {(() => { + const rawFindings = selectedDetail?.findings ?? []; + const suppressedCount = rawFindings.filter((f) => f.suppressionMatch != null).length; + const severityMatches = severityFilter === "all" + ? rawFindings + : rawFindings.filter((f) => f.severity === severityFilter); + const visible = severityMatches.filter((f) => showSuppressed || f.suppressionMatch == null); + return ( + <> + {feedbackError ? ( + <div className="mb-3 rounded-lg border border-red-500/30 bg-red-500/[0.08] px-3 py-2 text-xs text-red-200"> + {feedbackError} </div> - <div className="mt-3 flex flex-wrap items-center gap-1.5"> - <Chip className="text-[9px]">{finding.publicationState}</Chip> - {finding.originatingPasses?.map((pass) => ( - <Chip key={`${finding.id}-${pass}`} className="text-[9px]">{toPassLabel(pass)}</Chip> - ))} - {finding.adjudication ? ( - <Chip className="text-[9px]"> - {finding.adjudication.publicationEligible ? "publication eligible" : "local only"} - </Chip> - ) : null} - {finding.filePath ? <Chip className="text-[9px]">{finding.filePath}{finding.line ? `:${finding.line}` : ""}</Chip> : null} - {finding.filePath ? ( - <Button size="sm" variant="ghost" onClick={() => handleOpenFindingInFiles(finding)}> - <FileText size={12} /> - Open in files - </Button> - ) : null} - {finding.filePath ? ( - <Button size="sm" variant="ghost" onClick={() => handleOpenFindingInEditor(finding)}> - <ArrowSquareOut size={12} /> - Open editor - </Button> + ) : null} + {selectedRun.status === "running" || selectedRun.status === "queued" ? ( + <div className="mb-3 flex items-center justify-between gap-2 rounded-lg border border-amber-400/20 bg-amber-400/[0.06] px-3 py-2 text-[11px] text-amber-100"> + <span>Review {selectedRun.status === "queued" ? "queued" : "running"}. Findings appear as passes complete.</span> + <Button + size="sm" + variant="ghost" + onClick={() => void handleCancelRun(selectedRun)} + disabled={cancelInFlight} + > + {cancelInFlight ? "Cancelling…" : "Cancel run"} + </Button> + </div> + ) : null} + {selectedRun.status === "failed" ? ( + <div className="mb-3 flex items-center justify-between gap-2 rounded-lg border border-red-400/30 bg-red-400/[0.06] px-3 py-2 text-[11px] text-red-200"> + <span>{selectedRun.errorMessage ?? "Review run failed."}</span> + <Button size="sm" variant="ghost" onClick={() => void handleRerun(selectedRun)}> + Retry + </Button> + </div> + ) : null} + {rawFindings.length > 0 ? ( + <div className="mb-3 flex flex-wrap items-center gap-1.5 text-[10px]"> + <span className="text-[#6E7F92] uppercase tracking-[0.14em]">Severity:</span> + {(["all", "critical", "high", "medium", "low", "info"] as const).map((sev) => { + const count = sev === "all" ? rawFindings.length : rawFindings.filter((f) => f.severity === sev).length; + if (sev !== "all" && count === 0) return null; + return ( + <button + key={sev} + type="button" + onClick={() => setSeverityFilter(sev)} + className={cn( + "rounded-full border px-2 py-0.5 font-medium transition", + severityFilter === sev + ? "border-sky-400/40 bg-sky-400/[0.10] text-sky-100" + : "border-white/[0.08] bg-white/[0.02] text-[#93A4B8] hover:border-white/[0.16]", + )} + > + {sev} <span className="text-[#6E7F92]">{count}</span> + </button> + ); + })} + {suppressedCount > 0 ? ( + <label className="ml-auto inline-flex items-center gap-1.5 text-[10px] text-[#93A4B8]"> + <input + type="checkbox" + checked={showSuppressed} + onChange={(e) => setShowSuppressed(e.target.checked)} + className="h-3 w-3 accent-violet-400" + /> + Show {suppressedCount} filtered + </label> ) : null} </div> - {finding.adjudication ? ( - <div className="mt-3 rounded-lg border border-white/[0.06] bg-black/20 p-2 text-[11px] text-[#B7C4D7]"> - {finding.adjudication.rationale} + ) : null} + <div className="space-y-2"> + {visible.length > 0 ? visible.map((finding, index) => ( + <ReviewFindingCard + key={finding.id ?? `${finding.title}-${index}`} + finding={finding} + onRequestAction={handleFindingAction} + onOpenInFiles={finding.filePath ? handleOpenFindingInFiles : undefined} + onOpenInEditor={finding.filePath ? handleOpenFindingInEditor : undefined} + /> + )) : rawFindings.length > 0 ? ( + <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-xs text-[#94A3B8]"> + No findings match the current filters. {!showSuppressed && suppressedCount > 0 ? ( + <button + type="button" + onClick={() => setShowSuppressed(true)} + className="ml-1 text-sky-300 hover:text-sky-200 underline underline-offset-2" + > + Show {suppressedCount} filtered findings + </button> + ) : null} </div> - ) : null} - {evidence.length > 0 ? ( - <div className="mt-3 space-y-2"> - {evidence.map((entry, evidenceIndex) => ( - <div key={`${finding.id ?? finding.title}-${evidenceIndex}`} className="rounded-lg border border-white/[0.06] bg-black/20 p-2"> - <div className="flex flex-wrap items-center gap-2 text-[11px] text-[#8FA1B8]"> - <span className="font-mono uppercase tracking-[1px]">{entry.kind ?? "evidence"}</span> - {entry.summary ? <span>{entry.summary}</span> : null} - {entry.filePath ? <span>{entry.filePath}{entry.line ? `:${entry.line}` : ""}</span> : null} - {entry.artifactId ? <span>{entry.artifactId}</span> : null} - </div> - {entry.quote ? <pre className="mt-1 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-[#D8E3F2]">{entry.quote}</pre> : null} - </div> - ))} + ) : selectedRun.status === "completed" ? ( + <EmptyState + icon={MagnifyingGlass} + title="No findings" + description="The review passes found nothing actionable in this diff. That could mean the diff was clean or the target was too small to review." + /> + ) : ( + <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-xs text-[#94A3B8]"> + Findings will appear here once the review completes. </div> - ) : null} - </article> - ); - }) : ( - <div className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3 text-xs text-[#94A3B8]"> - No findings were saved for this run. - </div> - )} - </div> + )} + </div> + </> + ); + })()} </SectionCard> <SectionCard title="Artifacts" icon={FileText}> diff --git a/apps/desktop/src/renderer/components/review/reviewApi.ts b/apps/desktop/src/renderer/components/review/reviewApi.ts index b2044127d..4b23c7115 100644 --- a/apps/desktop/src/renderer/components/review/reviewApi.ts +++ b/apps/desktop/src/renderer/components/review/reviewApi.ts @@ -1,10 +1,15 @@ import type { ReviewEventPayload, + ReviewFeedbackRecord, ReviewLaunchContext, ReviewListRunsArgs, + ReviewListSuppressionsArgs, + ReviewQualityReport, + ReviewRecordFeedbackArgs, ReviewRun, ReviewRunDetail, ReviewStartRunArgs, + ReviewSuppression, } from "./reviewTypes"; type ReviewBridge = { @@ -12,6 +17,11 @@ type ReviewBridge = { getRunDetail: (runId: string) => Promise<ReviewRunDetail | null>; startRun: (args: ReviewStartRunArgs) => Promise<{ runId?: string; id?: string } | ReviewRunDetail | string | null>; rerun: (runId: string) => Promise<{ runId?: string; id?: string } | ReviewRunDetail | string | null>; + cancelRun?: (runId: string) => Promise<ReviewRun | null>; + recordFeedback?: (args: ReviewRecordFeedbackArgs) => Promise<ReviewFeedbackRecord>; + listSuppressions?: (args?: ReviewListSuppressionsArgs) => Promise<ReviewSuppression[]>; + deleteSuppression?: (suppressionId: string) => Promise<boolean>; + qualityReport?: () => Promise<ReviewQualityReport>; listLaunchContext: () => Promise<ReviewLaunchContext | null>; onEvent: (listener: (event: ReviewEventPayload) => void) => () => void; }; @@ -68,3 +78,37 @@ export function onReviewEvent(listener: (event: ReviewEventPayload) => void): () if (!bridge) return () => {}; return bridge.onEvent(listener); } + +export async function cancelReviewRun(runId: string): Promise<ReviewRun | null> { + const bridge = getReviewBridge(); + if (!bridge?.cancelRun) return null; + return bridge.cancelRun(runId); +} + +export async function recordReviewFeedback( + args: ReviewRecordFeedbackArgs, +): Promise<ReviewFeedbackRecord | null> { + const bridge = getReviewBridge(); + if (!bridge?.recordFeedback) return null; + return bridge.recordFeedback(args); +} + +export async function listReviewSuppressions( + args?: ReviewListSuppressionsArgs, +): Promise<ReviewSuppression[]> { + const bridge = getReviewBridge(); + if (!bridge?.listSuppressions) return []; + return bridge.listSuppressions(args); +} + +export async function deleteReviewSuppression(suppressionId: string): Promise<boolean> { + const bridge = getReviewBridge(); + if (!bridge?.deleteSuppression) return false; + return bridge.deleteSuppression(suppressionId); +} + +export async function fetchReviewQualityReport(): Promise<ReviewQualityReport | null> { + const bridge = getReviewBridge(); + if (!bridge?.qualityReport) return null; + return bridge.qualityReport(); +} diff --git a/apps/desktop/src/renderer/components/review/reviewTypes.ts b/apps/desktop/src/renderer/components/review/reviewTypes.ts index f16c89afb..e9e69fa40 100644 --- a/apps/desktop/src/renderer/components/review/reviewTypes.ts +++ b/apps/desktop/src/renderer/components/review/reviewTypes.ts @@ -4,15 +4,21 @@ export type { ReviewAnchorState, ReviewArtifactType, ReviewCompareAgainstTarget, + ReviewDiffContext, + ReviewDismissReason, ReviewEvidence, ReviewEventPayload, + ReviewFeedbackKind, + ReviewFeedbackRecord, ReviewFinding, ReviewFindingAdjudication, ReviewFindingClass, + ReviewFindingSuppressionMatch, ReviewLaunchCommit, ReviewLaunchContext, ReviewLaunchLane, ReviewListRunsArgs, + ReviewListSuppressionsArgs, ReviewPassKey, ReviewPublication, ReviewPublicationDestination, @@ -20,6 +26,8 @@ export type { ReviewPublicationState, ReviewPublicationStatus, ReviewPublishBehavior, + ReviewQualityReport, + ReviewRecordFeedbackArgs, ReviewResolvedCompareTarget, ReviewRun, ReviewRunArtifact, @@ -32,8 +40,11 @@ export type { ReviewSeveritySummary, ReviewSourcePass, ReviewStartRunArgs, + ReviewSuppression, + ReviewSuppressionScope, ReviewTarget, ReviewTargetMode, + ReviewToolSignalKind, } from "../../../shared/types"; export type ReviewCompareKind = "default_branch" | "lane"; diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 822423e44..e0d66da49 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -257,7 +257,12 @@ export const IPC = { reviewGetRunDetail: "ade.review.getRunDetail", reviewStartRun: "ade.review.startRun", reviewRerun: "ade.review.rerun", + reviewCancelRun: "ade.review.cancelRun", reviewListLaunchContext: "ade.review.listLaunchContext", + reviewRecordFeedback: "ade.review.recordFeedback", + reviewListSuppressions: "ade.review.listSuppressions", + reviewDeleteSuppression: "ade.review.deleteSuppression", + reviewQualityReport: "ade.review.qualityReport", reviewEvent: "ade.review.event", adeActionsListRegistry: "ade.actions.listRegistry", missionsList: "ade.missions.list", diff --git a/apps/desktop/src/shared/types/review.ts b/apps/desktop/src/shared/types/review.ts index 90874d023..d015e6837 100644 --- a/apps/desktop/src/shared/types/review.ts +++ b/apps/desktop/src/shared/types/review.ts @@ -25,12 +25,65 @@ export type ReviewArtifactType = | "provenance_brief" | "rule_overlays" | "validation_signals" + | "tool_evidence" | "diff_bundle" | "review_output" | "untracked_snapshot" | "publication_request" | "publication_result"; +// --------------------------------------------------------------------------- +// Feedback + Suppression (learning loop) +// --------------------------------------------------------------------------- + +export type ReviewFeedbackKind = "acknowledge" | "dismiss" | "snooze" | "suppress"; + +export type ReviewDismissReason = + | "not_a_bug" + | "out_of_scope" + | "style_only" + | "duplicate" + | "wont_fix" + | "low_value_noise" + | "other"; + +export type ReviewSuppressionScope = "repo" | "path" | "global"; + +export type ReviewFeedbackRecord = { + id: string; + findingId: string; + runId: string; + kind: ReviewFeedbackKind; + reason: ReviewDismissReason | null; + note: string | null; + snoozeUntil: string | null; + createdAt: string; +}; + +export type ReviewSuppression = { + id: string; + scope: ReviewSuppressionScope; + repoKey: string | null; + pathPattern: string | null; + title: string; + findingClass: ReviewFindingClass | null; + severity: ReviewSeverity | null; + reason: ReviewDismissReason | null; + note: string | null; + embedding: number[] | null; + sourceFindingId: string | null; + hitCount: number; + createdAt: string; + lastMatchedAt: string | null; +}; + +export type ReviewFindingSuppressionMatch = { + suppressionId: string; + similarity: number; + reason: ReviewDismissReason | null; + scope: ReviewSuppressionScope; +}; + export type ReviewPublicationDestination = | { kind: "github_pr_review"; @@ -123,13 +176,28 @@ export type ReviewTarget = prId: string; }; +export type ReviewEvidenceKind = + | "quote" + | "diff_hunk" + | "artifact" + | "file_snapshot" + | "tool_signal"; + +export type ReviewToolSignalKind = "typecheck" | "test" | "lint" | "build" | "ci_check" | "validation"; + export type ReviewEvidence = { - kind: "quote" | "diff_hunk" | "artifact" | "file_snapshot"; + kind: ReviewEvidenceKind; summary: string; filePath: string | null; line: number | null; quote: string | null; artifactId: string | null; + toolSignal?: { + kind: ReviewToolSignalKind; + source: string; + status: "pass" | "fail" | "warn" | "info"; + detail: string | null; + } | null; }; export type ReviewFindingAdjudication = { @@ -156,6 +224,22 @@ export type ReviewFinding = { publicationState: ReviewPublicationState; originatingPasses?: ReviewPassKey[]; adjudication?: ReviewFindingAdjudication | null; + feedback?: ReviewFeedbackRecord | null; + suppressionMatch?: ReviewFindingSuppressionMatch | null; + diffContext?: ReviewDiffContext | null; +}; + +export type ReviewDiffContext = { + filePath: string; + startLine: number; + endLine: number; + anchoredLine: number | null; + lines: Array<{ + line: number | null; + kind: "context" | "add" | "del" | "meta"; + text: string; + highlighted: boolean; + }>; }; export type ReviewSeveritySummary = { @@ -257,4 +341,52 @@ export type ReviewEventPayload = runId: string; laneId: string; status: ReviewRunStatus; + } + | { + type: "feedback-updated"; + findingId: string; + runId: string; + } + | { + type: "suppressions-updated"; }; + +export type ReviewRecordFeedbackArgs = { + findingId: string; + kind: ReviewFeedbackKind; + reason?: ReviewDismissReason | null; + note?: string | null; + snoozeDurationMs?: number | null; + suppression?: { + scope: ReviewSuppressionScope; + pathPattern?: string | null; + } | null; +}; + +export type ReviewListSuppressionsArgs = { + limit?: number | null; + scope?: ReviewSuppressionScope | null; + projectId?: string | null; +}; + +export type ReviewDeleteSuppressionArgs = { + suppressionId: string; +}; + +export type ReviewCancelRunArgs = { + runId: string; +}; + +export type ReviewQualityReport = { + projectId: string; + totalRuns: number; + totalFindings: number; + addressedCount: number; + dismissedCount: number; + snoozedCount: number; + suppressedCount: number; + publishedCount: number; + noiseRate: number; + recentFeedback: ReviewFeedbackRecord[]; + byClass: Array<{ findingClass: ReviewFindingClass | "uncategorized"; total: number; addressed: number }>; +}; From 77285520a6d2c69cb6adcc9d13df95a7a25cb525 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:46:08 -0400 Subject: [PATCH 09/16] Audit fixes: cancel race, noisy tool evidence, anchor-outside-hunk - executeRun: guard against cancellation before the first pass and re-check after publishRun; clear both activeRuns and cancelledRuns in the finally so stale entries can't leak into a rerun. - recordFeedback (suppress): if the user picks "path" scope but the finding has no file path and no explicit pattern, fall back to "repo" so we never insert a suppression that can never match. - reviewToolEvidence: unrelated failing CI checks and test runs were being attached to every finding; tightened the gate to require title/body token overlap, and scaled the token threshold so single-word summaries like "typecheck" still qualify. - reviewDiffContext: when the anchored line falls outside every hunk, show the first hunk unsliced instead of filtering against an empty window (previous behavior left just the hunk header on screen). - New test covering the anchor-outside-hunk path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../services/review/reviewDiffContext.test.ts | 12 +++++++ .../main/services/review/reviewDiffContext.ts | 8 +++-- .../src/main/services/review/reviewService.ts | 34 ++++++++++++++++--- .../services/review/reviewToolEvidence.ts | 7 ++-- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/services/review/reviewDiffContext.test.ts b/apps/desktop/src/main/services/review/reviewDiffContext.test.ts index f95f8f9ab..69d39a48b 100644 --- a/apps/desktop/src/main/services/review/reviewDiffContext.test.ts +++ b/apps/desktop/src/main/services/review/reviewDiffContext.test.ts @@ -44,6 +44,18 @@ describe("buildDiffContextForFinding", () => { expect(result!.lines.some((line) => line.kind === "add")).toBe(true); }); + it("shows full first hunk (no highlight) when anchor falls outside every hunk", () => { + const result = buildDiffContextForFinding({ + filePath: "src/auth.ts", + anchoredLine: 5000, + patches: [{ filePath: "src/auth.ts", excerpt: SAMPLE_PATCH }], + }); + expect(result).not.toBeNull(); + expect(result!.anchoredLine).toBeNull(); + expect(result!.lines.some((line) => line.highlighted)).toBe(false); + expect(result!.lines.some((line) => line.kind === "add" || line.kind === "context")).toBe(true); + }); + it("falls back to the first hunk when no anchor is provided", () => { const result = buildDiffContextForFinding({ filePath: "src/auth.ts", diff --git a/apps/desktop/src/main/services/review/reviewDiffContext.ts b/apps/desktop/src/main/services/review/reviewDiffContext.ts index 809494090..8694e08ed 100644 --- a/apps/desktop/src/main/services/review/reviewDiffContext.ts +++ b/apps/desktop/src/main/services/review/reviewDiffContext.ts @@ -96,15 +96,19 @@ export function buildDiffContextForFinding(args: { if (hunks.length === 0) return null; let chosen: ParsedHunk | null = null; + let anchorInHunk = false; if (args.anchoredLine != null) { chosen = hunks.find((hunk) => args.anchoredLine! >= hunk.newStart && args.anchoredLine! < hunk.newStart + hunk.newLength) ?? null; + anchorInHunk = chosen != null; } if (!chosen) chosen = hunks[0] ?? null; if (!chosen) return null; - const sliced = args.anchoredLine != null ? sliceAroundAnchor(chosen, args.anchoredLine) : chosen; + const sliced = anchorInHunk && args.anchoredLine != null + ? sliceAroundAnchor(chosen, args.anchoredLine) + : chosen; const lineNumbers = sliced.lines.filter((entry) => entry.line != null).map((entry) => entry.line!); const startLine = lineNumbers.length ? Math.min(...lineNumbers) : chosen.newStart; const endLine = lineNumbers.length ? Math.max(...lineNumbers) : chosen.newStart + chosen.newLength - 1; @@ -113,7 +117,7 @@ export function buildDiffContextForFinding(args: { filePath: args.filePath, startLine, endLine, - anchoredLine: args.anchoredLine, + anchoredLine: anchorInHunk ? args.anchoredLine : null, lines: sliced.lines, }; } diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index 93c463f45..3fbcac12f 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -1763,6 +1763,20 @@ export function createReviewService({ if (!row) return; const run = mapRunRow(row); if (disposed) return; + if (cancelledRuns.has(runId)) { + cancelledRuns.delete(runId); + const endedAt = nowIso(); + updateRun(runId, { + status: "cancelled", + summary: "Run cancelled before execution began.", + error_message: null, + ended_at: endedAt, + updated_at: endedAt, + }); + emit({ type: "run-completed", runId, laneId: run.laneId, status: "cancelled" }); + emit({ type: "runs-updated", runId, laneId: run.laneId, status: "cancelled" }); + return; + } updateRun(runId, { status: "running", updated_at: nowIso(), @@ -2062,8 +2076,10 @@ export function createReviewService({ if (disposed) return; const severitySummary = tallySeveritySummary(findings); const endedAt = nowIso(); + const cancelledDuringPublish = cancelledRuns.has(runId); + if (cancelledDuringPublish) cancelledRuns.delete(runId); updateRun(runId, { - status: "completed", + status: cancelledDuringPublish ? "cancelled" : "completed", summary: adjudication.summary, error_message: null, finding_count: findings.length, @@ -2071,8 +2087,9 @@ export function createReviewService({ ended_at: endedAt, updated_at: endedAt, }); - emit({ type: "run-completed", runId, laneId: run.laneId, status: "completed" }); - emit({ type: "runs-updated", runId, laneId: run.laneId, status: "completed" }); + const finalStatus = cancelledDuringPublish ? "cancelled" : "completed"; + emit({ type: "run-completed", runId, laneId: run.laneId, status: finalStatus }); + emit({ type: "runs-updated", runId, laneId: run.laneId, status: finalStatus }); } catch (error) { if (disposed) return; const endedAt = nowIso(); @@ -2097,6 +2114,7 @@ export function createReviewService({ emit({ type: "runs-updated", runId, laneId: row?.lane_id ?? "", status: "failed" }); } finally { activeRuns.delete(runId); + cancelledRuns.delete(runId); } } @@ -2288,8 +2306,14 @@ export function createReviewService({ ); if (args.kind === "suppress") { - const scope: ReviewSuppressionScope = args.suppression?.scope ?? "repo"; - const pathPattern = args.suppression?.pathPattern ?? (scope === "path" ? findingRow.file_path : null); + const requestedScope: ReviewSuppressionScope = args.suppression?.scope ?? "repo"; + const requestedPathPattern = args.suppression?.pathPattern + ?? (requestedScope === "path" ? findingRow.file_path : null); + const hasUsablePathPattern = typeof requestedPathPattern === "string" && requestedPathPattern.trim().length > 0; + const scope: ReviewSuppressionScope = requestedScope === "path" && !hasUsablePathPattern + ? "repo" + : requestedScope; + const pathPattern = scope === "path" ? requestedPathPattern : null; const finding = mapFindingRow(findingRow); const publicationRow = db.get<ReviewRunPublicationRow>( "select * from review_run_publications where run_id = ? order by created_at desc limit 1", diff --git a/apps/desktop/src/main/services/review/reviewToolEvidence.ts b/apps/desktop/src/main/services/review/reviewToolEvidence.ts index 518d95cd1..016f1265d 100644 --- a/apps/desktop/src/main/services/review/reviewToolEvidence.ts +++ b/apps/desktop/src/main/services/review/reviewToolEvidence.ts @@ -58,10 +58,11 @@ function titleMatchesSignal(findingTitle: string, findingBody: string, summary: .split(/[^a-z0-9]+/) .filter((token) => token.length >= 4); if (tokens.length === 0) return false; + const required = Math.min(2, tokens.length); let hits = 0; for (const token of tokens) { if (haystack.includes(token)) hits += 1; - if (hits >= 2) return true; + if (hits >= required) return true; } return false; } @@ -102,7 +103,7 @@ export function buildToolBackedEvidence(args: { const kind = classifyCheck(check.name); const detail = `${check.name} (${check.status}${check.conclusion ? ` / ${check.conclusion}` : ""})`; const matchesTitle = titleMatchesSignal(args.finding.title, args.finding.body, check.name); - if (!matchesTitle && out.length > 0) continue; + if (!matchesTitle) continue; out.push({ kind: "tool_signal", summary: `CI check ${detail}`, @@ -125,7 +126,7 @@ export function buildToolBackedEvidence(args: { const hitLog = testRun.logExcerpt ? titleMatchesSignal(args.finding.title, args.finding.body, testRun.logExcerpt) : false; - if (!hitLog && out.length > 0) continue; + if (!hitLog) continue; out.push({ kind: "tool_signal", summary: `Test run ${testRun.suiteName} — ${testRun.status}${testRun.exitCode != null ? ` (exit ${testRun.exitCode})` : ""}`, From 60c42398160c5da764ac5b905d9c0f298329d520 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 04:57:50 -0400 Subject: [PATCH 10/16] ship: checkpoint before automate/finalize --- .../main/services/review/reviewDiffContext.ts | 3 +- .../src/main/services/review/reviewService.ts | 44 ++++++++++++++++--- .../review/reviewSuppressionService.ts | 41 +++++++++++------ .../services/review/reviewToolEvidence.ts | 6 ++- .../components/review/ReviewFindingCard.tsx | 44 +++++++++++++++++-- 5 files changed, 112 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/main/services/review/reviewDiffContext.ts b/apps/desktop/src/main/services/review/reviewDiffContext.ts index 8694e08ed..792464853 100644 --- a/apps/desktop/src/main/services/review/reviewDiffContext.ts +++ b/apps/desktop/src/main/services/review/reviewDiffContext.ts @@ -9,7 +9,8 @@ const CONTEXT_RADIUS = 8; function extractFileHunks(patchText: string): string[] { if (!patchText) return []; - const lines = patchText.split("\n"); + // Normalize CRLF so render doesn't show a stray `\r` glyph on every line. + const lines = patchText.replace(/\r\n/g, "\n").split("\n"); const hunks: string[][] = []; let current: string[] = []; for (const line of lines) { diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index 3fbcac12f..0fae83250 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -1921,6 +1921,20 @@ export function createReviewService({ const passResults: PassExecutionResult[] = []; for (const pass of REVIEW_PASSES) { if (disposed) return; + if (cancelledRuns.has(runId)) { + cancelledRuns.delete(runId); + const endedAt = nowIso(); + updateRun(runId, { + status: "cancelled", + summary: "Run cancelled during review passes.", + error_message: null, + ended_at: endedAt, + updated_at: endedAt, + }); + emit({ type: "run-completed", runId, laneId: run.laneId, status: "cancelled" }); + emit({ type: "runs-updated", runId, laneId: run.laneId, status: "cancelled" }); + return; + } const passResult = await executePass({ runId, run: effectiveRun, @@ -2377,24 +2391,38 @@ export function createReviewService({ where rr.project_id = ? and rf.publication_state = 'published'`, [projectId], ); + // Count each finding once, using only its latest feedback entry. Without + // the row_number() filter, a user who toggled feedback (e.g. acknowledge + // then dismiss) would be counted twice and noiseRate could exceed 1.0. const feedbackCounts = db.all<{ kind: string; n: number }>( - `select kind, count(*) as n from review_finding_feedback - where project_id = ? group by kind`, + `with latest as ( + select finding_id, kind, + row_number() over (partition by finding_id order by created_at desc) as rn + from review_finding_feedback + where project_id = ? + ) + select kind, count(*) as n from latest where rn = 1 group by kind`, [projectId], ); const kindMap = new Map(feedbackCounts.map((row) => [row.kind, Number(row.n ?? 0)])); const byClassRows = db.all<{ finding_class: string | null; total: number; addressed: number }>( - `select rf.finding_class as finding_class, + `with latest as ( + select finding_id, kind, + row_number() over (partition by finding_id order by created_at desc) as rn + from review_finding_feedback + where project_id = ? + ) + select rf.finding_class as finding_class, count(*) as total, - sum(case when fb.kind = 'acknowledge' then 1 else 0 end) as addressed + sum(case when fb.kind = 'acknowledge' and fb.rn = 1 then 1 else 0 end) as addressed from review_findings rf join review_runs rr on rr.id = rf.run_id - left join review_finding_feedback fb on fb.finding_id = rf.id + left join latest fb on fb.finding_id = rf.id where rr.project_id = ? group by rf.finding_class order by total desc limit 20`, - [projectId], + [projectId, projectId], ); const recentFeedback = db.all<ReviewFindingFeedbackRow>( "select * from review_finding_feedback where project_id = ? order by created_at desc limit 20", @@ -2406,7 +2434,9 @@ export function createReviewService({ const suppressedCount = kindMap.get("suppress") ?? 0; const addressedCount = kindMap.get("acknowledge") ?? 0; const snoozedCount = kindMap.get("snooze") ?? 0; - const noiseRate = totalFindings > 0 ? Number(((dismissedCount + suppressedCount) / totalFindings).toFixed(3)) : 0; + const noiseRate = totalFindings > 0 + ? Math.max(0, Math.min(1, Number(((dismissedCount + suppressedCount) / totalFindings).toFixed(3)))) + : 0; return { projectId, diff --git a/apps/desktop/src/main/services/review/reviewSuppressionService.ts b/apps/desktop/src/main/services/review/reviewSuppressionService.ts index a9029367b..9e462e6f9 100644 --- a/apps/desktop/src/main/services/review/reviewSuppressionService.ts +++ b/apps/desktop/src/main/services/review/reviewSuppressionService.ts @@ -24,8 +24,16 @@ function globToRegExp(pattern: string): RegExp { const ch = pattern[i]; if (ch === "*") { if (pattern[i + 1] === "*") { - source += ".*"; - i += 2; + // `**/` means "zero or more path segments" — needs to match both + // `src/foo.ts` and `src/a/b/foo.ts` against `src/**/*.ts`. + // Swallow the trailing slash and make the whole segment optional. + if (pattern[i + 2] === "/") { + source += "(?:.*/)?"; + i += 3; + } else { + source += ".*"; + i += 2; + } } else { source += "[^/]*"; i += 1; @@ -33,7 +41,7 @@ function globToRegExp(pattern: string): RegExp { continue; } if (ch === "?") { - source += "."; + source += "[^/]"; i += 1; continue; } @@ -280,17 +288,24 @@ export function createReviewSuppressionService({ continue; } - let score = 0; - if (candidateEmbedding && suppression.embedding) { - score = cosine(candidateEmbedding, suppression.embedding); - } - if (score < EMBEDDING_SIM_THRESHOLD) { - const titleScore = jaccard(findingTokens, tokenize(suppression.title)); - if (titleScore > score) score = titleScore; + // Score each candidate against its strongest signal: + // - if both sides have embeddings, trust cosine with the embedding bar + // - otherwise fall back to Jaccard-over-title-tokens with a lower bar + // Previously we mixed the two (jaccard could overwrite cosine but then + // get compared to the embedding threshold), which rejected real + // title-matches when embeddings happened to be weak. + const haveBothEmbeddings = Boolean(candidateEmbedding && suppression.embedding); + const cosineScore = haveBothEmbeddings + ? cosine(candidateEmbedding, suppression.embedding) + : 0; + if (haveBothEmbeddings && cosineScore >= EMBEDDING_SIM_THRESHOLD) { + const score = cosineScore; + if (!best || score > best.similarity) best = { row: suppression, similarity: score }; + continue; } - - const threshold = candidateEmbedding && suppression.embedding ? EMBEDDING_SIM_THRESHOLD : TITLE_SIM_THRESHOLD; - if (score < threshold) continue; + const titleScore = jaccard(findingTokens, tokenize(suppression.title)); + if (titleScore < TITLE_SIM_THRESHOLD) continue; + const score = Math.max(cosineScore, titleScore); if (!best || score > best.similarity) { best = { row: suppression, similarity: score }; diff --git a/apps/desktop/src/main/services/review/reviewToolEvidence.ts b/apps/desktop/src/main/services/review/reviewToolEvidence.ts index 016f1265d..5cf723e17 100644 --- a/apps/desktop/src/main/services/review/reviewToolEvidence.ts +++ b/apps/desktop/src/main/services/review/reviewToolEvidence.ts @@ -60,8 +60,12 @@ function titleMatchesSignal(findingTitle: string, findingBody: string, summary: if (tokens.length === 0) return false; const required = Math.min(2, tokens.length); let hits = 0; + const seen = new Set<string>(); for (const token of tokens) { - if (haystack.includes(token)) hits += 1; + if (seen.has(token)) continue; + seen.add(token); + // Word-boundary match so "test" doesn't hit "latest" or "tested". + if (new RegExp(`\\b${token}\\b`).test(haystack)) hits += 1; if (hits >= required) return true; } return false; diff --git a/apps/desktop/src/renderer/components/review/ReviewFindingCard.tsx b/apps/desktop/src/renderer/components/review/ReviewFindingCard.tsx index 636058ff4..f2076da41 100644 --- a/apps/desktop/src/renderer/components/review/ReviewFindingCard.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewFindingCard.tsx @@ -153,7 +153,7 @@ function DiffContextBlock({ context }: { context: ReviewDiffContext | null | und > <span className="w-10 shrink-0 text-right font-mono text-[#4B5B71]">{line.line ?? ""}</span> <span className="w-3 shrink-0 font-mono text-[#6E7F92]">{marker}</span> - <span className="whitespace-pre-wrap font-mono text-[11px]">{line.text || " "}</span> + <span className="whitespace-pre font-mono text-[11px]">{line.text || " "}</span> </span> ); })} @@ -229,6 +229,7 @@ function DismissModal({ open, initialKind, finding, onClose, onSubmit }: Dismiss const [snoozeDays, setSnoozeDays] = React.useState(7); const [suppressionScope, setSuppressionScope] = React.useState<ReviewSuppressionScope>("repo"); const [submitting, setSubmitting] = React.useState(false); + const closeButtonRef = React.useRef<HTMLButtonElement | null>(null); React.useEffect(() => { if (open) { @@ -241,6 +242,20 @@ function DismissModal({ open, initialKind, finding, onClose, onSubmit }: Dismiss } }, [open, initialKind]); + React.useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", onKey); + // Move focus into the dialog so keyboard users are oriented. + queueMicrotask(() => closeButtonRef.current?.focus()); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + if (!open) return null; const handleSubmit = async () => { @@ -264,10 +279,25 @@ function DismissModal({ open, initialKind, finding, onClose, onSubmit }: Dismiss { value: "suppress", label: "Suppress similar findings", icon: <Shield size={14} /> }, ]; + const submitLabel = submitting + ? "Saving…" + : kind === "suppress" + ? suppressionScope === "path" + ? "Suppress for this path" + : suppressionScope === "global" + ? "Suppress everywhere" + : "Suppress in this repo" + : kind === "snooze" + ? `Snooze ${snoozeDays} day${snoozeDays === 1 ? "" : "s"}` + : "Dismiss"; + return ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8 backdrop-blur-sm" onClick={onClose} + role="dialog" + aria-modal="true" + aria-label="Review finding feedback" > <div className="w-full max-w-lg overflow-hidden rounded-2xl border border-white/[0.08] bg-[#0B141F] shadow-2xl" @@ -278,12 +308,18 @@ function DismissModal({ open, initialKind, finding, onClose, onSubmit }: Dismiss <div className="text-[10px] uppercase tracking-[0.18em] text-[#6E7F92]">Feedback</div> <div className="truncate text-sm font-semibold text-[#F5FAFF]">{finding.title}</div> </div> - <button type="button" onClick={onClose} className="rounded-md p-1 text-[#93A4B8] hover:bg-white/[0.06]"> + <button + ref={closeButtonRef} + type="button" + onClick={onClose} + aria-label="Close feedback dialog" + className="rounded-md p-1 text-[#93A4B8] hover:bg-white/[0.06] focus:outline-none focus:ring-2 focus:ring-sky-400/50" + > <X size={16} /> </button> </div> <div className="space-y-4 p-5"> - <div className="grid grid-cols-3 gap-2"> + <div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> {kindOptions.map((opt) => ( <button key={opt.value} @@ -386,7 +422,7 @@ function DismissModal({ open, initialKind, finding, onClose, onSubmit }: Dismiss onClick={handleSubmit} disabled={submitting || (reason === "other" && !note.trim())} > - {submitting ? "Saving…" : kind === "suppress" ? "Suppress" : kind === "snooze" ? "Snooze" : "Dismiss"} + {submitLabel} </Button> </div> </div> From d52d225d84339cf40cf2a25e0a6ae75a0884a6fc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:23:09 -0400 Subject: [PATCH 11/16] ship: add review-service tests + iOS SQL regen + TabNav mock fix - Unit tests for reviewDiffContext, reviewSuppressionService, reviewToolEvidence (21 tests). - Regenerate DatabaseBootstrap.sql for new pr_convergence_state / auto_converge_runs kvDb tables (iOS parity). - Add window.ade.app.getInfo mock to TabNav.test.tsx to match new useEffect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../services/review/reviewDiffContext.test.ts | 13 ++ .../review/reviewSuppressionService.test.ts | 130 ++++++++++++++++++ .../review/reviewToolEvidence.test.ts | 63 +++++++++ .../renderer/components/app/TabNav.test.tsx | 1 + apps/ios/ADE/Resources/DatabaseBootstrap.sql | 44 ++++++ 5 files changed, 251 insertions(+) diff --git a/apps/desktop/src/main/services/review/reviewDiffContext.test.ts b/apps/desktop/src/main/services/review/reviewDiffContext.test.ts index 69d39a48b..259ce09c5 100644 --- a/apps/desktop/src/main/services/review/reviewDiffContext.test.ts +++ b/apps/desktop/src/main/services/review/reviewDiffContext.test.ts @@ -66,4 +66,17 @@ describe("buildDiffContextForFinding", () => { expect(result!.lines.length).toBeGreaterThan(0); expect(result!.lines.find((line) => line.highlighted)).toBeUndefined(); }); + + it("normalizes CRLF so no line text carries a trailing carriage return", () => { + const crlfPatch = SAMPLE_PATCH.replace(/\n/g, "\r\n"); + const result = buildDiffContextForFinding({ + filePath: "src/auth.ts", + anchoredLine: 13, + patches: [{ filePath: "src/auth.ts", excerpt: crlfPatch }], + }); + expect(result).not.toBeNull(); + for (const line of result!.lines) { + expect(line.text.includes("\r")).toBe(false); + } + }); }); diff --git a/apps/desktop/src/main/services/review/reviewSuppressionService.test.ts b/apps/desktop/src/main/services/review/reviewSuppressionService.test.ts index fda2fc9c9..f7e1bad18 100644 --- a/apps/desktop/src/main/services/review/reviewSuppressionService.test.ts +++ b/apps/desktop/src/main/services/review/reviewSuppressionService.test.ts @@ -189,6 +189,136 @@ describe("reviewSuppressionService", () => { expect(miss).toBeNull(); }); + it("path glob `src/**/*.ts` matches both shallow and nested files", async () => { + const svc = createReviewSuppressionService({ + db: dbHandle.db as unknown as import("../state/kvDb").AdeDb, + logger, + projectId: "proj-1", + }); + await svc.create({ + scope: "path", + title: "Prefer named export over default in TS module", + pathPattern: "src/**/*.ts", + reason: "style_only", + }); + + const shallow = await svc.match({ + finding: { + title: "prefer named export over default in ts module", + body: "", + filePath: "src/foo.ts", + findingClass: null, + severity: "low", + }, + repoKey: "r", + }); + expect(shallow).not.toBeNull(); + + const nested = await svc.match({ + finding: { + title: "prefer named export over default in ts module", + body: "", + filePath: "src/a/b/c/foo.ts", + findingClass: null, + severity: "low", + }, + repoKey: "r", + }); + expect(nested).not.toBeNull(); + + const wrongExt = await svc.match({ + finding: { + title: "prefer named export over default in ts module", + body: "", + filePath: "src/foo.tsx", + findingClass: null, + severity: "low", + }, + repoKey: "r", + }); + expect(wrongExt).toBeNull(); + }); + + it("path glob `?` matches exactly one non-slash character", async () => { + const svc = createReviewSuppressionService({ + db: dbHandle.db as unknown as import("../state/kvDb").AdeDb, + logger, + projectId: "proj-1", + }); + await svc.create({ + scope: "path", + title: "Numbered helper lint", + pathPattern: "src/helper?.ts", + reason: "style_only", + }); + + const single = await svc.match({ + finding: { + title: "numbered helper lint", + body: "", + filePath: "src/helper1.ts", + findingClass: null, + severity: "low", + }, + repoKey: "r", + }); + expect(single).not.toBeNull(); + + const twoChars = await svc.match({ + finding: { + title: "numbered helper lint", + body: "", + filePath: "src/helper12.ts", + findingClass: null, + severity: "low", + }, + repoKey: "r", + }); + expect(twoChars).toBeNull(); + + const slashed = await svc.match({ + finding: { + title: "numbered helper lint", + body: "", + filePath: "src/helper/.ts", + findingClass: null, + severity: "low", + }, + repoKey: "r", + }); + expect(slashed).toBeNull(); + }); + + it("title-match fallback fires when embeddings are weak/missing but tokens overlap", async () => { + // No embeddingService is provided, so both sides fall back to the + // title-token (Jaccard) path with the TITLE_SIM_THRESHOLD bar (0.55). + // The pre-fix logic applied the embedding threshold here and rejected. + const svc = createReviewSuppressionService({ + db: dbHandle.db as unknown as import("../state/kvDb").AdeDb, + logger, + projectId: "proj-1", + }); + await svc.create({ + scope: "repo", + title: "Prefer async await over raw promise chains", + repoKey: "arul28/ade", + reason: "style_only", + }); + const hit = await svc.match({ + finding: { + title: "Prefer async await over raw promise chains", + body: "", + filePath: "src/anywhere.ts", + findingClass: null, + severity: "low", + }, + repoKey: "arul28/ade", + }); + expect(hit).not.toBeNull(); + // Identical titles → Jaccard = 1.0, comfortably above TITLE_SIM_THRESHOLD. + expect(hit!.similarity).toBeGreaterThanOrEqual(0.55); + }); + it("removes a suppression", async () => { const svc = createReviewSuppressionService({ db: dbHandle.db as unknown as import("../state/kvDb").AdeDb, diff --git a/apps/desktop/src/main/services/review/reviewToolEvidence.test.ts b/apps/desktop/src/main/services/review/reviewToolEvidence.test.ts index 912867946..b533e9156 100644 --- a/apps/desktop/src/main/services/review/reviewToolEvidence.test.ts +++ b/apps/desktop/src/main/services/review/reviewToolEvidence.test.ts @@ -80,4 +80,67 @@ describe("buildToolBackedEvidence", () => { expect(out.length).toBe(1); expect(out[0]?.toolSignal?.kind).toBe("typecheck"); }); + + it("does not attach a test-run signal whose only token overlap is a substring (test vs latest)", () => { + // Finding body mentions "latest" and "tested" but never the word "test" + // on its own. A substring match would have (incorrectly) attached this. + const out = buildToolBackedEvidence({ + finding: { + filePath: null, + title: "Use the latest client", + body: "We have tested this carefully.", + line: null, + }, + validation: emptyPayload({ + signals: [ + { + kind: "test_run_failure", + summary: "test suite failing", + filePaths: [], + sourceId: "suite:unit", + }, + ], + }), + }); + expect(out).toEqual([]); + }); + + it("single-word summary (e.g. 'typecheck') needs only one token hit to qualify", () => { + const out = buildToolBackedEvidence({ + finding: { filePath: null, title: "typecheck fails in shared types", body: "", line: null }, + validation: emptyPayload({ + signals: [ + { + kind: "pr_check_failure", + summary: "typecheck", + filePaths: [], + sourceId: "check:typecheck", + }, + ], + }), + }); + expect(out.length).toBe(1); + expect(out[0]?.toolSignal?.kind).toBe("ci_check"); + }); + + it("does not double-count duplicate tokens in the summary", () => { + // Summary repeats "handler" three times; title mentions "handler" once. + // Under the pre-dedup logic, three hits would satisfy the "required: 2" + // threshold even against a single real occurrence. With dedup, tokens + // are only counted once — so we still need a second distinct token match. + const out = buildToolBackedEvidence({ + finding: { filePath: null, title: "Handler drops errors", body: "", line: null }, + validation: emptyPayload({ + signals: [ + { + kind: "test_run_failure", + summary: "handler handler handler", + filePaths: [], + sourceId: "suite:x", + }, + ], + }), + }); + expect(out).toEqual([]); + }); }); diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx index 1d87ce3d2..3451b3962 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -37,6 +37,7 @@ describe("TabNav", () => { globalThis.window.ade = { app: { revealPath: async () => undefined, + getInfo: async () => ({ isPackaged: false }) as any, }, } as any; }); diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 1984ee70a..2763a3c54 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -2451,6 +2451,50 @@ alter table review_findings add column originating_passes_json text; alter table review_findings add column adjudication_json text; +alter table review_findings add column diff_context_json text; + +alter table review_findings add column suppression_match_json text; + +create table if not exists review_finding_feedback ( + id text primary key, + finding_id text not null, + run_id text not null, + project_id text not null, + kind text not null, + reason text, + note text, + snooze_until text, + created_at text not null, + foreign key(finding_id) references review_findings(id) on delete cascade + ); + +create index if not exists idx_review_feedback_finding on review_finding_feedback(finding_id); + +create index if not exists idx_review_feedback_project_created on review_finding_feedback(project_id, created_at desc); + +create table if not exists review_suppressions ( + id text primary key, + project_id text not null, + scope text not null, + repo_key text, + path_pattern text, + title text not null, + title_norm text not null, + finding_class text, + severity text, + reason text, + note text, + embedding_json text, + source_finding_id text, + hit_count integer not null default 0, + created_at text not null, + last_matched_at text + ); + +create index if not exists idx_review_suppressions_project on review_suppressions(project_id, created_at desc); + +create index if not exists idx_review_suppressions_repo on review_suppressions(project_id, repo_key); + create table if not exists pr_issue_inventory ( id text primary key, pr_id text not null, From 69bc7b3e27b4079f2fd0a019f4c629c8aa19421e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:50:09 -0400 Subject: [PATCH 12/16] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20fix?= =?UTF-8?q?=205=20review-flagged=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reviewTargetMaterializer: increment diff position on non-first @@ hunks so GitHub inline-comment positions are correct across multi-hunk files (capy-ai). - laneService: explicitly delete review_finding_feedback before review_findings on lane deletion (CRR strips FKs, no cascade) (capy-ai). - reviewService.mapPublicationRow: drop tautological COMMENT?COMMENT:COMMENT ternary in favor of a direct cast (greptile). - reviewSuppressionService: cap PATH_GLOB_CACHE at 256 entries with FIFO eviction (greptile). - reviewService: build changedFilesByPath from the budget-sliced changedFiles, not materialized.changedFiles (greptile). Addresses PR #191 comments: 3136781788, 3136781797, 3136758435, 3136758509, 3136758600. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/desktop/src/main/services/lanes/laneService.ts | 1 + apps/desktop/src/main/services/review/reviewService.ts | 4 ++-- .../src/main/services/review/reviewSuppressionService.ts | 5 +++++ .../src/main/services/review/reviewTargetMaterializer.ts | 1 + 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 1e46393b1..0b8cb2ccd 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -2533,6 +2533,7 @@ export function createLaneService({ db.run("delete from pr_issue_inventory where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from pull_requests where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from review_run_publications where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from review_finding_feedback where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from review_run_artifacts where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); db.run("delete from review_runs where lane_id = ? and project_id = ?", [laneId, projectId]); diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index 0fae83250..ffec74936 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -1262,7 +1262,7 @@ function mapPublicationRow(row: ReviewRunPublicationRow): ReviewPublication { prNumber: 0, githubUrl: null, }), - reviewEvent: row.review_event === "COMMENT" ? "COMMENT" : "COMMENT", + reviewEvent: row.review_event as ReviewPublication["reviewEvent"], status: row.status === "published" ? "published" : "failed", reviewUrl: row.review_url, remoteReviewId: row.remote_review_id, @@ -1910,7 +1910,7 @@ export function createReviewService({ }, }); - const changedFilesByPath = new Map(materialized.changedFiles.map((entry) => [ + const changedFilesByPath = new Map(changedFiles.map((entry) => [ entry.filePath, { excerpt: entry.excerpt, diff --git a/apps/desktop/src/main/services/review/reviewSuppressionService.ts b/apps/desktop/src/main/services/review/reviewSuppressionService.ts index 9e462e6f9..3f531d365 100644 --- a/apps/desktop/src/main/services/review/reviewSuppressionService.ts +++ b/apps/desktop/src/main/services/review/reviewSuppressionService.ts @@ -13,6 +13,7 @@ import type { EmbeddingService } from "../memory/embeddingService"; const TITLE_SIM_THRESHOLD = 0.55; const EMBEDDING_SIM_THRESHOLD = 0.78; +const PATH_GLOB_CACHE_MAX = 256; const PATH_GLOB_CACHE = new Map<string, RegExp>(); function globToRegExp(pattern: string): RegExp { @@ -53,6 +54,10 @@ function globToRegExp(pattern: string): RegExp { i += 1; } const re = new RegExp(`^${source}$`); + if (PATH_GLOB_CACHE.size >= PATH_GLOB_CACHE_MAX) { + const oldest = PATH_GLOB_CACHE.keys().next().value; + if (oldest !== undefined) PATH_GLOB_CACHE.delete(oldest); + } PATH_GLOB_CACHE.set(pattern, re); return re; } diff --git a/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts b/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts index 915b215b5..d43dc82fc 100644 --- a/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts +++ b/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts @@ -124,6 +124,7 @@ function parseDiffFiles(patchText: string, fallbackPaths: string[]): ReviewMater const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); if (hunkMatch) { currentNewLine = Number(hunkMatch[1] ?? "0"); + if (currentDiffPosition > 0) currentDiffPosition += 1; continue; } if (currentNewLine == null) continue; From 085d56d5547379ae034af4f3d30136768f2b56e1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:05:59 -0400 Subject: [PATCH 13/16] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20byCla?= =?UTF-8?q?ss=20total=20fix=20+=20regex=20escape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reviewService.byClass: wrap the latest-feedback subquery in a latest_only CTE (rn = 1 filter), so count(*) per class stops inflating when a finding has multiple feedback rows (greptile P1 #3136916987). - reviewToolEvidence.titleMatchesSignal: escape regex metachars in the interpolated token before `\b...\b` matching. Defensive; current tokenization strips these already (Copilot re-review item 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../src/main/services/review/reviewService.ts | 16 +++++++++------- .../main/services/review/reviewToolEvidence.ts | 6 +++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index ffec74936..b9a1c9686 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -2406,18 +2406,20 @@ export function createReviewService({ ); const kindMap = new Map(feedbackCounts.map((row) => [row.kind, Number(row.n ?? 0)])); const byClassRows = db.all<{ finding_class: string | null; total: number; addressed: number }>( - `with latest as ( - select finding_id, kind, - row_number() over (partition by finding_id order by created_at desc) as rn - from review_finding_feedback - where project_id = ? + `with latest_only as ( + select finding_id, kind from ( + select finding_id, kind, + row_number() over (partition by finding_id order by created_at desc) as rn + from review_finding_feedback + where project_id = ? + ) where rn = 1 ) select rf.finding_class as finding_class, count(*) as total, - sum(case when fb.kind = 'acknowledge' and fb.rn = 1 then 1 else 0 end) as addressed + sum(case when fb.kind = 'acknowledge' then 1 else 0 end) as addressed from review_findings rf join review_runs rr on rr.id = rf.run_id - left join latest fb on fb.finding_id = rf.id + left join latest_only fb on fb.finding_id = rf.id where rr.project_id = ? group by rf.finding_class order by total desc diff --git a/apps/desktop/src/main/services/review/reviewToolEvidence.ts b/apps/desktop/src/main/services/review/reviewToolEvidence.ts index 5cf723e17..9a1b1a8f0 100644 --- a/apps/desktop/src/main/services/review/reviewToolEvidence.ts +++ b/apps/desktop/src/main/services/review/reviewToolEvidence.ts @@ -51,6 +51,10 @@ function pathMatchesFinding(paths: string[], findingPath: string | null): boolea }); } +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function titleMatchesSignal(findingTitle: string, findingBody: string, summary: string): boolean { const haystack = `${findingTitle} ${findingBody}`.toLowerCase(); const tokens = summary @@ -65,7 +69,7 @@ function titleMatchesSignal(findingTitle: string, findingBody: string, summary: if (seen.has(token)) continue; seen.add(token); // Word-boundary match so "test" doesn't hit "latest" or "tested". - if (new RegExp(`\\b${token}\\b`).test(haystack)) hits += 1; + if (new RegExp(`\\b${escapeRegex(token)}\\b`).test(haystack)) hits += 1; if (hits >= required) return true; } return false; From 47692da43b1974d5043b91d9a39b039979d5fd8f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:21:52 -0400 Subject: [PATCH 14/16] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20docum?= =?UTF-8?q?ent=20trust=20model=20for=20external=20cwd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1 #3137014594 flags the absolute-cwd path as a sandbox bypass. Configured cwd originates from projectConfigService (same file that declares the command itself); there is no IPC path that lets UI input reach this code. Inline comment documents the reasoning so future reviewers don't relitigate the same question. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apps/desktop/src/main/services/processes/processService.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index 650fc32f8..515597a81 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -583,6 +583,9 @@ export function createProcessService({ const laneRoot = laneService.getLaneWorktreePath(laneId); const configuredCwd = opts.overlay?.cwd?.trim() ? opts.overlay.cwd.trim() : definition.cwd.trim(); + // Absolute cwd is intentionally allowed: ProcessDefinition.cwd and LaneOverlayOverrides.cwd + // originate from the trusted project config (same file that already declares the command + // to execute), not from UI/IPC input. Relative paths remain sandboxed to the lane worktree. const allowExternalCwd = path.isAbsolute(configuredCwd); let cwd: string; if (allowExternalCwd) { From 457b4efaf2ff4d02949f08f1a415dcc6e6ac8256 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:37:55 -0400 Subject: [PATCH 15/16] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20docum?= =?UTF-8?q?ent=20first-hunk=20position=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1 #3137082789 claims the first @@ header should count toward position. GitHub's REST docs are explicit: "the line just below the '@@' line is position 1", i.e. the first content line after the first hunk header is position 1, not the header itself. Inline comment documents this so future re-reviews don't re-flag the conditional gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../src/main/services/review/reviewTargetMaterializer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts b/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts index d43dc82fc..13a9cfd6d 100644 --- a/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts +++ b/apps/desktop/src/main/services/review/reviewTargetMaterializer.ts @@ -124,6 +124,8 @@ function parseDiffFiles(patchText: string, fallbackPaths: string[]): ReviewMater const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); if (hunkMatch) { currentNewLine = Number(hunkMatch[1] ?? "0"); + // GitHub: "the line just below the '@@' line is position 1". The first hunk header + // anchors position 0 (not counted); later '@@' headers each consume one position. if (currentDiffPosition > 0) currentDiffPosition += 1; continue; } From de33f49f5d58f57e05c0acbc67524b714b9f43a5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:54:26 -0400 Subject: [PATCH 16/16] =?UTF-8?q?ship:=20iteration=205=20=E2=80=94=20fix?= =?UTF-8?q?=20flaky=20TerminalView=20webgl-timing=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-desktop (5) failed on CI: TerminalView's 'fits to container' test expected renderer=webgl but got 'dom'. The test used fake timers while the production path awaits a dynamic `import('@xterm/addon-webgl')` — that microtask chain doesn't reliably settle under fake timers on CI shards. Switch this one callsite to real timers + waitFor, matching the sibling test at line 409 that already uses this pattern. Test-only change; no production code touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../terminals/TerminalView.test.tsx | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 791931dab..30ea44dee 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -325,17 +325,28 @@ describe("TerminalView", () => { }); it("fits to the container and resizes the PTY when the fit result is valid", async () => { - render(<TerminalView ptyId="pty-valid" sessionId="session-valid" isActive />); - await flushAllTimers(); + // `await import("@xterm/addon-webgl")` may not settle under Vi's fake timers on CI shards, + // so use real timers + waitFor to let the microtask chain drain reliably. + vi.useRealTimers(); + try { + render(<TerminalView ptyId="pty-valid" sessionId="session-valid" isActive />); - const runtime = getTerminalRuntimeSnapshot("session-valid"); - expect(runtime?.renderer).toBe("webgl"); - expect(runtime?.health.fitRecoveries).toBe(0); - expect((window as any).ade.pty.resize).toHaveBeenCalledWith({ - ptyId: "pty-valid", - cols: 120, - rows: 40, - }); + await waitFor( + () => { + const runtime = getTerminalRuntimeSnapshot("session-valid"); + expect(runtime?.renderer).toBe("webgl"); + expect(runtime?.health.fitRecoveries).toBe(0); + expect((window as any).ade.pty.resize).toHaveBeenCalledWith({ + ptyId: "pty-valid", + cols: 120, + rows: 40, + }); + }, + { timeout: 10_000 }, + ); + } finally { + vi.useFakeTimers(); + } }); it("rejects implausible fit results, restores the last good size, and skips PTY resize", async () => {