diff --git a/.ade/ade.yaml b/.ade/ade.yaml index f8abdd165..d7995eecf 100644 --- a/.ade/ade.yaml +++ b/.ade/ade.yaml @@ -1,11 +1,12 @@ version: 1 processes: - - id: xsi8oj88 - name: dogfood onboardinign fixes + - id: gq3sy4rj + name: npm run dev:desktop command: - - ./scripts/dogfood.sh - - onboarding-fixes - cwd: apps/desktop + - npm + - run + - dev:desktop + cwd: . gracefulShutdownMs: 7000 readiness: type: none diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/plan/SKILL.md deleted file mode 100644 index fe1d124b8..000000000 --- a/.claude/skills/plan/SKILL.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -name: plan -description: Deliberate a feature or change in three locked rounds — functional requirements, then UI design with wireframes, then extras/quirks/out-of-the-box ideas. Each round asks the user clarifying questions via AskUserQuestion before proceeding. New functional scope at any point cascade-restarts the new piece through all three rounds and merges it into the locked plan. Use when the user invokes `/plan`, when the user is in plan mode and asks for a design/spec/feature breakdown, or when a non-trivial change needs structured deliberation before implementation. -metadata: - author: ade - version: "1.0" ---- - -# /plan — Three-Round Locked Deliberation - -A feature plan has three layers: **what it does**, **how it looks**, and **the delightful extras**. Cross-talk between them produces sloppy plans. This skill enforces a strict order: lock functional, then lock UI, then add extras. New functional scope discovered at any point cascades back through all three rounds *for that new piece only*, then merges into the locked plan. - -## Activation - -Activate when **any** of: -- The user invokes `/plan` (with or without extra context). -- The user is in plan mode and asks for a design, spec, breakdown, or feature plan. -- A non-trivial change request would benefit from structured deliberation (multi-component features, UX-sensitive work, anything spanning >2 files). - -## Pre-flight: plan mode gate - -Before Round 1, verify plan mode is active. If not: - -> This skill is meant to run in plan mode (read-only deliberation). Enter plan mode (Shift+Tab cycles to it) and re-invoke `/plan `. - -Then stop. Do not proceed in edit/full-auto modes — the deliberation contract assumes no files will be touched mid-plan. - -## State you must track - -Maintain these in your head (or in scratch) across the whole skill: - -- **LockedFunctional** — bullet list of functional requirements confirmed so far. -- **LockedUI** — bullet list of UI decisions confirmed so far (with wireframe sketches). -- **LockedExtras** — list of delightful extras confirmed. -- **CurrentRound** — 1 (Functional), 2 (UI), or 3 (Extras). -- **PendingNewPieces** — queue of new functional requirements detected mid-flight that need their own cascade. - -When a cascade fires, you snapshot CurrentRound, run the cascade for the new piece, merge results back into the Locked* sets, then resume from the snapshotted round. - -## Round 1 — Functional Requirements - -Silently deliberate on what the user asked for. Pull out: -- Core capabilities the feature must have. -- Boundaries (what it does *not* do). -- Inputs, outputs, edge cases that change behavior. -- Ambiguities where multiple interpretations exist. - -Then call **AskUserQuestion** with 1–4 questions that resolve the meaningful ambiguities. Each option should describe a concrete interpretation with its trade-off. Avoid asking the user to write prose — frame everything as concrete choices. - -### Tool reference: AskUserQuestion - -Use `AskUserQuestion(questions)` to gather structured choices during Round 1, Round 2, Round 3, and any cascade. `questions` is an array of question objects: - -- `id`: stable snake_case identifier. -- `title`: short user-facing label. -- `text`: the actual question. -- `multiSelect`: optional boolean for menus like LockedExtras. -- `allowOther`: optional boolean that permits an "Other" selection. -- `allowAnnotation`: optional boolean that permits free-text annotation alongside a selected option. -- `options`: array of `{ id, label, tradeoffs, description?, preview? }`. - -The return value is keyed by question id and contains `selectedOptionIds`, optional `otherText`, and optional `annotations` / `freeText`. Treat selected option ids as the locked choice. Treat `otherText` as a new option authored by the user; if it changes behavior, update **LockedFunctional** and trigger the **Cascade rule**. Treat annotations as refinements to the selected option; if an annotation adds behavior rather than clarifying wording or presentation, it also cascades. - -When the user responds: -- Treat selected options as additions to **LockedFunctional**. -- If the user's free-text (the "Other" reply or annotation) adds new scope → see **Cascade rule** below. -- If the user only clarifies an existing item → update **LockedFunctional** and proceed. - -Once questions are answered, **do not ask for explicit confirmation**. Silently move to Round 2. - -## Round 2 — UI Design - -Now deliberate on how the feature presents to the user. Generate 1–3 candidate UI directions. Round 2 candidates may be full-layout wireframes when the whole surface is up for decision, or component-level candidates when only one area is ambiguous. Be explicit which level each candidate represents. - -Call **AskUserQuestion** with options that carry **markdown wireframe previews** in the `preview` field. Wireframes are ASCII boxes: - -``` -┌─────────────────────────────────┐ -│ Header · filter ▾ · +new │ -├─────────────────────────────────┤ -│ ▣ item ⋯ │ -│ ▢ item ⋯ │ -│ ▢ item ⋯ │ -└─────────────────────────────────┘ -``` - -Keep each preview readable in a chat-width column (~70 chars). Show real labels, real density, real affordances — not lorem ipsum. - -Cover the meaningful axes (layout type, hierarchy of actions, empty/loading/error states, dense vs roomy) in **at most 4 questions total**. Don't waste a question on a decision that has one obvious answer. - -When the user responds: -- Selected wireframes/options → **LockedUI**. -- If candidates were component-level, merge the selected components into **LockedUI** as integration decisions. The Final output still needs one composed/merged inline wireframe derived from **LockedFunctional**, **LockedUI**, and any selected **LockedExtras**. -- Free-text that implies new functional behavior (e.g. "add export button" implies an export *flow*) → see **Cascade rule**. -- Pure UI refinements stay in Round 2. - -Silently proceed to Round 3. - -## Round 3 — Extras, Quirks, Out-of-the-Box - -Now generate 4–8 candidate ideas the user *didn't* ask for but might love: micro-interactions, keyboard shortcuts, empty-state delight, power-user features, animations, accessibility wins, surprise affordances, anti-aesthetics-of-AI touches (favor warmth and specificity over generic monochrome polish). - -Call **AskUserQuestion** with `multiSelect: true` for the menu of extras. Each option's `description` should be one sentence explaining the idea and one sentence on the cost/benefit. - -Selected items → **LockedExtras**. - -If the user adds a new functional idea in free-text → **Cascade rule**. - -## Cascade rule (the core contract) - -A **new functional requirement** is any input — from the user OR self-detected from your own proposals — that adds, removes, or changes scope of the feature. Not a UI refinement. Not an extras pick. *New behavior the system must perform.* - -When detected: - -1. **Snapshot** the current round. -2. **Queue** the new piece in **PendingNewPieces** with a one-line description. -3. **Announce briefly**: "New functional piece detected: . Cascading it through R1→R2→R3 before resuming Round ." -4. **Run a focused mini-cascade for that piece only**: - - **R1 for new piece**: AskUserQuestion scoped to just the new piece's functional ambiguities. - - **R2 for new piece**: AskUserQuestion with wireframes scoped to just how this new piece integrates into the already-locked UI (do NOT redesign locked layouts — show how the new piece slots in). - - **R3 for new piece**: AskUserQuestion with extras *for this piece only*. -5. **Merge** the results into the appropriate Locked* sets. -6. **Resume** from the snapshotted round. - -Multiple cascades may stack. Process them depth-first; never lose the snapshot. - -### Self-detecting new functional scope - -Before sending a UI option or an extra, ask yourself silently: *does this option require behavior that isn't already in LockedFunctional?* If yes, you have a choice: -- Drop the option (cleanest). -- Or, if it's genuinely a great idea, **fire the cascade yourself** before sending it. Don't sneak new functional scope into UI/extras questions. - -## Final output - -After Round 3 completes with no pending cascades: - -1. Print a tight consolidated plan in chat with three sections: - - **Functional** — bulleted LockedFunctional. - - **UI** — bulleted LockedUI with one inline wireframe of the final composed layout. - - **Extras** — bulleted LockedExtras. - Keep total under ~50 lines. No filler. -2. Call **ExitPlanMode** to formally request approval. - -### Tool reference: ExitPlanMode - -Use `ExitPlanMode(): void` immediately after the Final output. It has no parameters and returns no value. Calling it signals that **LockedFunctional**, **LockedUI**, and **LockedExtras** are complete, including the final composed wireframe, and asks the user to approve leaving plan mode. Do not call it before all pending cascades are processed. - -## Anti-patterns (do not do) - -- Asking the user to "describe what they want" in prose. Always frame as concrete options. -- Sending more than 4 questions in a single AskUserQuestion call (tool max). -- Slipping new functional behavior into UI or Extras rounds without cascading. -- Asking explicit "lock in? yes/no" questions between rounds — proceed silently unless interrupted. -- Re-litigating LockedFunctional during R2 or R3 unless a cascade explicitly opens that piece. -- Generic AI aesthetics in wireframes — no `font-mono` aesthetic apologies, no centered-everything, no purple gradients in mocked copy. Be specific and warm. -- Long preamble. The user invoked `/plan`; jump to Round 1. - -## Minimal example flow - -User: `/plan a markdown note app with tags` - -You: silent deliberation → AskUserQuestion (R1): -- Q1: "How should tags be created?" (inline `#tag` parsing / explicit tag field / both) -- Q2: "Search scope when filtering by tag?" (notes containing tag / notes tagged-only / both modes) -- Q3: "Multi-user or single-user?" - -User selects + adds "and they sync to iCloud" (new functional scope). - -You: "New piece detected: iCloud sync. Cascading." -→ R1-for-sync: conflict resolution? offline-first? -→ R2-for-sync: sync-status indicator placement? -→ R3-for-sync: optimistic UI? merge-conflict modal? -Merge → resume Round 1. - -… and so on through R2 and R3 of the main plan, then ExitPlanMode. diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 76105b068..c48bcb819 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -293,7 +293,7 @@ function createRuntime() { listBranches: vi.fn(async () => [{ name: "main", current: true, ahead: 0, behind: 0, hasUpstream: true, upstream: "origin/main" }]), checkoutBranch: vi.fn(async () => ({ success: true })), stashPush: vi.fn(async () => ({ success: true })), - listStashes: vi.fn(async () => [{ ref: "stash@{0}", createdAt: "2026-04-06T00:00:00.000Z", subject: "test stash" }]), + listStashes: vi.fn(async () => [{ oid: "oid-0", ref: "stash@{0}", createdAt: "2026-04-06T00:00:00.000Z", subject: "test stash" }]), stashApply: vi.fn(async () => ({ success: true })), stashPop: vi.fn(async () => ({ success: true })), stashDrop: vi.fn(async () => ({ success: true })), @@ -4515,6 +4515,51 @@ describe("adeRpcServer", () => { }); + it("invokes review.startRun through ADE actions without dropping unlimited budgets", async () => { + const fixture = createRuntime(); + const startArgs = { + target: { mode: "lane_diff", laneId: "lane-1" }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: "openai/gpt-5.4", + reasoningEffort: "medium", + budgets: { + unlimited: true, + maxFiles: Number.MAX_SAFE_INTEGER, + maxDiffChars: Number.MAX_SAFE_INTEGER, + maxPromptChars: Number.MAX_SAFE_INTEGER, + maxFindings: Number.MAX_SAFE_INTEGER, + maxFindingsPerPass: Number.MAX_SAFE_INTEGER, + maxPublishedFindings: Number.MAX_SAFE_INTEGER, + }, + publishBehavior: "local_only", + }, + }; + const startRun = vi.fn(async (args: typeof startArgs) => ({ + id: "review-run-1", + laneId: args.target.laneId, + config: args.config, + status: "queued", + })); + (fixture.runtime as any).reviewService = { startRun }; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const response = await callTool(handler, "run_ade_action", { + domain: "review", + action: "startRun", + args: startArgs, + }); + + expect(response?.isError).toBeUndefined(); + expect(startRun).toHaveBeenCalledWith(startArgs); + expect(startRun.mock.calls[0][0].config.budgets).toEqual(startArgs.config.budgets); + expect(response.structuredContent.result.config.budgets).toEqual(startArgs.config.budgets); + expect(response.structuredContent.result.config.budgets.unlimited).toBe(true); + }); + it("binds service method context when invoking dynamic ADE actions", async () => { const fixture = createRuntime(); const missionService = fixture.runtime.missionService as any; @@ -4689,6 +4734,37 @@ describe("adeRpcServer", () => { expect(response.structuredContent.count).toBe(1); }); + it("passes stash oid through destructive stash tools", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const pop = await callTool(handler, "stash_pop", { + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + }); + const drop = await callTool(handler, "stash_drop", { + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + }); + + expect(pop?.isError).toBeUndefined(); + expect(drop?.isError).toBeUndefined(); + expect(fixture.runtime.gitService.stashPop).toHaveBeenCalledWith({ + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + }); + expect(fixture.runtime.gitService.stashDrop).toHaveBeenCalledWith({ + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + }); + }); + it("returns resources for lane status/conflicts", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index d7962f372..4ba2c3888 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -1046,7 +1046,8 @@ const TOOL_SPECS: ToolSpec[] = [ additionalProperties: false, properties: { laneId: { type: "string", minLength: 1 }, - stashRef: { type: "string", minLength: 1 } + stashRef: { type: "string", minLength: 1 }, + stashOid: { type: "string", minLength: 1 } } } }, @@ -1055,11 +1056,12 @@ const TOOL_SPECS: ToolSpec[] = [ description: "Pop a stash onto a lane and remove it from the stash list. Defaults to the current chat lane when laneId is omitted.", inputSchema: { type: "object", - required: ["stashRef"], + required: ["stashRef", "stashOid"], additionalProperties: false, properties: { laneId: { type: "string", minLength: 1 }, - stashRef: { type: "string", minLength: 1 } + stashRef: { type: "string", minLength: 1 }, + stashOid: { type: "string", minLength: 1 } } } }, @@ -1068,11 +1070,12 @@ const TOOL_SPECS: ToolSpec[] = [ description: "Drop a stash from a lane. Defaults to the current chat lane when laneId is omitted.", inputSchema: { type: "object", - required: ["stashRef"], + required: ["stashRef", "stashOid"], additionalProperties: false, properties: { laneId: { type: "string", minLength: 1 }, - stashRef: { type: "string", minLength: 1 } + stashRef: { type: "string", minLength: 1 }, + stashOid: { type: "string", minLength: 1 } } } }, @@ -6526,21 +6529,24 @@ async function runTool(args: { if (name === "stash_apply") { const laneId = requireLaneIdForTool(runtime, session, toolArgs, "stash_apply"); const stashRef = assertNonEmptyString(toolArgs.stashRef, "stashRef"); - const action = await runtime.gitService.stashApply({ laneId, stashRef }); + const stashOid = asOptionalTrimmedString(toolArgs.stashOid); + const action = await runtime.gitService.stashApply({ laneId, stashRef, ...(stashOid ? { stashOid } : {}) }); return { action }; } if (name === "stash_pop") { const laneId = requireLaneIdForTool(runtime, session, toolArgs, "stash_pop"); const stashRef = assertNonEmptyString(toolArgs.stashRef, "stashRef"); - const action = await runtime.gitService.stashPop({ laneId, stashRef }); + const stashOid = assertNonEmptyString(toolArgs.stashOid, "stashOid"); + const action = await runtime.gitService.stashPop({ laneId, stashRef, stashOid }); return { action }; } if (name === "stash_drop") { const laneId = requireLaneIdForTool(runtime, session, toolArgs, "stash_drop"); const stashRef = assertNonEmptyString(toolArgs.stashRef, "stashRef"); - const action = await runtime.gitService.stashDrop({ laneId, stashRef }); + const stashOid = assertNonEmptyString(toolArgs.stashOid, "stashOid"); + const action = await runtime.gitService.stashDrop({ laneId, stashRef, stashOid }); return { action }; } diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index de3ef5b6d..3fb9654e0 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1686,6 +1686,186 @@ describe("ADE CLI", () => { }); }); + it("resolves stash OIDs before CLI pop and drop actions", () => { + const pop = buildCliPlan([ + "git", + "stash", + "pop", + "stash@{0}", + "--lane", + "lane-1", + ]); + expect(pop.kind).toBe("execute"); + if (pop.kind !== "execute") return; + expect(pop.steps[0]?.params).toEqual({ + name: "list_stashes", + arguments: { laneId: "lane-1" }, + }); + const popParams = typeof pop.steps[1]?.params === "function" + ? pop.steps[1].params({ stashes: { stashes: [{ ref: "stash@{0}", oid: "oid-0" }] } }) + : pop.steps[1]?.params; + expect(popParams).toEqual({ + name: "stash_pop", + arguments: { + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + }, + }); + + const drop = buildCliPlan([ + "git", + "stash", + "drop", + "stash@{1}", + "--lane", + "lane-1", + ]); + expect(drop.kind).toBe("execute"); + if (drop.kind !== "execute") return; + const dropParams = typeof drop.steps[1]?.params === "function" + ? drop.steps[1].params({ stashes: { stashes: [{ ref: "stash@{1}", oid: "oid-1" }] } }) + : drop.steps[1]?.params; + expect(dropParams).toEqual({ + name: "stash_drop", + arguments: { + laneId: "lane-1", + stashRef: "stash@{1}", + stashOid: "oid-1", + }, + }); + }); + + it("uses the latest lane stash when CLI pop omits a stash ref", () => { + const plan = buildCliPlan([ + "git", + "stash", + "pop", + "--lane", + "lane-1", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + const params = typeof plan.steps[1]?.params === "function" + ? plan.steps[1].params({ stashes: { stashes: [{ ref: "stash@{3}", oid: "oid-3" }] } }) + : plan.steps[1]?.params; + expect(params).toEqual({ + name: "stash_pop", + arguments: { + laneId: "lane-1", + stashRef: "stash@{3}", + stashOid: "oid-3", + }, + }); + }); + + it("resolves a stash ref from an explicit OID when CLI stash omits the ref", () => { + const plan = buildCliPlan([ + "git", + "stash", + "drop", + "--stash-oid", + "oid-3", + "--lane", + "lane-1", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + const params = typeof plan.steps[1]?.params === "function" + ? plan.steps[1].params({ + stashes: { + stashes: [ + { ref: "stash@{0}", oid: "oid-0" }, + { ref: "stash@{3}", oid: "oid-3" }, + ], + }, + }) + : plan.steps[1]?.params; + expect(params).toEqual({ + name: "stash_drop", + arguments: { + laneId: "lane-1", + stashRef: "stash@{3}", + stashOid: "oid-3", + }, + }); + }); + + it("keeps explicit stash OIDs on direct CLI stash calls", () => { + const plan = buildCliPlan([ + "git", + "stash", + "drop", + "stash@{0}", + "--oid", + "oid-0", + "--lane", + "lane-1", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps).toHaveLength(1); + expect(plan.steps[0]?.params).toEqual({ + name: "stash_drop", + arguments: { + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + }, + }); + }); + + it("throws a clear CLI error when a stash ref cannot be resolved to an OID", () => { + const plan = buildCliPlan([ + "git", + "stash", + "pop", + "stash@{2}", + "--lane", + "lane-1", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(() => { + if (typeof plan.steps[1]?.params !== "function") throw new Error("Expected resolver params."); + plan.steps[1].params({ stashes: { stashes: [{ ref: "stash@{0}", oid: "oid-0" }] } }); + }).toThrow(/Stash stash@\{2\} is not saved for this lane/); + }); + + it("throws a clear CLI error when a stash OID cannot be resolved to a ref", () => { + const plan = buildCliPlan([ + "git", + "stash", + "drop", + "--stash-oid", + "oid-9", + "--lane", + "lane-1", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(() => { + if (typeof plan.steps[1]?.params !== "function") throw new Error("Expected resolver params."); + plan.steps[1].params({ stashes: { stashes: [{ ref: "stash@{0}", oid: "oid-0" }] } }); + }).toThrow(/Stash OID oid-9 is not saved for this lane/); + }); + + it("throws a clear CLI error when no default lane stash exists", () => { + const plan = buildCliPlan([ + "git", + "stash", + "drop", + "--lane", + "lane-1", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(() => { + if (typeof plan.steps[1]?.params !== "function") throw new Error("Expected resolver params."); + plan.steps[1].params({ stashes: { stashes: [] } }); + }).toThrow(/No saved stashes were found for this lane/); + }); + it("preserves the public git push --set-upstream flag", () => { const plan = buildCliPlan([ "git", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 08371ea8e..0024981f1 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -912,7 +912,8 @@ const HELP_BY_COMMAND: Record = { $ ade git push --lane --force-with-lease Force-push through ADE with lease $ ade git branches --lane --text List branches with last-commit metadata $ ade git user-identity --lane --text Read lane checkout's git user.name/email - $ ade git stash push|list|apply|pop Use ADE lane stash actions + $ ade git stash push|list|apply|pop|drop Use ADE lane stash actions + pop/drop resolve the saved stash OID before changing it $ ade git rebase --lane --ai Rebase with ADE conflict support $ ade git rebase continue --lane Continue an in-progress rebase $ ade git conflict show --lane --text Inspect merge/rebase conflict state @@ -2703,6 +2704,32 @@ function buildLanePlan(args: string[]): CliPlan { }; } +function resolveStashSelectionForCli(listResult: unknown, stashRef: string | null, stashOid: string | null): { + stashRef: string; + stashOid: string; +} { + const stashes = firstArray(listResult, ["stashes"]); + const match = stashRef + ? stashes.find((stash) => asString(stash.ref) === stashRef) + : stashOid + ? stashes.find((stash) => asString(stash.oid) === stashOid) + : stashes[0]; + const selectedRef = asString(match?.ref); + const selectedOid = asString(match?.oid); + if (selectedRef && selectedOid) return { stashRef: selectedRef, stashOid: selectedOid }; + if (!stashRef && !stashOid) { + throw new CliUsageError("No saved stashes were found for this lane."); + } + if (stashOid) { + throw new CliUsageError( + `Stash OID ${stashOid} is not saved for this lane. Run ade git stash list --lane .`, + ); + } + throw new CliUsageError( + `Stash ${stashRef} is not saved for this lane. Run ade git stash list --lane .`, + ); +} + function buildGitPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "status"; if (sub === "actions") { @@ -2966,14 +2993,10 @@ function buildGitPlan(args: string[]): CliPlan { } if (sub === "stash") { const action = firstPositional(args) ?? "list"; - const stashRef = - readValue(args, ["--ref", "--stash-ref"]) ?? firstPositional(args); + const stashOid = readValue(args, ["--oid", "--stash-oid"]); + const stashRef = readValue(args, ["--ref", "--stash-ref"]) ?? firstPositional(args); const message = readValue(args, ["--message", "-m"]); - const common = withLane({ - ...(stashRef ? { stashRef } : {}), - includeUntracked: !readFlag(args, ["--tracked-only"]), - ...(message ? { message } : {}), - }); + const includeUntracked = !readFlag(args, ["--tracked-only"]); const toolNameByAction: Record = { push: "stash_push", save: "stash_push", @@ -2986,6 +3009,45 @@ function buildGitPlan(args: string[]): CliPlan { }; const toolName = toolNameByAction[action]; if (!toolName) throw new CliUsageError(`Unknown stash action '${action}'.`); + const stashRefTool = + toolName === "stash_apply" || toolName === "stash_pop" || toolName === "stash_drop"; + const common = withLane({ + ...(stashRef && stashRefTool ? { stashRef } : {}), + ...(stashOid && stashRefTool ? { stashOid } : {}), + ...(toolName === "stash_push" + ? { includeUntracked, ...(message ? { message } : {}) } + : {}), + }); + const needsStashSelection = stashRefTool && ( + !stashRef || ((toolName === "stash_pop" || toolName === "stash_drop") && !stashOid) + ); + if (needsStashSelection) { + const listArgs: JsonObject = {}; + if (typeof common.laneId === "string") listArgs.laneId = common.laneId; + return { + kind: "execute", + label: `git stash ${action}`, + steps: [ + actionCallStep("stashes", "list_stashes", listArgs), + { + key: "result", + method: "ade/actions/call", + params: (values) => { + const selection = resolveStashSelectionForCli(values.stashes, stashRef, stashOid); + return { + name: toolName, + arguments: { + ...common, + stashRef: selection.stashRef, + stashOid: selection.stashOid, + }, + }; + }, + unwrapToolResult: true, + }, + ], + }; + } return { kind: "execute", label: `git stash ${action}`, @@ -8451,6 +8513,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--owner-id", "--owner-kind", "--output", + "--oid", "--params-json", "--parent", "--parent-lane", @@ -8506,6 +8569,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--start-point", "--start-x", "--start-y", + "--stash-oid", "--stash-ref", "--step", "--step-id", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 8c6875369..e04cf40a5 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -911,9 +911,15 @@ function parseGitStashPushArgs(value: Record): GitStashPushArgs } function parseGitStashRefArgs(value: Record, action: string): GitStashRefArgs { + const stashOid = asTrimmedString(value.stashOid); + const destructive = action === "git.stashPop" || action === "git.stashDrop"; + if (destructive && !stashOid) { + throw new Error(`${action} requires stashOid.`); + } return { laneId: requireString(value.laneId, `${action} requires laneId.`), stashRef: requireString(value.stashRef, `${action} requires stashRef.`), + ...(stashOid ? { stashOid } : {}), }; } diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 62c28ae14..57c6f6c44 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -103,6 +103,45 @@ function claudeNativePackagesToPrune(platform) { .flatMap(([, packages]) => packages); } +function codexNativePackagesToPrune(platform) { + const byPlatform = { + darwin: [ + path.join("node_modules", "@openai", "codex-darwin-arm64"), + path.join("node_modules", "@openai", "codex-darwin-x64"), + ], + linux: [ + path.join("node_modules", "@openai", "codex-linux-arm64"), + path.join("node_modules", "@openai", "codex-linux-x64"), + ], + win32: [ + path.join("node_modules", "@openai", "codex-win32-arm64"), + path.join("node_modules", "@openai", "codex-win32-x64"), + ], + }; + return Object.entries(byPlatform) + .filter(([targetPlatform]) => targetPlatform !== platform) + .flatMap(([, packages]) => packages); +} + +function cursorNativePackagesToPrune(platform) { + const byPlatform = { + darwin: [ + path.join("node_modules", "@cursor", "sdk-darwin-arm64"), + path.join("node_modules", "@cursor", "sdk-darwin-x64"), + ], + linux: [ + path.join("node_modules", "@cursor", "sdk-linux-arm64"), + path.join("node_modules", "@cursor", "sdk-linux-x64"), + ], + win32: [ + path.join("node_modules", "@cursor", "sdk-win32-x64"), + ], + }; + return Object.entries(byPlatform) + .filter(([targetPlatform]) => targetPlatform !== platform) + .flatMap(([, packages]) => packages); +} + function openCodeNativePackagesToPrune() { const packages = [ "opencode-darwin-arm64", @@ -128,10 +167,32 @@ function pruneUnneededRuntimePayload(runtimeRoot, platform) { if (platform === "darwin") return; const commonNonRuntimePayload = [ ...claudeNativePackagesToPrune(platform), + ...codexNativePackagesToPrune(platform), + ...cursorNativePackagesToPrune(platform), ...openCodeNativePackagesToPrune(), path.join("node_modules", "node-pty", "deps"), path.join("node_modules", "node-pty", "src"), ]; + const win32X64OnlyPayload = platform === "win32" + ? [ + path.join("node_modules", "@anthropic-ai", "claude-agent-sdk-win32-arm64"), + path.join("node_modules", "@openai", "codex-win32-arm64"), + path.join( + "node_modules", + "@huggingface", + "transformers", + "node_modules", + "onnxruntime-node", + "bin", + "napi-v3", + "win32", + "arm64", + ), + path.join("node_modules", "node-pty", "build", "Release", "conpty"), + path.join("node_modules", "node-pty", "third_party", "conpty", "1.23.251008001", "win10-arm64"), + path.join("node_modules", "node-pty", "prebuilds", "win32-arm64"), + ] + : []; const platformPayload = { darwin: [ path.join("node_modules", "@anthropic-ai", "claude-agent-sdk", "vendor", "audio-capture", "arm64-linux"), @@ -173,6 +234,7 @@ function pruneUnneededRuntimePayload(runtimeRoot, platform) { }; const candidates = [ ...commonNonRuntimePayload, + ...win32X64OnlyPayload, ...(platformPayload[platform] ?? []), ]; const removed = candidates.filter((relativePath) => removeIfPresent(runtimeRoot, relativePath)); diff --git a/apps/desktop/scripts/ensure-ade-cli-build.cjs b/apps/desktop/scripts/ensure-ade-cli-build.cjs index dfb9c3f5f..14530480a 100644 --- a/apps/desktop/scripts/ensure-ade-cli-build.cjs +++ b/apps/desktop/scripts/ensure-ade-cli-build.cjs @@ -21,6 +21,11 @@ const sourceEntries = [ path.join(cliRoot, "package-lock.json"), path.join(cliRoot, "tsconfig.json"), path.join(cliRoot, "tsup.config.ts"), + path.join(desktopRoot, "src", "main"), + path.join(desktopRoot, "src", "shared"), + path.join(desktopRoot, "package.json"), + path.join(desktopRoot, "package-lock.json"), + path.join(desktopRoot, "tsconfig.json"), ]; function newestMtimeMs(entryPath) { diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index a1df4dff5..2a6d92749 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -13,7 +13,9 @@ const packageJsonPath = path.join(desktopRoot, "package.json"); const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); const productName = pkg.build?.productName ?? pkg.productName ?? "ADE"; const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; -const DEFAULT_MAX_UNPACKED_BYTES = 600 * 1024 * 1024; +// The unpacked runtime includes x64 Codex, Claude, OpenCode, node-pty, and +// ONNX payloads. Keep a ceiling, but size it to the current required toolset. +const DEFAULT_MAX_UNPACKED_BYTES = 720 * 1024 * 1024; const REMOTE_RUNTIME_TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; function readFlag(name) { @@ -442,6 +444,24 @@ async function validatePackageHygiene(resourcesPath) { await assertPathMissing(path.join(unpackedPath, "node_modules", "node-pty", "src"), "node-pty source tree"); await assertPathMissing(path.join(unpackedPath, "node_modules", "node-pty", "prebuilds", "darwin-arm64"), "macOS node-pty arm64 prebuild in Windows package"); await assertPathMissing(path.join(unpackedPath, "node_modules", "node-pty", "prebuilds", "darwin-x64"), "macOS node-pty x64 prebuild in Windows package"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "node-pty", "prebuilds", "win32-arm64"), "Windows arm64 node-pty prebuild in Windows x64 package"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "@anthropic-ai", "claude-agent-sdk-win32-arm64"), "Claude Windows arm64 payload in Windows x64 package"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "@openai", "codex-win32-arm64"), "Codex Windows arm64 payload in Windows x64 package"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "@openai", "codex-darwin-arm64"), "Codex macOS arm64 payload in Windows package"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "@openai", "codex-darwin-x64"), "Codex macOS x64 payload in Windows package"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "@openai", "codex-linux-arm64"), "Codex Linux arm64 payload in Windows package"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "@openai", "codex-linux-x64"), "Codex Linux x64 payload in Windows package"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "@cursor", "sdk-darwin-arm64"), "Cursor macOS arm64 payload in Windows package"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "@cursor", "sdk-darwin-x64"), "Cursor macOS x64 payload in Windows package"); + await assertPathMissing( + path.join(unpackedPath, "node_modules", "@huggingface", "transformers", "node_modules", "onnxruntime-node", "bin", "napi-v3", "win32", "arm64"), + "Windows arm64 ONNX Runtime payload in Windows x64 package", + ); + await assertPathMissing(path.join(unpackedPath, "node_modules", "node-pty", "build", "Release", "conpty"), "duplicate node-pty build conpty payload in Windows package"); + await assertPathMissing( + path.join(unpackedPath, "node_modules", "node-pty", "third_party", "conpty", "1.23.251008001", "win10-arm64"), + "node-pty Windows arm64 conpty payload in Windows x64 package", + ); await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-windows-x64"), "duplicate OpenCode Windows x64 payload in Windows package"); await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-windows-x64-baseline"), "baseline OpenCode Windows x64 payload in Windows package"); await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-windows-arm64"), "OpenCode Windows arm64 payload in Windows x64 package"); diff --git a/apps/desktop/src/main/rendererCsp.test.ts b/apps/desktop/src/main/rendererCsp.test.ts index 4f860cd97..6395fed5f 100644 --- a/apps/desktop/src/main/rendererCsp.test.ts +++ b/apps/desktop/src/main/rendererCsp.test.ts @@ -19,4 +19,11 @@ describe("buildRendererCspPolicy", () => { expect(policy).toContain("frame-src 'self' file: app: http://localhost:* http://127.0.0.1:* about:"); }); + + it("does not allow arbitrary public Google Cloud Storage image beacons", () => { + const policy = buildRendererCspPolicy(false); + + expect(policy).toContain("img-src"); + expect(policy).not.toContain("https://storage.googleapis.com"); + }); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 4c9e2b9cb..a8b99fb20 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -335,6 +335,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { expect(toolKeys).toContain("listAutomationRuns"); }); + it("resolves the latest branch stash before popping when no stash ref is provided", async () => { + const gitService = { + listStashes: vi.fn().mockResolvedValue([ + { oid: "oid-3", ref: "stash@{3}", subject: "feature/lane: latest branch stash", createdAt: "2026-03-16T00:00:00.000Z" }, + ]), + stashPop: vi.fn().mockResolvedValue({ operationId: "stash-pop" }), + }; + const deps = buildDeps({ gitService: gitService as any }); + const tools = createCtoOperatorTools(deps); + + const result = await (tools.gitStashPop as any).execute({ laneId: "lane-1" }); + + expect(gitService.listStashes).toHaveBeenCalledWith({ laneId: "lane-1" }); + expect(gitService.stashPop).toHaveBeenCalledWith({ laneId: "lane-1", stashRef: "stash@{3}", stashOid: "oid-3" }); + expect(result).toMatchObject({ success: true, operationId: "stash-pop" }); + }); + + it("throws a lane-specific error when a requested branch stash is missing", async () => { + const gitService = { + listStashes: vi.fn().mockResolvedValue([ + { oid: "oid-3", ref: "stash@{3}", subject: "feature/lane: latest branch stash", createdAt: "2026-03-16T00:00:00.000Z" }, + ]), + stashPop: vi.fn(), + }; + const deps = buildDeps({ gitService: gitService as any }); + const tools = createCtoOperatorTools(deps); + + await expect((tools.gitStashPop as any).execute({ laneId: "lane-1", stashRef: "stash@{0}" })) + .resolves.toMatchObject({ + success: false, + error: "Stash stash@{0} is not saved for this lane branch.", + }); + + expect(gitService.stashPop).not.toHaveBeenCalled(); + }); + // ── Chat tools ────────────────────────────────────────────────── describe("chat tools", () => { diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index f0889819e..2937e2f2e 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -2622,19 +2622,36 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.stashPush({ laneId: resolveLaneId(laneId), ...(message?.trim() ? { message: message.trim() } : {}) })), }); tools.gitStashPop = tool({ - description: "Pop the latest stash in a lane.", - inputSchema: z.object({ laneId: z.string().optional() }), - execute: ({ laneId }) => gitGuard(() => deps.gitService!.stashPop({ laneId: resolveLaneId(laneId) })), + description: "Pop a stash saved for a lane branch. Defaults to the latest branch-matching stash.", + inputSchema: z.object({ laneId: z.string().optional(), stashRef: z.string().optional() }), + execute: ({ laneId, stashRef }) => gitGuard(async () => { + const resolvedLaneId = resolveLaneId(laneId); + const trimmedRef = stashRef?.trim(); + const stashes = await deps.gitService!.listStashes({ laneId: resolvedLaneId }); + const selectedStash = trimmedRef + ? stashes.find((stash) => stash.ref === trimmedRef) + : stashes[0]; + if (trimmedRef && !selectedStash) { + throw new Error(`Stash ${trimmedRef} is not saved for this lane branch.`); + } + const resolvedRef = trimmedRef || selectedStash?.ref; + if (!resolvedRef) throw new Error("No stashes are saved for this lane branch."); + return deps.gitService!.stashPop({ + laneId: resolvedLaneId, + stashRef: resolvedRef, + ...(selectedStash?.oid ? { stashOid: selectedStash.oid } : {}), + }); + }), }); tools.gitStashList = tool({ - description: "List stashes in a lane.", + description: "List stashes saved for a lane branch.", inputSchema: z.object({ laneId: z.string().optional() }), execute: ({ laneId }) => gitGuard(async () => { const stashes = await deps.gitService!.listStashes({ laneId: resolveLaneId(laneId) }); diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index e517234cf..f4faacf91 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -14,6 +14,8 @@ vi.mock("./git", () => ({ import { createGitOperationsService } from "./gitOperationsService"; +const STASH_LIST_FORMAT = "--format=%H%x1f%gd%x1f%cI%x1f%gs"; + function createTestGitOperationsService( branchRef = "feature/stash-test", overrides: { worktreePath?: string } = {}, @@ -67,17 +69,61 @@ describe("gitOperationsService.stashClear", () => { vi.clearAllMocks(); }); - it("calls git stash clear with the lane worktree path and returns the action result", async () => { + it("drops only stashes saved for the lane branch", async () => { mockGit.getHeadSha.mockResolvedValue("abc123"); - mockGit.runGitOrThrow.mockResolvedValue(undefined); + mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => { + if (args[0] === "stash" && args[1] === "list") { + return [ + "oid-0\u001fstash@{0}\u001f2026-05-12T02:09:32-04:00\u001fOn feature/stash-test: keep for this lane", + "oid-other\u001fstash@{1}\u001f2026-05-12T02:08:32-04:00\u001fOn other-branch: leave alone", + "oid-2\u001fstash@{2}\u001f2026-05-12T02:07:32-04:00\u001fWIP on feature/stash-test: abc123 work", + ].join("\n"); + } + if (args[0] === "rev-parse" && args[1] === "--verify") { + if (args[2] === "stash@{2}") return "oid-2\n"; + if (args[2] === "stash@{0}") return "oid-0\n"; + } + return undefined; + }); const { service, mockStart, mockFinish, mockInvalidateListCache } = createTestGitOperationsService(); const result = await service.stashClear({ laneId: "lane-1" }); - expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( - ["stash", "clear"], + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 1, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 2, + ["stash", "list", STASH_LIST_FORMAT], { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 3, + ["rev-parse", "--verify", "stash@{2}"], + { cwd: "/tmp/ade-lane", timeoutMs: 8_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 4, + ["stash", "drop", "stash@{2}"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 5, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 6, + ["rev-parse", "--verify", "stash@{0}"], + { cwd: "/tmp/ade-lane", timeoutMs: 8_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 7, + ["stash", "drop", "stash@{0}"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); expect(result).toEqual({ operationId: "op-1", preHeadSha: "abc123", @@ -97,6 +143,35 @@ describe("gitOperationsService.stashClear", () => { ); expect(mockInvalidateListCache).toHaveBeenCalledTimes(1); }); + + it("re-resolves stash refs by oid before each clear drop when ordinals shift", async () => { + mockGit.getHeadSha.mockResolvedValue("abc123"); + const initial = "oid-lane\u001fstash@{2}\u001f2026-05-12T02:07:32-04:00\u001fOn feature/stash-test: shifted"; + const shifted = "oid-lane\u001fstash@{1}\u001f2026-05-12T02:07:32-04:00\u001fOn feature/stash-test: shifted"; + let listCalls = 0; + mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => { + if (args[0] === "stash" && args[1] === "list") { + listCalls += 1; + return listCalls === 1 ? initial : shifted; + } + if (args[0] === "rev-parse" && args[1] === "--verify") return "oid-lane\n"; + return undefined; + }); + const { service } = createTestGitOperationsService(); + + await service.stashClear({ laneId: "lane-1" }); + + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 3, + ["rev-parse", "--verify", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 8_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 4, + ["stash", "drop", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); + }); }); describe("gitOperationsService.getSyncStatus", () => { @@ -133,43 +208,97 @@ describe("gitOperationsService.getSyncStatus", () => { }); describe("gitOperationsService stash item commands", () => { + const branchStashList = [ + "oid-0\u001fstash@{0}\u001f2026-05-12T02:09:32-04:00\u001fOn feature/stash-test: first", + "oid-1\u001fstash@{1}\u001f2026-05-12T02:08:32-04:00\u001fWIP on feature/stash-test: abc123 work", + "oid-2\u001fstash@{2}\u001f2026-05-12T02:07:32-04:00\u001fOn other-branch: hidden", + ].join("\n"); + const resolveStashOid = (stashRef: string | undefined): string | undefined => { + if (stashRef === "stash@{0}") return "oid-0\n"; + if (stashRef === "stash@{1}") return "oid-1\n"; + if (stashRef === "stash@{2}") return "oid-2\n"; + return undefined; + }; + beforeEach(() => { vi.clearAllMocks(); }); it("lists stashes with ordinal refs and ISO timestamps", async () => { - mockGit.runGitOrThrow.mockResolvedValue("stash@{0}\u001f2026-05-12T02:09:32-04:00\u001fOn main: test\n"); + mockGit.runGitOrThrow.mockResolvedValue([ + "oid-0\u001fstash@{0}\u001f2026-05-12T02:09:32-04:00\u001fOn feature/stash-test: test", + "oid-1\u001fstash@{1}\u001f2026-05-12T02:08:32-04:00\u001fWIP on feature/stash-test: abc123 work", + "oid-2\u001fstash@{2}\u001f2026-05-12T02:07:32-04:00\u001fOn other-branch: hidden", + ].join("\n")); const { service } = createTestGitOperationsService(); await expect(service.listStashes({ laneId: "lane-1" })).resolves.toEqual([ { + oid: "oid-0", ref: "stash@{0}", createdAt: "2026-05-12T02:09:32-04:00", - subject: "On main: test", + subject: "On feature/stash-test: test", + }, + { + oid: "oid-1", + ref: "stash@{1}", + createdAt: "2026-05-12T02:08:32-04:00", + subject: "WIP on feature/stash-test: abc123 work", }, ]); expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( - ["stash", "list", "--format=%gd%x1f%cI%x1f%gs"], + ["stash", "list", STASH_LIST_FORMAT], { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, ); }); it("applies then drops a stash when restoring it", async () => { mockGit.getHeadSha.mockResolvedValue("abc123"); - mockGit.runGitOrThrow.mockResolvedValue(undefined); + mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => { + if (args[0] === "stash" && args[1] === "list") return branchStashList; + if (args[0] === "rev-parse" && args[1] === "--verify") return resolveStashOid(args[2]); + return undefined; + }); const { service, mockStart, mockFinish } = createTestGitOperationsService(); - const result = await service.stashPop({ laneId: "lane-1", stashRef: "stash@{1}" }); + const result = await service.stashPop({ laneId: "lane-1", stashRef: "stash@{1}", stashOid: "oid-1" }); - expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 1, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 2, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 3, + ["rev-parse", "--verify", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 8_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 4, ["stash", "apply", "stash@{1}"], { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, ); - expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 5, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 6, + ["rev-parse", "--verify", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 8_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 7, ["stash", "drop", "stash@{1}"], { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, ); - expect(mockGit.runGitOrThrow).toHaveBeenCalledTimes(2); + expect(mockGit.runGitOrThrow).toHaveBeenCalledTimes(7); expect(result).toEqual({ operationId: "op-1", preHeadSha: "abc123", @@ -179,7 +308,7 @@ describe("gitOperationsService stash item commands", () => { expect.objectContaining({ laneId: "lane-1", kind: "git_stash_pop", - metadata: expect.objectContaining({ stashRef: "stash@{1}" }), + metadata: expect.objectContaining({ stashRef: "stash@{1}", stashOid: "oid-1" }), }), ); expect(mockFinish).toHaveBeenCalledWith( @@ -192,48 +321,82 @@ describe("gitOperationsService stash item commands", () => { it("keeps the stash when restore apply fails", async () => { mockGit.getHeadSha.mockResolvedValue("abc123"); - mockGit.runGitOrThrow.mockRejectedValueOnce(new Error("apply failed")); + mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => { + if (args[0] === "stash" && args[1] === "list") return branchStashList; + if (args[0] === "rev-parse" && args[1] === "--verify") return resolveStashOid(args[2]); + if (args[0] === "stash" && args[1] === "apply") throw new Error("apply failed"); + return undefined; + }); const { service } = createTestGitOperationsService(); - await expect(service.stashPop({ laneId: "lane-1", stashRef: "stash@{1}" })).rejects.toThrow("apply failed"); + await expect(service.stashPop({ laneId: "lane-1", stashRef: "stash@{1}", stashOid: "oid-1" })).rejects.toThrow("apply failed"); - expect(mockGit.runGitOrThrow).toHaveBeenCalledTimes(1); - expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + expect(mockGit.runGitOrThrow).toHaveBeenCalledTimes(4); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 4, ["stash", "apply", "stash@{1}"], { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, ); }); - it("reports restore success when drop fails after apply", async () => { + it("succeeds restore when drop fails after apply", async () => { mockGit.getHeadSha.mockResolvedValue("abc123"); - mockGit.runGitOrThrow - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error("drop failed")); + mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => { + if (args[0] === "stash" && args[1] === "list") return branchStashList; + if (args[0] === "rev-parse" && args[1] === "--verify") return resolveStashOid(args[2]); + if (args[0] === "stash" && args[1] === "drop") throw new Error("drop failed"); + return undefined; + }); const { service, mockFinish, mockLogger } = createTestGitOperationsService(); - const result = await service.stashPop({ laneId: "lane-1", stashRef: "stash@{1}" }); + const result = await service.stashPop({ laneId: "lane-1", stashRef: "stash@{1}", stashOid: "oid-1" }); expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( 1, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 2, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 3, + ["rev-parse", "--verify", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 8_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 4, ["stash", "apply", "stash@{1}"], { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, ); expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( - 2, + 5, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 6, + ["rev-parse", "--verify", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 8_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 7, ["stash", "drop", "stash@{1}"], { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, ); - expect(result).toEqual({ - operationId: "op-1", - preHeadSha: "abc123", - postHeadSha: "abc123", - }); expect(mockFinish).toHaveBeenCalledWith( expect.objectContaining({ operationId: "op-1", status: "succeeded", }), ); + expect(result).toEqual({ + operationId: "op-1", + preHeadSha: "abc123", + postHeadSha: "abc123", + }); expect(mockLogger.warn).toHaveBeenCalledWith( "git.stash_pop_drop_failed", expect.objectContaining({ @@ -246,12 +409,32 @@ describe("gitOperationsService stash item commands", () => { it("calls git stash drop with the lane worktree path and stash ref", async () => { mockGit.getHeadSha.mockResolvedValue("abc123"); - mockGit.runGitOrThrow.mockResolvedValue(undefined); + mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => { + if (args[0] === "stash" && args[1] === "list") return branchStashList; + if (args[0] === "rev-parse" && args[1] === "--verify") return resolveStashOid(args[2]); + return undefined; + }); const { service, mockStart, mockFinish } = createTestGitOperationsService(); - const result = await service.stashDrop({ laneId: "lane-1", stashRef: "stash@{0}" }); + const result = await service.stashDrop({ laneId: "lane-1", stashRef: "stash@{0}", stashOid: "oid-0" }); - expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 1, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 2, + ["stash", "list", STASH_LIST_FORMAT], + { cwd: "/tmp/ade-lane", timeoutMs: 15_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 3, + ["rev-parse", "--verify", "stash@{0}"], + { cwd: "/tmp/ade-lane", timeoutMs: 8_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 4, ["stash", "drop", "stash@{0}"], { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, ); @@ -264,7 +447,7 @@ describe("gitOperationsService stash item commands", () => { expect.objectContaining({ laneId: "lane-1", kind: "git_stash_drop", - metadata: expect.objectContaining({ stashRef: "stash@{0}" }), + metadata: expect.objectContaining({ stashRef: "stash@{0}", stashOid: "oid-0" }), }), ); expect(mockFinish).toHaveBeenCalledWith( @@ -274,6 +457,66 @@ describe("gitOperationsService stash item commands", () => { }), ); }); + + it("uses stash oid when the selected ordinal shifted before dropping it", async () => { + mockGit.getHeadSha.mockResolvedValue("abc123"); + mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => { + if (args[0] === "stash" && args[1] === "list") { + return [ + "oid-new\u001fstash@{0}\u001f2026-05-12T02:10:32-04:00\u001fOn feature/stash-test: newer", + "oid-0\u001fstash@{1}\u001f2026-05-12T02:09:32-04:00\u001fOn feature/stash-test: shifted", + ].join("\n"); + } + if (args[0] === "rev-parse" && args[1] === "--verify") { + if (args[2] === "stash@{0}") return "oid-new\n"; + if (args[2] === "stash@{1}") return "oid-0\n"; + } + return undefined; + }); + const { service, mockStart } = createTestGitOperationsService(); + + await service.stashDrop({ laneId: "lane-1", stashRef: "stash@{0}", stashOid: "oid-0" }); + + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 3, + ["rev-parse", "--verify", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 8_000 }, + ); + expect(mockGit.runGitOrThrow).toHaveBeenNthCalledWith( + 4, + ["stash", "drop", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + kind: "git_stash_drop", + metadata: expect.objectContaining({ stashRef: "stash@{0}", stashOid: "oid-0" }), + }), + ); + }); + + it("requires a stash oid before destructive stash actions", async () => { + const { service } = createTestGitOperationsService(); + + await expect(service.stashPop({ laneId: "lane-1", stashRef: "stash@{0}" })) + .rejects.toThrow("stashOid is required to pop a saved stash"); + await expect(service.stashDrop({ laneId: "lane-1", stashRef: "stash@{0}" })) + .rejects.toThrow("stashOid is required to drop a saved stash"); + expect(mockGit.runGitOrThrow).not.toHaveBeenCalled(); + }); + + it("does not apply a stash from another branch", async () => { + mockGit.getHeadSha.mockResolvedValue("abc123"); + mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => + args[0] === "stash" && args[1] === "list" ? branchStashList : undefined + ); + const { service } = createTestGitOperationsService(); + + await expect(service.stashApply({ laneId: "lane-1", stashRef: "stash@{2}" })) + .rejects.toThrow("Stash stash@{2} is not saved for branch feature/stash-test."); + expect(mockGit.runGitOrThrow).toHaveBeenCalledTimes(1); + }); }); describe("gitOperationsService.commit", () => { diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 7a28467d8..7886b335d 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -47,6 +47,10 @@ type LaneInfo = { linearIssue?: LaneLinearIssue | null; }; +type BranchStashSummary = GitStashSummary & { + oid: string; +}; + type CommitMessagePromptContext = { hasStagedChanges: boolean; stagedFiles: string; @@ -106,6 +110,52 @@ function parseDelimited(line: string): string[] { return line.split("\u001f"); } +function normalizeBranchForStashComparison(ref: string): string { + return localBranchNameForConfig(ref).trim(); +} + +function branchNameFromStashSubject(subject: string): string | null { + const trimmed = subject.trim(); + const onMatch = /^On ([^:]+):/.exec(trimmed); + if (onMatch) return onMatch[1]?.trim() || null; + const wipMatch = /^WIP on ([^:]+):/.exec(trimmed); + if (wipMatch) return wipMatch[1]?.trim() || null; + return null; +} + +function stashMatchesBranch(subject: string, branchRef: string): boolean { + const branch = normalizeBranchForStashComparison(branchRef); + if (!branch) return false; + return branchNameFromStashSubject(subject) === branch; +} + +function stashOrdinal(ref: string): number | null { + const match = /^stash@\{(\d+)\}$/.exec(ref.trim()); + if (!match) return null; + const ordinal = Number(match[1]); + return Number.isSafeInteger(ordinal) ? ordinal : null; +} + +function sortStashesForDrop(stashes: T[]): T[] { + return stashes.slice().sort((left, right) => { + const leftOrdinal = stashOrdinal(left.ref); + const rightOrdinal = stashOrdinal(right.ref); + if (leftOrdinal != null && rightOrdinal != null) return rightOrdinal - leftOrdinal; + if (leftOrdinal != null) return -1; + if (rightOrdinal != null) return 1; + return right.ref.localeCompare(left.ref); + }); +} + +function toPublicStash(stash: BranchStashSummary): GitStashSummary { + return { + oid: stash.oid, + ref: stash.ref, + subject: stash.subject, + createdAt: stash.createdAt, + }; +} + async function isWorktreeDirty(worktreePath: string): Promise { const res = await runGit(["status", "--porcelain=v1"], { cwd: worktreePath, timeoutMs: 8_000 }); if (res.exitCode !== 0) return false; @@ -164,6 +214,12 @@ export function createGitOperationsService({ } } + function invalidateStashReadCaches(): void { + for (const key of laneReadCache.keys()) { + if (key.startsWith("stashes:")) laneReadCache.delete(key); + } + } + async function readLaneCached(key: string, ttlMs: number, load: () => Promise): Promise { const now = Date.now(); const cached = laneReadCache.get(key) as CachedReadEntry | undefined; @@ -222,6 +278,56 @@ export function createGitOperationsService({ return cleaned.slice(0, 72).trimEnd(); } + async function listBranchStashes(lane: LaneInfo): Promise { + const out = await runGitOrThrow(["stash", "list", "--format=%H%x1f%gd%x1f%cI%x1f%gs"], { + cwd: lane.worktreePath, + timeoutMs: 15_000 + }); + return out + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line): BranchStashSummary | null => { + const [oid, ref, createdAt, subject] = parseDelimited(line); + if (!oid || !ref) return null; + return { + oid, + ref, + createdAt: createdAt && createdAt.length ? createdAt : null, + subject: subject ?? "" + }; + }) + .filter((entry): entry is BranchStashSummary => entry != null) + .filter((entry) => stashMatchesBranch(entry.subject, lane.branchRef)); + } + + async function requireBranchStash(lane: LaneInfo, stashRef: string, stashOid?: string): Promise { + const normalizedOid = stashOid?.trim(); + const stash = (await listBranchStashes(lane)).find((entry) => + normalizedOid?.length ? entry.oid === normalizedOid : entry.ref === stashRef + ); + if (!stash) { + const branch = normalizeBranchForStashComparison(lane.branchRef); + throw new Error(`Stash ${stashRef} is not saved for branch ${branch || lane.branchRef}.`); + } + return stash; + } + + async function resolveCurrentBranchStashRef(lane: LaneInfo, stash: BranchStashSummary): Promise { + const current = (await listBranchStashes(lane)).find((entry) => entry.oid === stash.oid); + if (!current) { + throw new Error(`Stash ${stash.ref} changed before ADE could clear it. Refresh stashes and try again.`); + } + const resolved = await runGitOrThrow(["rev-parse", "--verify", current.ref], { + cwd: lane.worktreePath, + timeoutMs: 8_000, + }); + if (resolved.trim() !== stash.oid) { + throw new Error(`Stash ${current.ref} changed before ADE could clear it. Refresh stashes and try again.`); + } + return current.ref; + } + async function assertCommitMessageGenerationEnabled(): Promise { if (!aiIntegrationService.getFeatureFlag("commit_messages")) { throw new Error("AI commit messages are off. Enable Commit Messages in Settings or type a commit message manually."); @@ -913,6 +1019,7 @@ export function createGitOperationsService({ async stashPush(args: GitStashPushArgs): Promise { const message = args.message?.trim(); + invalidateStashReadCaches(); const { action } = await runLaneOperation({ laneId: args.laneId, kind: "git_stash_push", @@ -930,6 +1037,7 @@ export function createGitOperationsService({ await runGitOrThrow(cmd, { cwd: lane.worktreePath, timeoutMs: 30_000 }); } }); + invalidateStashReadCaches(); return action; }, @@ -937,37 +1045,23 @@ export function createGitOperationsService({ const laneId = args.laneId.trim(); return readLaneCached(`stashes:${laneId}:default`, 1_500, async () => { const lane = laneService.getLaneBaseAndBranch(laneId); - const out = await runGitOrThrow(["stash", "list", "--format=%gd%x1f%cI%x1f%gs"], { - cwd: lane.worktreePath, - timeoutMs: 15_000 - }); - return out - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line): GitStashSummary | null => { - const [ref, createdAt, subject] = parseDelimited(line); - if (!ref) return null; - return { - ref, - createdAt: createdAt && createdAt.length ? createdAt : null, - subject: subject ?? "" - }; - }) - .filter((entry): entry is GitStashSummary => entry != null); + return (await listBranchStashes(lane)).map(toPublicStash); }); }, async stashApply(args: GitStashRefArgs): Promise { const stashRef = args.stashRef.trim(); + const stashOid = args.stashOid?.trim(); if (!stashRef.length) throw new Error("stashRef is required"); const { action } = await runLaneOperation({ laneId: args.laneId, kind: "git_stash_apply", reason: "stash_apply", - metadata: { stashRef }, + metadata: { stashRef, stashOid: stashOid || null }, fn: async (lane) => { - await runGitOrThrow(["stash", "apply", stashRef], { cwd: lane.worktreePath, timeoutMs: 30_000 }); + const stash = await requireBranchStash(lane, stashRef, stashOid); + const currentRef = await resolveCurrentBranchStashRef(lane, stash); + await runGitOrThrow(["stash", "apply", currentRef], { cwd: lane.worktreePath, timeoutMs: 30_000 }); } }); return action; @@ -975,23 +1069,31 @@ export function createGitOperationsService({ async stashPop(args: GitStashRefArgs): Promise { const stashRef = args.stashRef.trim(); + const stashOid = args.stashOid?.trim(); if (!stashRef.length) throw new Error("stashRef is required"); + if (!stashOid?.length) throw new Error("stashOid is required to pop a saved stash. Refresh stashes and try again."); const { action } = await runLaneOperation({ laneId: args.laneId, kind: "git_stash_pop", reason: "stash_pop", - metadata: { stashRef }, + metadata: { stashRef, stashOid: stashOid || null }, fn: async (lane) => { - await runGitOrThrow(["stash", "apply", stashRef], { cwd: lane.worktreePath, timeoutMs: 30_000 }); + const stash = await requireBranchStash(lane, stashRef, stashOid); + const currentApplyRef = await resolveCurrentBranchStashRef(lane, stash); + await runGitOrThrow(["stash", "apply", currentApplyRef], { cwd: lane.worktreePath, timeoutMs: 30_000 }); try { - await runGitOrThrow(["stash", "drop", stashRef], { cwd: lane.worktreePath, timeoutMs: 30_000 }); + const currentDropRef = await resolveCurrentBranchStashRef(lane, stash); + await runGitOrThrow(["stash", "drop", currentDropRef], { cwd: lane.worktreePath, timeoutMs: 30_000 }); + invalidateStashReadCaches(); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.warn("git.stash_pop_drop_failed", { laneId: args.laneId, stashRef, + stashOid: stashOid || null, error: message, }); + // Apply already succeeded; retrying pop would conflict with the applied changes. } } }); @@ -1000,29 +1102,41 @@ export function createGitOperationsService({ async stashDrop(args: GitStashRefArgs): Promise { const stashRef = args.stashRef.trim(); + const stashOid = args.stashOid?.trim(); if (!stashRef.length) throw new Error("stashRef is required"); + if (!stashOid?.length) throw new Error("stashOid is required to drop a saved stash. Refresh stashes and try again."); + invalidateStashReadCaches(); const { action } = await runLaneOperation({ laneId: args.laneId, kind: "git_stash_drop", reason: "stash_drop", - metadata: { stashRef }, + metadata: { stashRef, stashOid: stashOid || null }, fn: async (lane) => { - await runGitOrThrow(["stash", "drop", stashRef], { cwd: lane.worktreePath, timeoutMs: 30_000 }); + const stash = await requireBranchStash(lane, stashRef, stashOid); + const currentRef = await resolveCurrentBranchStashRef(lane, stash); + await runGitOrThrow(["stash", "drop", currentRef], { cwd: lane.worktreePath, timeoutMs: 30_000 }); } }); + invalidateStashReadCaches(); return action; }, async stashClear(args: { laneId: string }): Promise { + invalidateStashReadCaches(); const { action } = await runLaneOperation({ laneId: args.laneId, kind: "git_stash_clear", reason: "stash_clear", metadata: {}, fn: async (lane) => { - await runGitOrThrow(["stash", "clear"], { cwd: lane.worktreePath, timeoutMs: 15_000 }); + const stashes = sortStashesForDrop(await listBranchStashes(lane)); + for (const stash of stashes) { + const currentRef = await resolveCurrentBranchStashRef(lane, stash); + await runGitOrThrow(["stash", "drop", currentRef], { cwd: lane.worktreePath, timeoutMs: 30_000 }); + } } }); + invalidateStashReadCaches(); return action; }, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 52ca5f9ae..8069e00b8 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -146,6 +146,9 @@ import type { GitSyncArgs, GitHubRepoRef, GitHubStatus, + CreateLaneFromPrBranchArgs, + CreateLaneFromPrBranchPreflightResult, + CreateLaneFromPrBranchResult, CreatePrFromLaneArgs, CreateIntegrationPrArgs, CreateIntegrationPrResult, @@ -8276,17 +8279,37 @@ export function registerIpc({ return result; }); + const ensurePrMutationContext = (): AppContext => { + const ctx = getCtx(); + if (!ctx.prService || !ctx.prPollingService) { + throw new Error("PR service is not available for this project window."); + } + return ctx; + }; + + ipcMain.handle(IPC.prsPreflightCreateLaneFromPrBranch, async (_event, arg: CreateLaneFromPrBranchArgs): Promise => { + const ctx = ensurePrMutationContext(); + return await ctx.prService.preflightCreateLaneFromPrBranch(arg); + }); + + ipcMain.handle(IPC.prsCreateLaneFromPrBranch, async (_event, arg: CreateLaneFromPrBranchArgs): Promise => { + const ctx = ensurePrMutationContext(); + const result = await ctx.prService.createLaneFromPrBranch(arg); + ctx.prPollingService.poke(); + return result; + }); + const ensurePrPolling = () => { const ctx = getCtx(); - // PR services are only attached to fully-initialised project contexts. When - // the renderer fires PR queries for an unscoped window (e.g. a brand-new - // File > New Window before a project is chosen) or during a transition, - // ctx is dormant and these services are null. Return null so callers can - // hand back empty results instead of throwing into IPC. if (!ctx.prPollingService || !ctx.prService) return null; ctx.prPollingService.start(); return ctx; }; + const ensurePrReadContext = (): AppContext => { + const ctx = ensurePrPolling(); + if (!ctx) throw new Error("PR service is not available for this project window."); + return ctx; + }; ipcMain.handle(IPC.prsGetForLane, async (_event, arg: { laneId: string }): Promise => { const ctx = getCtx(); @@ -8295,26 +8318,22 @@ export function registerIpc({ }); ipcMain.handle(IPC.prsListAll, async (): Promise => { - const ctx = ensurePrPolling(); - if (!ctx) return []; + const ctx = ensurePrReadContext(); return ctx.prService.listAll(); }); ipcMain.handle(IPC.prsListOpenForRepo, async (): Promise => { - const ctx = ensurePrPolling(); - if (!ctx) return []; + const ctx = ensurePrReadContext(); return await ctx.prService.listOpenPullRequests(); }); ipcMain.handle(IPC.prsRefresh, async (_event, arg: { prId?: string; prIds?: string[] } = {}): Promise => { - const ctx = ensurePrPolling(); - if (!ctx) return []; + const ctx = ensurePrReadContext(); return await ctx.prService.refresh(arg); }); ipcMain.handle(IPC.prsGetStatus, async (_event, arg: { prId: string }): Promise => { - const ctx = ensurePrPolling(); - if (!ctx) return null; + const ctx = ensurePrReadContext(); try { return await ctx.prService.getStatus(arg.prId); } catch (err) { @@ -8325,8 +8344,7 @@ export function registerIpc({ }); ipcMain.handle(IPC.prsGetChecks, async (_event, arg: { prId: string }): Promise => { - const ctx = ensurePrPolling(); - if (!ctx) return []; + const ctx = ensurePrReadContext(); try { return await ctx.prService.getChecks(arg.prId); } catch (err) { @@ -8336,8 +8354,7 @@ export function registerIpc({ }); ipcMain.handle(IPC.prsGetComments, async (_event, arg: { prId: string }): Promise => { - const ctx = ensurePrPolling(); - if (!ctx) return []; + const ctx = ensurePrReadContext(); try { return await ctx.prService.getComments(arg.prId); } catch (err) { @@ -8347,8 +8364,7 @@ export function registerIpc({ }); ipcMain.handle(IPC.prsGetReviews, async (_event, arg: { prId: string }): Promise => { - const ctx = ensurePrPolling(); - if (!ctx) return []; + const ctx = ensurePrReadContext(); try { return await ctx.prService.getReviews(arg.prId); } catch (err) { @@ -8358,8 +8374,7 @@ export function registerIpc({ }); ipcMain.handle(IPC.prsGetReviewThreads, async (_event, arg: { prId: string }): Promise => { - const ctx = ensurePrPolling(); - if (!ctx) return []; + const ctx = ensurePrReadContext(); try { return await ctx.prService.getReviewThreads(arg.prId); } catch (err) { @@ -8425,37 +8440,35 @@ export function registerIpc({ return result; }); - ipcMain.handle(IPC.prsGetConflictAnalysis, async (_event, arg: { prId: string }) => getCtx().prService.getConflictAnalysis(arg.prId)); + ipcMain.handle(IPC.prsGetConflictAnalysis, async (_event, arg: { prId: string }) => { + const ctx = ensurePrReadContext(); + return ctx.prService.getConflictAnalysis(arg.prId); + }); - ipcMain.handle(IPC.prsGetMergeContext, async (_event, arg: { prId: string }): Promise => getCtx().prService.getMergeContext(arg.prId)); + ipcMain.handle(IPC.prsGetMergeContext, async (_event, arg: { prId: string }): Promise => { + const ctx = ensurePrReadContext(); + return ctx.prService.getMergeContext(arg.prId); + }); - ipcMain.handle(IPC.prsGetMergeContexts, async (_event, arg: { prIds?: string[] }): Promise> => - getCtx().prService.getMergeContexts(Array.isArray(arg?.prIds) ? arg.prIds : []) - ); + ipcMain.handle(IPC.prsGetMergeContexts, async (_event, arg: { prIds?: string[] }): Promise> => { + const ctx = ensurePrReadContext(); + return ctx.prService.getMergeContexts(Array.isArray(arg?.prIds) ? arg.prIds : []); + }); ipcMain.handle(IPC.prsListWithConflicts, async (_event, arg?: { includeConflictAnalysis?: boolean }) => { - const ctx = ensurePrPolling(); - if (!ctx) return []; + const ctx = ensurePrReadContext(); return ctx.prService.listWithConflicts({ includeConflictAnalysis: arg?.includeConflictAnalysis === true, }); }); - ipcMain.handle(IPC.prsListSnapshots, async (_event, arg?: { prId?: string }) => - getCtx().prService.listSnapshots({ prId: typeof arg?.prId === "string" ? arg.prId : undefined }) - ); + ipcMain.handle(IPC.prsListSnapshots, async (_event, arg?: { prId?: string }) => { + const ctx = ensurePrReadContext(); + return ctx.prService.listSnapshots({ prId: typeof arg?.prId === "string" ? arg.prId : undefined }); + }); ipcMain.handle(IPC.prsGetGitHubSnapshot, async (_event, arg?: { force?: boolean; includeExternalClosed?: boolean }): Promise => { - const ctx = ensurePrPolling(); - if (!ctx) { - return { - repo: null, - viewerLogin: null, - repoPullRequests: [], - externalPullRequests: [], - syncedAt: new Date(0).toISOString(), - }; - } + const ctx = ensurePrReadContext(); return await ctx.prService.getGithubSnapshot({ force: arg?.force === true, includeExternalClosed: arg?.includeExternalClosed === true, @@ -8511,17 +8524,18 @@ export function registerIpc({ ipcMain.handle(IPC.prsStartQueueAutomation, async (_event, arg) => { const ctx = getCtx(); + if (!ctx.queueLandingService) throw new Error("Queue automation is unavailable in this runtime."); return await ctx.queueLandingService.startQueue(arg); }); - ipcMain.handle(IPC.prsPauseQueueAutomation, async (_event, arg) => getCtx().queueLandingService.pauseQueue(arg.queueId)); + ipcMain.handle(IPC.prsPauseQueueAutomation, async (_event, arg) => getCtx().queueLandingService?.pauseQueue(arg.queueId) ?? null); ipcMain.handle(IPC.prsResumeQueueAutomation, async (_event, arg) => { const ctx = getCtx(); - return ctx.queueLandingService.resumeQueue(arg); + return ctx.queueLandingService?.resumeQueue(arg) ?? null; }); - ipcMain.handle(IPC.prsCancelQueueAutomation, async (_event, arg) => getCtx().queueLandingService.cancelQueue(arg.queueId)); + ipcMain.handle(IPC.prsCancelQueueAutomation, async (_event, arg) => getCtx().queueLandingService?.cancelQueue(arg.queueId) ?? null); ipcMain.handle(IPC.prsReorderQueue, async (_event, arg: ReorderQueuePrsArgs): Promise => { const ctx = getCtx(); @@ -8529,13 +8543,16 @@ export function registerIpc({ ctx.prPollingService.poke(); }); - ipcMain.handle(IPC.prsGetHealth, async (_event, arg: { prId: string }): Promise => getCtx().prService.getPrHealth(arg.prId)); + ipcMain.handle(IPC.prsGetHealth, async (_event, arg: { prId: string }): Promise => { + const ctx = ensurePrReadContext(); + return ctx.prService.getPrHealth(arg.prId); + }); ipcMain.handle(IPC.prsGetQueueState, async (_event, arg: { groupId: string }): Promise => - getCtx().queueLandingService.getQueueStateByGroup(arg.groupId) + getCtx().queueLandingService?.getQueueStateByGroup(arg.groupId) ?? null ); - ipcMain.handle(IPC.prsListQueueStates, async (_event, arg = {}) => getCtx().queueLandingService.listQueueStates(arg)); + ipcMain.handle(IPC.prsListQueueStates, async (_event, arg = {}) => getCtx().queueLandingService?.listQueueStates(arg) ?? []); ipcMain.handle(IPC.prsCreateIntegrationLaneForProposal, async (_event, arg: CreateIntegrationLaneForProposalArgs): Promise => getCtx().prService.createIntegrationLaneForProposal(arg)); @@ -8887,11 +8904,26 @@ export function registerIpc({ ); }); - ipcMain.handle(IPC.prsGetDetail, (_e, args: { prId: string }) => getCtx().prService.getDetail(args.prId)); - ipcMain.handle(IPC.prsGetFiles, (_e, args: { prId: string }) => getCtx().prService.getFiles(args.prId)); - ipcMain.handle(IPC.prsGetCommits, (_e, args: { prId: string }): Promise => getCtx().prService.getCommits(args.prId)); - ipcMain.handle(IPC.prsGetActionRuns, (_e, args: { prId: string }) => getCtx().prService.getActionRuns(args.prId)); - ipcMain.handle(IPC.prsGetActivity, (_e, args: { prId: string }) => getCtx().prService.getActivity(args.prId)); + ipcMain.handle(IPC.prsGetDetail, (_e, args: { prId: string }) => { + const ctx = ensurePrReadContext(); + return ctx.prService.getDetail(args.prId); + }); + ipcMain.handle(IPC.prsGetFiles, (_e, args: { prId: string }) => { + const ctx = ensurePrReadContext(); + return ctx.prService.getFiles(args.prId); + }); + ipcMain.handle(IPC.prsGetCommits, (_e, args: { prId: string }): Promise | PrCommit[] => { + const ctx = ensurePrReadContext(); + return ctx.prService.getCommits(args.prId); + }); + ipcMain.handle(IPC.prsGetActionRuns, (_e, args: { prId: string }) => { + const ctx = ensurePrReadContext(); + return ctx.prService.getActionRuns(args.prId); + }); + ipcMain.handle(IPC.prsGetActivity, (_e, args: { prId: string }) => { + const ctx = ensurePrReadContext(); + return ctx.prService.getActivity(args.prId); + }); ipcMain.handle(IPC.prsAddComment, (_e, args) => getCtx().prService.addComment(args)); ipcMain.handle(IPC.prsReplyToReviewThread, (_e, args: ReplyToPrReviewThreadArgs) => getCtx().prService.replyToReviewThread(args)); ipcMain.handle(IPC.prsResolveReviewThread, (_e, args: ResolvePrReviewThreadArgs) => getCtx().prService.resolveReviewThread(args)); @@ -8906,9 +8938,16 @@ export function registerIpc({ ipcMain.handle(IPC.prsAiReviewSummary, (_e, args) => getCtx().prService.aiReviewSummary(args)); // PRs Tab redesign (Timeline + Rails) - ipcMain.handle(IPC.prsGetDeployments, (_e, args: { prId: string }) => getCtx().prService.getDeployments(args.prId)); - ipcMain.handle(IPC.prsGetAiSummary, (_e, args: { prId: string }) => getCtx().prSummaryService.getSummary(args.prId)); - ipcMain.handle(IPC.prsRegenerateAiSummary, (_e, args: { prId: string }) => getCtx().prSummaryService.regenerateSummary(args.prId)); + ipcMain.handle(IPC.prsGetDeployments, (_e, args: { prId: string }) => { + const ctx = ensurePrReadContext(); + return ctx.prService.getDeployments(args.prId); + }); + ipcMain.handle(IPC.prsGetAiSummary, (_e, args: { prId: string }) => getCtx().prSummaryService?.getSummary(args.prId) ?? null); + ipcMain.handle(IPC.prsRegenerateAiSummary, (_e, args: { prId: string }) => { + const service = getCtx().prSummaryService; + if (!service) throw new Error("PR summary service is unavailable for this project window."); + return service.regenerateSummary(args.prId); + }); ipcMain.handle(IPC.prsPostReviewComment, (_e, args: PostPrReviewCommentArgs) => getCtx().prService.postReviewComment(args)); ipcMain.handle( IPC.prsSetReviewThreadResolved, diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 920d36fb3..ed06120ed 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3153,8 +3153,41 @@ describe("laneService delete teardown + cancellation + streaming", () => { "insert into pr_issue_inventory(id, pr_id, source, type, external_id, headline, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)", ["issue-child", "pr-child", "review", "comment", "1", "Fix it", now, now], ); + db.run( + ` + insert into pr_auto_link_ignores( + project_id, repo_owner, repo_name, github_pr_number, lane_id, head_branch, created_at + ) values (?, ?, ?, ?, ?, ?, ?) + `, + [projectId, "acme", "demo", 1, "lane-child", "feature/child", now], + ); db.run("insert into pr_convergence_state(pr_id, active_lane_id, created_at, updated_at) values (?, ?, ?, ?)", ["pr-child", "lane-child", now, now]); db.run("insert into pr_convergence_state(pr_id, active_lane_id, created_at, updated_at) values (?, ?, ?, ?)", ["pr-parent", "lane-child", now, now]); + db.run( + ` + insert into review_runs( + id, project_id, lane_id, target_json, config_json, target_label, + status, created_at, started_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ["review-run-child", projectId, "lane-child", "{}", "{}", "Lane review", "completed", now, now, now], + ); + db.run( + ` + insert into review_reviewer_runs( + id, run_id, reviewer_key, label, focus, status, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?) + `, + ["reviewer-run-child", "review-run-child", "diff-risk", "Diff risk", "Diff risk", "completed", now, now], + ); + db.run( + ` + insert into review_candidate_findings( + id, run_id, reviewer_run_id, reviewer_key, title, severity, body, anchor_state, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ["candidate-child", "review-run-child", "reviewer-run-child", "diff-risk", "Candidate", "medium", "Body", "anchored", now], + ); db.run( ` @@ -3205,7 +3238,11 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(count("pull_request_snapshots", "pr_id = ?", ["pr-child"])).toBe(0); expect(count("pr_pipeline_settings", "pr_id = ?", ["pr-child"])).toBe(0); expect(count("pr_issue_inventory", "pr_id = ?", ["pr-child"])).toBe(0); + expect(count("pr_auto_link_ignores", "lane_id = ?", ["lane-child"])).toBe(0); expect(db.get<{ active_lane_id: string | null }>("select active_lane_id from pr_convergence_state where pr_id = ?", ["pr-parent"])?.active_lane_id).toBeNull(); + expect(count("review_runs", "id = ?", ["review-run-child"])).toBe(0); + expect(count("review_reviewer_runs", "id = ?", ["reviewer-run-child"])).toBe(0); + expect(count("review_candidate_findings", "id = ?", ["candidate-child"])).toBe(0); expect(count("terminal_sessions", "lane_id = ?", ["lane-child"])).toBe(0); expect(count("session_deltas", "lane_id = ?", ["lane-child"])).toBe(0); expect(count("checkpoints", "lane_id = ?", ["lane-child"])).toBe(0); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index e0ab966f5..cec651e3a 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -1955,11 +1955,14 @@ 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 pr_auto_link_ignores 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_run_artifacts 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_candidate_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from review_reviewer_runs 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 file_directory_snapshots where workspace_id in (select id from files_workspaces where lane_id = ?)", [laneId]); diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 28fe8fe3c..a43f2d42c 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -143,6 +143,32 @@ function makeGitHubPull(overrides?: Partial>) { }; } +function makeUnmappedBranchPull(overrides?: Partial>) { + return makeGitHubPull({ + node_id: "PR_node_unmapped", + number: 404, + html_url: "https://github.com/test-owner/test-repo/pull/404", + title: "Unmapped branch PR", + base: { + ref: "main", + repo: { + owner: { login: REPO.owner }, + name: REPO.name, + }, + }, + head: { + ref: "feature/unmapped", + sha: "head-sha-unmapped", + user: { login: REPO.owner }, + repo: { + owner: { login: REPO.owner }, + name: REPO.name, + }, + }, + ...overrides, + }); +} + function makeGithubService(overrides?: Record) { return { getRepoOrThrow: vi.fn(async () => REPO), @@ -155,10 +181,21 @@ function makeGithubService(overrides?: Record) { } as any; } +function makeGithubStatus(overrides?: Record) { + return { + tokenStored: true, + connected: true, + repo: REPO, + userLogin: "octocat", + ...overrides, + }; +} + function makeLaneService(lanes?: unknown[]) { return { list: vi.fn(async () => lanes ?? [makeFakeLane()]), getLaneBaseAndBranch: vi.fn(), + delete: vi.fn(async () => undefined), } as any; } @@ -202,6 +239,16 @@ function buildService(opts: BuildServiceOpts = {}) { if (command === "fetch" || command === "push") { return { exitCode: 0, stdout: "", stderr: "" }; } + if (command === "ls-remote") { + const branch = String(args[3] ?? "feature/unmapped"); + return { exitCode: 0, stdout: `head-sha-unmapped\trefs/heads/${branch}\n`, stderr: "" }; + } + if (command === "rev-parse" && args[1] === "--verify" && String(args[2] ?? "").startsWith("refs/heads/")) { + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (command === "rev-parse" && args[1] === "HEAD") { + return { exitCode: 0, stdout: "head-sha-unmapped\n", stderr: "" }; + } // Make runGit succeed for upstream check (returns exitCode 0 → push path) return { exitCode: 0, stdout: "origin/my-feature", stderr: "" }; }); @@ -224,6 +271,96 @@ function buildService(opts: BuildServiceOpts = {}) { return { service, db, githubService, laneService, logger }; } +function serviceWithPrBranchActions(service: ReturnType["service"]) { + return service as typeof service & { + preflightCreateLaneFromPrBranch: (args: { prUrlOrNumber: string; laneName?: string }) => Promise; + createLaneFromPrBranch: (args: { prUrlOrNumber: string; laneName?: string }) => Promise; + }; +} + +function preflightDisposition(preflight: any): string { + if (typeof preflight?.status === "string") return preflight.status; + if (typeof preflight?.state === "string") return preflight.state; + if (typeof preflight?.ok === "boolean") return preflight.ok ? "ready" : "blocked"; + if (typeof preflight?.blocked === "boolean") return preflight.blocked ? "blocked" : "ready"; + return ""; +} + +function preflightConflicts(preflight: any): unknown[] { + if (Array.isArray(preflight?.blockingConflicts)) return preflight.blockingConflicts; + if (Array.isArray(preflight?.conflicts)) return preflight.conflicts; + if (Array.isArray(preflight?.blockers)) return preflight.blockers; + if (preflight?.blockingConflict) return [preflight.blockingConflict]; + return []; +} + +function installPullRequestRowStore(db: ReturnType, initialRows: any[] = []) { + const rows = [...initialRows]; + + db.get.mockImplementation((sql: string, params: unknown[] = []) => { + const text = String(sql); + if (!text.includes("from pull_requests")) return null; + if (text.includes("where id = ?")) { + return rows.find((row) => row.id === params[0] && row.project_id === params[1]) ?? null; + } + if (text.includes("lower(repo_owner)") && text.includes("github_pr_number")) { + const [projectIdParam, owner, name, prNumber] = params; + return rows.find((row) => + row.project_id === projectIdParam + && String(row.repo_owner).toLowerCase() === String(owner).toLowerCase() + && String(row.repo_name).toLowerCase() === String(name).toLowerCase() + && Number(row.github_pr_number) === Number(prNumber) + ) ?? null; + } + if (text.includes("where lane_id = ?")) { + return rows.find((row) => row.lane_id === params[0] && row.project_id === params[1]) ?? null; + } + return null; + }); + + db.all.mockImplementation((sql: string, params: unknown[] = []) => { + const text = String(sql); + if (!text.includes("from pull_requests")) return []; + if (text.includes("where lane_id = ?")) { + return rows.filter((row) => row.lane_id === params[0] && row.project_id === params[1]); + } + if (text.includes("where project_id = ?")) { + return rows.filter((row) => row.project_id === params[0]); + } + return rows; + }); + + db.run.mockImplementation((sql: string, params: unknown[] = []) => { + const text = String(sql); + if (!text.includes("insert into pull_requests(")) return undefined; + rows.push({ + id: params[0], + project_id: params[1], + lane_id: params[2], + repo_owner: params[3], + repo_name: params[4], + github_pr_number: params[5], + github_url: params[6], + github_node_id: params[7], + title: params[8], + state: params[9], + base_branch: params[10], + head_branch: params[11], + checks_status: params[12], + review_status: params[13], + additions: params[14], + deletions: params[15], + last_synced_at: params[16], + created_at: params[17], + updated_at: params[18], + creation_strategy: params[19] ?? null, + }); + return undefined; + }); + + return rows; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -375,6 +512,227 @@ describe("prService.getGithubSnapshot", () => { vi.clearAllMocks(); }); + it("fetches live GitHub data before serving cold-cache PR metadata", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn(async () => makeGithubStatus()), + apiRequest: vi.fn(async () => ({ + data: [ + makeGitHubPull({ + number: 321, + title: "Live PR", + html_url: "https://github.com/test-owner/test-repo/pull/321", + }), + ], + })), + }); + const db = makeMockDb(); + const cachedRow = makePrRow({ + github_pr_number: 321, + title: "Local cached PR", + last_synced_at: "2026-01-02T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }); + db.all.mockImplementation((sql: string) => { + const text = String(sql); + if (text.includes("from pull_requests")) return [cachedRow]; + return []; + }); + const { service } = buildService({ db, githubService, laneService: makeLaneService([makeFakeLane()]) }); + + const snapshot = await service.getGithubSnapshot(); + + expect(snapshot).toMatchObject({ + repo: REPO, + viewerLogin: "octocat", + repoPullRequests: [ + expect.objectContaining({ + githubPrNumber: 321, + title: "Live PR", + linkedPrId: "pr-row-1", + linkedLaneId: LANE_ID, + linkedLaneName: "my-feature", + adeKind: "single", + }), + ], + externalPullRequests: [], + syncedAt: expect.any(String), + }); + expect(githubService.getStatus).toHaveBeenCalledTimes(1); + expect(githubService.apiRequest).toHaveBeenCalledWith(expect.objectContaining({ + path: `/repos/${REPO.owner}/${REPO.name}/pulls`, + })); + }); + + it("does not inspect repository data when the token is missing", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn(async () => makeGithubStatus({ + tokenStored: false, + connected: false, + userLogin: null, + })), + apiRequest: vi.fn(async () => ({ data: [makeGitHubPull({ title: "Private live PR" })] })), + }); + const db = makeMockDb(); + db.all.mockImplementation(() => { + throw new Error("Repository state should not be inspected without a usable GitHub token."); + }); + const { service } = buildService({ db, githubService }); + + await expect(service.getGithubSnapshot()).rejects.toThrow("GitHub token missing"); + expect(db.all).not.toHaveBeenCalled(); + expect(githubService.apiRequest).not.toHaveBeenCalled(); + }); + + it("does not return an in-memory GitHub snapshot when token status is invalid", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn() + .mockResolvedValueOnce(makeGithubStatus()) + .mockResolvedValueOnce(makeGithubStatus({ + connected: false, + repoAccessError: "403: Resource not accessible by token", + })), + apiRequest: vi.fn(async () => ({ data: [makeGitHubPull({ title: "Private cached PR" })] })), + }); + const { service } = buildService({ githubService, laneService: makeLaneService([]) }); + + const cached = await service.getGithubSnapshot({ force: true }); + expect(cached.repoPullRequests[0]?.title).toBe("Private cached PR"); + githubService.apiRequest.mockClear(); + + await expect(service.getGithubSnapshot()).rejects.toThrow("GitHub token cannot access test-owner/test-repo"); + expect(githubService.apiRequest).not.toHaveBeenCalled(); + }); + + it("backfills branch PR auto-links during a live snapshot", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn(async () => makeGithubStatus()), + apiRequest: vi.fn(async (args: { path: string; query?: Record }) => { + if (args.path !== `/repos/${REPO.owner}/${REPO.name}/pulls`) { + throw new Error(`Unexpected GitHub API path: ${args.path}`); + } + if (args.query?.head === `${REPO.owner}:feature/missed`) { + return { + data: [ + makeGitHubPull({ + number: 654, + title: "Background linked PR", + head: { + ref: "feature/missed", + user: { login: REPO.owner }, + repo: { owner: { login: REPO.owner }, name: REPO.name }, + }, + }), + ], + }; + } + return { data: [] }; + }), + }); + const db = makeMockDb(); + db.all.mockImplementation((sql: string) => { + const text = String(sql); + if (text.includes("from pull_requests")) return [makePrRow({ title: "Already cached PR" })]; + return []; + }); + const laneService = makeLaneService([ + makeFakeLane(), + makeFakeLane({ id: "lane-missed", branchRef: "refs/heads/feature/missed" }), + ]); + const { service } = buildService({ db, githubService, laneService }); + + const snapshot = await service.getGithubSnapshot(); + + expect(snapshot.repoPullRequests[0]?.title).toBe("Background linked PR"); + + expect(githubService.apiRequest).toHaveBeenCalledWith(expect.objectContaining({ + query: expect.objectContaining({ head: `${REPO.owner}:feature/missed` }), + })); + expect(db.run).toHaveBeenCalledWith( + expect.stringContaining("insert into pull_requests("), + expect.arrayContaining(["lane-missed", REPO.owner, REPO.name, 654, "Background linked PR", "open", "main", "feature/missed"]), + ); + }); + + it("does not auto-link same-owner fork PRs to matching local lanes", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn(async () => makeGithubStatus()), + apiRequest: vi.fn(async (args: { path: string }) => { + if (args.path !== `/repos/${REPO.owner}/${REPO.name}/pulls`) { + throw new Error(`Unexpected GitHub API path: ${args.path}`); + } + return { + data: [ + makeGitHubPull({ + number: 655, + title: "Same owner fork PR", + head: { + ref: "feature/missed", + user: { login: REPO.owner }, + repo: { owner: { login: REPO.owner }, name: "fork-repo" }, + }, + }), + ], + }; + }), + }); + const db = makeMockDb(); + const laneService = makeLaneService([ + makeFakeLane({ id: "lane-missed", branchRef: "refs/heads/feature/missed" }), + ]); + const { service } = buildService({ db, githubService, laneService }); + + const snapshot = await service.getGithubSnapshot({ force: true }); + + expect(snapshot.repoPullRequests[0]).toEqual(expect.objectContaining({ + githubPrNumber: 655, + linkedPrId: null, + headRepoOwner: REPO.owner, + headRepoName: "fork-repo", + })); + expect(db.run.mock.calls.some(([sql]: [unknown]) => String(sql).includes("insert into pull_requests("))).toBe(false); + }); + + it("does not backfill a PR row when only an archived lane matches the head branch", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn(async () => makeGithubStatus()), + apiRequest: vi.fn(async (args: { path: string }) => { + if (args.path !== `/repos/${REPO.owner}/${REPO.name}/pulls`) { + throw new Error(`Unexpected GitHub API path: ${args.path}`); + } + return { + data: [ + makeGitHubPull({ + number: 656, + title: "Archived lane PR", + head: { + ref: "feature/archived", + user: { login: REPO.owner }, + repo: { owner: { login: REPO.owner }, name: REPO.name }, + }, + }), + ], + }; + }), + }); + const db = makeMockDb(); + const laneService = makeLaneService([ + makeFakeLane({ + id: "lane-archived", + branchRef: "refs/heads/feature/archived", + archivedAt: "2026-05-01T00:00:00.000Z", + }), + ]); + const { service } = buildService({ db, githubService, laneService }); + + const snapshot = await service.getGithubSnapshot({ force: true }); + + expect(snapshot.repoPullRequests[0]).toEqual(expect.objectContaining({ + githubPrNumber: 656, + linkedPrId: null, + })); + expect(db.run.mock.calls.some(([sql]: [unknown]) => String(sql).includes("insert into pull_requests("))).toBe(false); + }); + it("returns stale cached data immediately while revalidating in the background", async () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-01-01T00:00:00Z")); let resolveRevalidation!: (value: unknown) => void; @@ -382,11 +740,7 @@ describe("prService.getGithubSnapshot", () => { resolveRevalidation = resolve; }); const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn() .mockResolvedValueOnce({ data: [makeGitHubPull({ title: "Cached PR" })] }) .mockImplementationOnce(() => revalidationStarted), @@ -417,11 +771,7 @@ describe("prService.getGithubSnapshot", () => { it("keeps GitHub tab snapshots scoped to the current repo", async () => { const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn(async (args: { path: string }) => ({ data: args.path === "/search/issues" ? { items: [] } : [], })), @@ -449,11 +799,7 @@ describe("prService.getGithubSnapshot", () => { }); let repoCalls = 0; const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn(async (args: { path: string }) => { if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls`) { repoCalls += 1; @@ -483,11 +829,7 @@ describe("prService.getGithubSnapshot", () => { it("serves closed-history requests from a fresh repo snapshot cache", async () => { const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn(async (args: { path: string }) => { if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls`) { return { data: [makeGitHubPull({ number: 1, title: "Cached repo PR" })] }; @@ -518,11 +860,7 @@ describe("prService.getGithubSnapshot", () => { }); let repoCalls = 0; const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn(async (args: { path: string }) => { if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls`) { repoCalls += 1; @@ -565,11 +903,7 @@ describe("prService.getGithubSnapshot", () => { }); let repoCalls = 0; const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn(async (args: { path: string }) => { if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls`) { repoCalls += 1; @@ -604,11 +938,7 @@ describe("prService.getGithubSnapshot", () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(initialNow); let repoCalls = 0; const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn(async (args: { path: string; query?: { q?: string } }) => { if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls`) { repoCalls += 1; @@ -650,11 +980,7 @@ describe("prService.getGithubSnapshot", () => { it("backfills a lane PR row from GitHub when the head branch matches an active lane", async () => { const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn() .mockResolvedValueOnce({ data: [ @@ -694,11 +1020,7 @@ describe("prService.getGithubSnapshot", () => { it("fetches a targeted same-repo lane branch PR when the repo snapshot window misses it", async () => { const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn(async (args: { path: string; query?: Record }) => { if (args.path !== `/repos/${REPO.owner}/${REPO.name}/pulls`) { throw new Error(`Unexpected GitHub API path: ${args.path}`); @@ -761,11 +1083,7 @@ describe("prService.getGithubSnapshot", () => { it("continues targeted lane branch PR lookups after one branch lookup fails", async () => { const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn(async (args: { path: string; query?: Record }) => { if (args.path !== `/repos/${REPO.owner}/${REPO.name}/pulls`) { throw new Error(`Unexpected GitHub API path: ${args.path}`); @@ -834,11 +1152,7 @@ describe("prService.getGithubSnapshot", () => { it("updates an existing repo PR row during lane PR backfill instead of duplicating it", async () => { const githubService = makeGithubService({ - getStatus: vi.fn(async () => ({ - tokenStored: true, - repo: REPO, - userLogin: "octocat", - })), + getStatus: vi.fn(async () => makeGithubStatus()), apiRequest: vi.fn() .mockResolvedValueOnce({ data: [ @@ -1349,6 +1663,470 @@ describe("prService merge contexts", () => { }); }); +describe("prService.createLaneFromPrBranch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const prUrl = "https://github.com/test-owner/test-repo/pull/404"; + const primaryLane = makeFakeLane({ + id: "lane-primary", + name: "main", + laneType: "primary", + branchRef: "refs/heads/main", + baseRef: "refs/heads/main", + worktreePath: "/tmp/test-project", + parentLaneId: null, + }); + + function makeBranchPrGithubService(overrides?: Record) { + return makeGithubService({ + apiRequest: vi.fn(async (args: { path: string }) => { + if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls/404`) { + return { + data: makeUnmappedBranchPull(), + response: { status: 200, headers: new Headers() }, + }; + } + return { data: [], response: { status: 200, headers: new Headers() } }; + }), + ...overrides, + }); + } + + it("preflights an unmapped PR branch without creating a lane or PR row", async () => { + const githubService = makeBranchPrGithubService(); + const laneService = { + ...makeLaneService([primaryLane]), + importBranch: vi.fn(), + } as any; + const db = makeMockDb(); + installPullRequestRowStore(db); + const { service } = buildService({ db, githubService, laneService }); + + const result = await serviceWithPrBranchActions(service).preflightCreateLaneFromPrBranch({ + prUrlOrNumber: prUrl, + }); + + expect(result).toEqual(expect.objectContaining({ + preflight: expect.objectContaining({ + githubPrNumber: 404, + headBranch: "feature/unmapped", + baseBranch: "main", + }), + lane: null, + })); + expect(preflightDisposition(result.preflight)).toBe("ready"); + expect(preflightConflicts(result.preflight)).toEqual([]); + expect(laneService.importBranch).not.toHaveBeenCalled(); + expect(db.run).not.toHaveBeenCalledWith( + expect.stringContaining("insert into pull_requests("), + expect.anything(), + ); + }); + + it("blocks fork PR branches before trying to import from origin", async () => { + const githubService = makeBranchPrGithubService({ + apiRequest: vi.fn(async (args: { path: string }) => { + if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls/404`) { + return { + data: makeUnmappedBranchPull({ + head: { + ref: "feature/unmapped", + sha: "head-sha-unmapped", + user: { login: "fork-owner" }, + repo: { + owner: { login: "fork-owner" }, + name: "fork-repo", + }, + }, + }), + response: { status: 200, headers: new Headers() }, + }; + } + return { data: [], response: { status: 200, headers: new Headers() } }; + }), + }); + const laneService = { + ...makeLaneService([primaryLane]), + importBranch: vi.fn(), + } as any; + const db = makeMockDb(); + installPullRequestRowStore(db); + const { service } = buildService({ db, githubService, laneService }); + + const result = await serviceWithPrBranchActions(service).preflightCreateLaneFromPrBranch({ + prUrlOrNumber: prUrl, + }); + + expect(preflightDisposition(result.preflight)).toBe("blocked"); + expect(preflightConflicts(result.preflight)).toEqual([ + expect.objectContaining({ code: "fork_unavailable" }), + ]); + expect(JSON.stringify(preflightConflicts(result.preflight))).toMatch(/fork-owner|fork-repo|cannot be imported/i); + expect(result.lane ?? null).toBeNull(); + expect(laneService.importBranch).not.toHaveBeenCalled(); + expect(mockGit.runGit.mock.calls.some(([args]) => Array.isArray(args) && args[0] === "ls-remote")).toBe(false); + }); + + it("blocks PR branches when GitHub omits the head repository", async () => { + const githubService = makeBranchPrGithubService({ + apiRequest: vi.fn(async (args: { path: string }) => { + if (args.path === `/repos/${REPO.owner}/${REPO.name}/pulls/404`) { + return { + data: makeUnmappedBranchPull({ + head: { + ref: "feature/unmapped", + sha: "head-sha-unmapped", + user: { login: REPO.owner }, + repo: null, + }, + }), + response: { status: 200, headers: new Headers() }, + }; + } + return { data: [], response: { status: 200, headers: new Headers() } }; + }), + }); + const laneService = { + ...makeLaneService([primaryLane]), + importBranch: vi.fn(), + } as any; + const db = makeMockDb(); + installPullRequestRowStore(db); + const { service } = buildService({ db, githubService, laneService }); + + const result = await serviceWithPrBranchActions(service).preflightCreateLaneFromPrBranch({ + prUrlOrNumber: prUrl, + }); + + expect(preflightDisposition(result.preflight)).toBe("blocked"); + expect(preflightConflicts(result.preflight)).toEqual([ + expect.objectContaining({ code: "fork_unavailable" }), + ]); + expect(JSON.stringify(preflightConflicts(result.preflight))).toMatch(/test-owner|unknown repository|cannot be imported/i); + expect(result.lane ?? null).toBeNull(); + expect(laneService.importBranch).not.toHaveBeenCalled(); + expect(mockGit.runGit.mock.calls.some(([args]) => Array.isArray(args) && args[0] === "ls-remote")).toBe(false); + }); + + it("creates a lane from the PR branch, maps the PR to that lane, and returns lane/pr summaries", async () => { + const importedLane = makeFakeLane({ + id: "lane-imported", + name: "Unmapped branch PR", + branchRef: "refs/heads/feature/unmapped", + baseRef: "refs/heads/main", + worktreePath: "/tmp/test-project/.ade/worktrees/feature-unmapped", + parentLaneId: null, + }); + let branchImported = false; + const laneService = { + ...makeLaneService(), + list: vi.fn(async () => branchImported ? [primaryLane, importedLane] : [primaryLane]), + importBranch: vi.fn(async () => { + branchImported = true; + return importedLane; + }), + } as any; + const githubService = makeBranchPrGithubService(); + const db = makeMockDb(); + installPullRequestRowStore(db); + const { service } = buildService({ db, githubService, laneService }); + + const result = await serviceWithPrBranchActions(service).createLaneFromPrBranch({ + prUrlOrNumber: prUrl, + laneName: "Unmapped branch PR", + }); + + expect(laneService.importBranch).toHaveBeenCalledWith(expect.objectContaining({ + name: "Unmapped branch PR", + baseBranch: "main", + })); + expect(laneService.importBranch.mock.calls[0]?.[0]?.branchRef).toMatch(/feature\/unmapped$/); + expect(db.run).toHaveBeenCalledWith( + expect.stringContaining("insert into pull_requests("), + expect.arrayContaining(["lane-imported", REPO.owner, REPO.name, 404, "Unmapped branch PR", "open", "main", "feature/unmapped"]), + ); + expect(result).toEqual(expect.objectContaining({ + preflight: expect.objectContaining({ + githubPrNumber: 404, + headBranch: "feature/unmapped", + baseBranch: "main", + }), + lane: expect.objectContaining({ + id: "lane-imported", + branchRef: "refs/heads/feature/unmapped", + }), + pr: expect.objectContaining({ + laneId: "lane-imported", + githubPrNumber: 404, + headBranch: "feature/unmapped", + baseBranch: "main", + }), + })); + expect(preflightDisposition(result.preflight)).toBe("ready"); + }); + + it("blocks create when the remote branch moves after preflight", async () => { + const laneService = { + ...makeLaneService([primaryLane]), + importBranch: vi.fn(), + } as any; + const githubService = makeBranchPrGithubService(); + const db = makeMockDb(); + installPullRequestRowStore(db); + const { service } = buildService({ db, githubService, laneService }); + let lsRemoteCalls = 0; + mockGit.runGit.mockImplementation(async (args: unknown[]) => { + const command = Array.isArray(args) ? args[0] : null; + if (command === "ls-remote") { + lsRemoteCalls += 1; + const sha = lsRemoteCalls === 1 ? "head-sha-unmapped" : "moved-sha"; + return { exitCode: 0, stdout: `${sha}\trefs/heads/feature/unmapped\n`, stderr: "" }; + } + if (command === "rev-parse" && Array.isArray(args) && args[1] === "--verify") { + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (command === "worktree") return { exitCode: 0, stdout: "", stderr: "" }; + if (command === "fetch" || command === "push") return { exitCode: 0, stdout: "", stderr: "" }; + return { exitCode: 0, stdout: "origin/my-feature", stderr: "" }; + }); + + await expect(serviceWithPrBranchActions(service).createLaneFromPrBranch({ + prUrlOrNumber: prUrl, + laneName: "Unmapped branch PR", + })).rejects.toThrow(/does not match the current PR head/i); + + expect(lsRemoteCalls).toBe(2); + expect(laneService.importBranch).not.toHaveBeenCalled(); + }); + + it("blocks before importing when a stale local branch would shadow the PR head", async () => { + const laneService = { + ...makeLaneService([primaryLane]), + importBranch: vi.fn(), + delete: vi.fn(async () => undefined), + } as any; + const githubService = makeBranchPrGithubService(); + const db = makeMockDb(); + installPullRequestRowStore(db); + const { service } = buildService({ db, githubService, laneService }); + mockGit.runGit.mockImplementation(async (args: unknown[]) => { + const command = Array.isArray(args) ? args[0] : null; + if (command === "ls-remote") { + return { exitCode: 0, stdout: "head-sha-unmapped\trefs/heads/feature/unmapped\n", stderr: "" }; + } + if (command === "rev-parse" && Array.isArray(args) && args[1] === "--verify") { + return { exitCode: 0, stdout: "stale-sha\n", stderr: "" }; + } + if (command === "worktree") return { exitCode: 0, stdout: "", stderr: "" }; + if (command === "fetch" || command === "push") return { exitCode: 0, stdout: "", stderr: "" }; + return { exitCode: 0, stdout: "origin/my-feature", stderr: "" }; + }); + + await expect(serviceWithPrBranchActions(service).createLaneFromPrBranch({ + prUrlOrNumber: prUrl, + laneName: "Unmapped branch PR", + })).rejects.toThrow(/Local branch 'feature\/unmapped' is at stale-sha, but PR #404 is at head-sha-unmapped/i); + + expect(laneService.importBranch).not.toHaveBeenCalled(); + expect(laneService.delete).not.toHaveBeenCalled(); + }); + + it("cleans up the imported lane when the imported checkout is not at the PR head", async () => { + const importedLane = makeFakeLane({ + id: "lane-imported", + name: "Unmapped branch PR", + branchRef: "refs/heads/feature/unmapped", + baseRef: "refs/heads/main", + worktreePath: "/tmp/test-project/.ade/worktrees/feature-unmapped", + parentLaneId: null, + }); + let branchImported = false; + const laneService = { + ...makeLaneService(), + list: vi.fn(async () => branchImported ? [primaryLane, importedLane] : [primaryLane]), + importBranch: vi.fn(async () => { + branchImported = true; + return importedLane; + }), + delete: vi.fn(async () => undefined), + } as any; + const githubService = makeBranchPrGithubService(); + const db = makeMockDb(); + installPullRequestRowStore(db); + const { service } = buildService({ db, githubService, laneService }); + mockGit.runGit.mockImplementation(async (args: unknown[]) => { + const command = Array.isArray(args) ? args[0] : null; + if (command === "ls-remote") { + return { exitCode: 0, stdout: "head-sha-unmapped\trefs/heads/feature/unmapped\n", stderr: "" }; + } + if (command === "rev-parse" && Array.isArray(args) && args[1] === "--verify") { + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (command === "rev-parse" && Array.isArray(args) && args[1] === "HEAD") { + return { exitCode: 0, stdout: "stale-sha\n", stderr: "" }; + } + if (command === "worktree") return { exitCode: 0, stdout: "", stderr: "" }; + if (command === "fetch" || command === "push") return { exitCode: 0, stdout: "", stderr: "" }; + return { exitCode: 0, stdout: "origin/my-feature", stderr: "" }; + }); + + await expect(serviceWithPrBranchActions(service).createLaneFromPrBranch({ + prUrlOrNumber: prUrl, + laneName: "Unmapped branch PR", + })).rejects.toThrow(/is at stale-sha, but PR #404 is at head-sha-unmapped/i); + + expect(laneService.importBranch).toHaveBeenCalled(); + expect(laneService.delete).toHaveBeenCalledWith({ + laneId: "lane-imported", + deleteBranch: false, + deleteRemoteBranch: false, + force: true, + }); + expect(db.run).not.toHaveBeenCalledWith( + expect.stringContaining("insert into pull_requests("), + expect.anything(), + ); + }); + + it("cleans up the imported lane when PR linking fails", async () => { + const importedLane = makeFakeLane({ + id: "lane-imported", + name: "Unmapped branch PR", + branchRef: "refs/heads/feature/unmapped", + baseRef: "refs/heads/main", + worktreePath: "/tmp/test-project/.ade/worktrees/feature-unmapped", + parentLaneId: null, + }); + const existingLane = makeFakeLane({ + id: "lane-raced", + name: "Raced lane", + branchRef: "refs/heads/feature/raced", + }); + let branchImported = false; + const laneService = { + ...makeLaneService(), + list: vi.fn(async () => branchImported ? [primaryLane, importedLane, existingLane] : [primaryLane]), + importBranch: vi.fn(async () => { + branchImported = true; + rows.push(makePrRow({ + id: "pr-raced", + lane_id: "lane-raced", + github_pr_number: 404, + head_branch: "feature/unmapped", + })); + return importedLane; + }), + delete: vi.fn(async () => undefined), + } as any; + const githubService = makeBranchPrGithubService(); + const db = makeMockDb(); + const rows = installPullRequestRowStore(db); + const { service } = buildService({ db, githubService, laneService }); + + await expect(serviceWithPrBranchActions(service).createLaneFromPrBranch({ + prUrlOrNumber: prUrl, + laneName: "Unmapped branch PR", + })).rejects.toThrow(/already mapped to lane/i); + + expect(laneService.importBranch).toHaveBeenCalled(); + expect(laneService.delete).toHaveBeenCalledWith({ + laneId: "lane-imported", + deleteBranch: false, + deleteRemoteBranch: false, + force: true, + }); + }); + + it("blocks when the GitHub PR is already mapped to an ADE lane", async () => { + const existingPr = makePrRow({ + id: "pr-existing", + lane_id: "lane-existing", + github_pr_number: 404, + head_branch: "feature/unmapped", + }); + const existingLane = makeFakeLane({ + id: "lane-existing", + name: "Existing lane", + branchRef: "refs/heads/feature/other", + }); + const laneService = { + ...makeLaneService([primaryLane, existingLane]), + importBranch: vi.fn(), + } as any; + const db = makeMockDb(); + installPullRequestRowStore(db, [existingPr]); + const { service } = buildService({ + db, + githubService: makeBranchPrGithubService(), + laneService, + }); + + const result = await serviceWithPrBranchActions(service).preflightCreateLaneFromPrBranch({ + prUrlOrNumber: prUrl, + }); + + expect(preflightDisposition(result.preflight)).toBe("blocked"); + expect(JSON.stringify(preflightConflicts(result.preflight))).toMatch(/already|mapped|linked|existing/i); + expect(result.lane ?? null).toBeNull(); + expect(laneService.importBranch).not.toHaveBeenCalled(); + }); + + it("blocks when another ADE lane already owns the PR head branch", async () => { + const branchOwner = makeFakeLane({ + id: "lane-branch-owner", + name: "Branch owner", + branchRef: "refs/heads/feature/unmapped", + }); + const laneService = { + ...makeLaneService([primaryLane, branchOwner]), + importBranch: vi.fn(), + } as any; + const db = makeMockDb(); + installPullRequestRowStore(db); + const { service } = buildService({ + db, + githubService: makeBranchPrGithubService(), + laneService, + }); + + const result = await serviceWithPrBranchActions(service).preflightCreateLaneFromPrBranch({ + prUrlOrNumber: prUrl, + }); + + expect(preflightDisposition(result.preflight)).toBe("blocked"); + expect(JSON.stringify(preflightConflicts(result.preflight))).toMatch(/branch owner|feature\/unmapped|owned|already/i); + expect(result.lane ?? null).toBeNull(); + expect(laneService.importBranch).not.toHaveBeenCalled(); + }); +}); + +describe("prService.delete", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes cached PR children before deleting the PR row", async () => { + const db = makeMockDb(); + installPullRequestRowStore(db, [makePrRow()]); + const { service } = buildService({ db }); + + await service.delete({ prId: "pr-row-1", closeOnGitHub: false, archiveLane: false }); + + const runSql: string[] = db.run.mock.calls.map((call: unknown[]) => String(call[0])); + const summaryDeleteIndex = runSql.findIndex((sql: string) => sql.includes("delete from pull_request_ai_summaries")); + const snapshotDeleteIndex = runSql.findIndex((sql: string) => sql.includes("delete from pull_request_snapshots")); + const prDeleteIndex = runSql.findIndex((sql: string) => sql.includes("delete from pull_requests")); + + expect(summaryDeleteIndex).toBeGreaterThanOrEqual(0); + expect(snapshotDeleteIndex).toBeGreaterThanOrEqual(0); + expect(prDeleteIndex).toBeGreaterThanOrEqual(0); + expect(summaryDeleteIndex).toBeLessThan(prDeleteIndex); + expect(snapshotDeleteIndex).toBeLessThan(prDeleteIndex); + }); +}); + describe("prService.createFromLane", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 3444d794a..e382567fe 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -4,6 +4,11 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import type { BranchPullRequest, + CreateLaneFromPrBranchArgs, + CreateLaneFromPrBranchBlock, + CreateLaneFromPrBranchPreflight, + CreateLaneFromPrBranchPreflightResult, + CreateLaneFromPrBranchResult, CreatePrFromLaneArgs, CreateQueuePrsArgs, CreateQueuePrsResult, @@ -22,6 +27,7 @@ import type { DraftPrDescriptionArgs, DeletePrArgs, DeletePrResult, + GitHubStatus, IntegrationLaneChangeStatus, IntegrationLaneOrigin, IntegrationLaneSnapshot, @@ -171,6 +177,16 @@ type PullRequestRow = { creation_strategy: string | null; }; +type PrAutoLinkIgnoreRow = { + project_id: string; + repo_owner: string; + repo_name: string; + github_pr_number: number; + lane_id: string; + head_branch: string | null; + created_at: string; +}; + type LanePrLookupRow = { lane_type: string | null; branch_ref: string | null; @@ -618,6 +634,12 @@ function repoPrKey(owner: string, repo: string, number: number): string { return `${owner.trim().toLowerCase()}/${repo.trim().toLowerCase()}#${Number(number)}`; } +function repoRefKey(repo: GitHubRepoRef | null | undefined): string | null { + const owner = repo?.owner?.trim().toLowerCase() ?? ""; + const name = repo?.name?.trim().toLowerCase() ?? ""; + return owner && name ? `${owner}/${name}` : null; +} + function readPrTemplate(projectRoot: string): string | null { const templatePath = path.join(projectRoot, ".github", "PULL_REQUEST_TEMPLATE.md"); if (!fs.existsSync(templatePath)) return null; @@ -1057,14 +1079,19 @@ export function createPrService({ } }; - const markHotRefresh = (prIds: string[]): void => { + const markHotRefresh = ( + prIds: string[], + options: { invalidateGithubSnapshot?: boolean } = {}, + ): void => { const nowMs = Date.now(); const uniquePrIds = [...new Set(prIds.map((prId) => String(prId ?? "").trim()).filter(Boolean))]; if (uniquePrIds.length === 0) return; for (const prId of uniquePrIds) { hotRefreshStartedAtByPrId.set(prId, nowMs); } - invalidateGithubSnapshotCache(); + if (options.invalidateGithubSnapshot !== false) { + invalidateGithubSnapshotCache(); + } onHotRefreshChanged?.(); }; @@ -1555,10 +1582,78 @@ export function createPrService({ const owner = rawPullHeadOwner(rawPr); const name = rawPullHeadRepoName(rawPr); if (!owner || owner.toLowerCase() !== repo.owner.toLowerCase()) return false; - if (name && name.toLowerCase() !== repo.name.toLowerCase()) return false; + if (!name || name.toLowerCase() !== repo.name.toLowerCase()) return false; return true; }; + const autoLinkIgnoreKey = (args: { + owner: string; + repo: string; + prNumber: number; + laneId: string; + }): string => `${repoPrKey(args.owner, args.repo, args.prNumber)}:${args.laneId}`; + + const listAutoLinkIgnores = (repo: GitHubRepoRef): Set => { + const rows = db.all( + `select project_id, repo_owner, repo_name, github_pr_number, lane_id, head_branch, created_at + from pr_auto_link_ignores + where project_id = ? + and lower(repo_owner) = lower(?) + and lower(repo_name) = lower(?)`, + [projectId, repo.owner, repo.name], + ); + return new Set(rows.map((row) => autoLinkIgnoreKey({ + owner: row.repo_owner, + repo: row.repo_name, + prNumber: Number(row.github_pr_number), + laneId: row.lane_id, + }))); + }; + + const rememberAutoLinkIgnore = (row: PullRequestRow): void => { + db.run( + ` + insert or replace into pr_auto_link_ignores( + project_id, + repo_owner, + repo_name, + github_pr_number, + lane_id, + head_branch, + created_at + ) values (?, ?, ?, ?, ?, ?, ?) + `, + [ + projectId, + row.repo_owner, + row.repo_name, + Number(row.github_pr_number), + row.lane_id, + row.head_branch, + nowIso(), + ], + ); + }; + + const clearAutoLinkIgnore = (args: { + repoOwner: string; + repoName: string; + githubPrNumber: number; + laneId: string; + }): void => { + db.run( + ` + delete from pr_auto_link_ignores + where project_id = ? + and lower(repo_owner) = lower(?) + and lower(repo_name) = lower(?) + and github_pr_number = ? + and lane_id = ? + `, + [projectId, args.repoOwner, args.repoName, Number(args.githubPrNumber), args.laneId], + ); + }; + const backfillLanePrRowsFromGithubPulls = (rawPulls: any[], repo: GitHubRepoRef, lanes: LaneSummary[]): number => { const activeLaneByBranch = new Map(); for (const lane of lanes) { @@ -1570,16 +1665,19 @@ export function createPrService({ if (activeLaneByBranch.size === 0) return 0; const backfilledIds: string[] = []; + const ignoredAutoLinks = listAutoLinkIgnores(repo); for (const rawPr of rawPulls) { const headBranch = rawPullHeadBranch(rawPr); const lane = headBranch ? activeLaneByBranch.get(headBranch) ?? null : null; if (!lane) continue; - const headOwner = rawPullHeadOwner(rawPr); - if (!headOwner || headOwner.toLowerCase() !== repo.owner.toLowerCase()) continue; + if (!rawPullHasSameRepoHead(rawPr, repo)) continue; const prNumber = asNumber(rawPr?.number); if (!prNumber) continue; + if (ignoredAutoLinks.has(autoLinkIgnoreKey({ owner: repo.owner, repo: repo.name, prNumber, laneId: lane.id }))) { + continue; + } const existingRepoRow = getRowForRepoPr(repo.owner, repo.name, prNumber); if (existingRepoRow && existingRepoRow.lane_id !== lane.id) continue; @@ -1610,12 +1708,18 @@ export function createPrService({ updatedAt: asString(rawPr?.updated_at) || nowIso(), creationStrategy: "pr_target", }; - backfilledIds.push(upsertRow(summary, { allowRepoPrAdoption: true })); + const prId = upsertRow(summary, { allowRepoPrAdoption: true }); + clearAutoLinkIgnore({ + repoOwner: repo.owner, + repoName: repo.name, + githubPrNumber: prNumber, + laneId: lane.id, + }); + backfilledIds.push(prId); } if (backfilledIds.length > 0) { - markHotRefresh(backfilledIds); - invalidateGithubSnapshotCache(); + markHotRefresh(backfilledIds, { invalidateGithubSnapshot: false }); } return backfilledIds.length; }; @@ -1877,6 +1981,317 @@ export function createPrService({ return data; }; + const createLaneFromPrBranchBlock = ( + code: CreateLaneFromPrBranchBlock["code"], + message: string, + extra: Omit = {}, + ): CreateLaneFromPrBranchBlock => ({ code, message, ...extra }); + + const resolveCreateLaneFromPrBranchLocator = async ( + args: CreateLaneFromPrBranchArgs, + ): Promise<{ repo: GitHubRepoRef; prNumber: number }> => { + const locatorText = asString(args.prUrlOrNumber).trim(); + if (locatorText) { + const locator = parsePrLocator(locatorText); + const repo = locator.owner && locator.repo + ? { owner: locator.owner, name: locator.repo } + : await githubService.getRepoOrThrow(); + return { repo, prNumber: locator.number }; + } + + const repoOwner = asString(args.repoOwner).trim(); + const repoName = asString(args.repoName).trim(); + const prNumber = Number(args.githubPrNumber ?? args.prNumber ?? 0); + if (!repoOwner || !repoName) throw new Error("Repository owner and name are required."); + if (!Number.isFinite(prNumber) || prNumber <= 0) throw new Error("GitHub PR number is required."); + return { repo: { owner: repoOwner, name: repoName }, prNumber }; + }; + + const parseRemoteBranchSha = (stdout: string, branchName: string): string | null => { + const suffix = `refs/heads/${branchName}`; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [sha, ref] = trimmed.split(/\s+/); + if (sha && (!ref || ref === suffix || ref.endsWith(suffix))) return sha; + } + const first = stdout.trim().split(/\s+/)[0]; + return first || null; + }; + + const findCheckedOutWorktreeForBranch = async (branchName: string): Promise => { + const res = await runGit(["worktree", "list", "--porcelain"], { + cwd: projectRoot, + timeoutMs: 10_000, + }); + if (res.exitCode !== 0) return null; + + for (const block of res.stdout.split(/\n\n+/)) { + const lines = block.split(/\r?\n/); + const worktreeLine = lines.find((line) => line.startsWith("worktree ")); + const branchLine = lines.find((line) => line.startsWith("branch ")); + const worktreePath = worktreeLine?.slice("worktree ".length).trim() ?? ""; + const checkedBranch = branchLine?.slice("branch ".length).trim() ?? ""; + if (!worktreePath || !checkedBranch) continue; + if (normalizeBranchName(branchNameFromRef(checkedBranch)) === branchName) { + return path.resolve(worktreePath); + } + } + return null; + }; + + const resolveLocalBranchForPrHead = async (args: { + headBranch: string; + headSha: string | null; + githubPrNumber: number; + }): Promise => { + if (!args.headSha) return null; + const localBranch = await runGit(["rev-parse", "--verify", `refs/heads/${args.headBranch}`], { + cwd: projectRoot, + timeoutMs: 8_000, + }); + if (localBranch.exitCode !== 0) return null; + const localSha = localBranch.stdout.trim(); + if (!localSha || localSha === args.headSha) return null; + return createLaneFromPrBranchBlock( + "local_branch_mismatch", + `Local branch '${args.headBranch}' is at ${localSha}, but PR #${args.githubPrNumber} is at ${args.headSha}. Update or delete the local branch, then try again.`, + ); + }; + + const resolveConfiguredRemoteBranch = async (args: { + headBranch: string; + headSha: string | null; + sameRepoHead: boolean; + }): Promise => { + const remoteRef = `origin/${args.headBranch}`; + const lsRemote = await runGit(["ls-remote", "--heads", "origin", args.headBranch], { + cwd: projectRoot, + timeoutMs: 30_000, + }); + let remoteSha = lsRemote.exitCode === 0 + ? parseRemoteBranchSha(lsRemote.stdout, args.headBranch) + : null; + + if (!remoteSha) { + await runGit(["fetch", "--prune", "origin"], { + cwd: projectRoot, + timeoutMs: 60_000, + }).catch(() => null); + const cached = await runGit(["rev-parse", "--verify", `refs/remotes/${remoteRef}`], { + cwd: projectRoot, + timeoutMs: 8_000, + }); + remoteSha = cached.exitCode === 0 ? cached.stdout.trim() || null : null; + } + + if (!remoteSha) { + return createLaneFromPrBranchBlock( + args.sameRepoHead ? "remote_branch_missing" : "fork_unavailable", + args.sameRepoHead + ? `Remote branch '${remoteRef}' was not found. It may have been deleted.` + : `Fork PR branch '${args.headBranch}' is not fetchable from the configured remote.`, + ); + } + + if (args.headSha && remoteSha !== args.headSha) { + return createLaneFromPrBranchBlock( + args.sameRepoHead ? "remote_branch_mismatch" : "fork_unavailable", + args.sameRepoHead + ? `Remote branch '${remoteRef}' does not match the current PR head. Refresh the branch and try again.` + : `Fork PR branch '${args.headBranch}' does not match a branch on the configured remote.`, + ); + } + + return null; + }; + + const buildCreateLaneFromPrBranchPreflight = async ( + args: CreateLaneFromPrBranchArgs, + ): Promise => { + const { repo, prNumber } = await resolveCreateLaneFromPrBranchLocator(args); + const pr = await fetchPr(repo, prNumber); + const githubPrNumber = Number(pr?.number) || prNumber; + const title = asString(pr?.title) || `PR #${githubPrNumber}`; + const githubUrl = asString(pr?.html_url) || `https://github.com/${repo.owner}/${repo.name}/pull/${githubPrNumber}`; + const headBranch = rawPullHeadBranch(pr) || null; + const headRepoOwner = rawPullHeadOwner(pr) || null; + const headRepoName = rawPullHeadRepoName(pr) || null; + const baseBranch = asString(pr?.base?.ref) || null; + const headSha = asString(pr?.head?.sha) || null; + const targetLaneName = asString(args.laneName).trim() || title || headBranch || `PR #${githubPrNumber}`; + const importBranchRef = headBranch ? `origin/${headBranch}` : null; + + const finish = (block: CreateLaneFromPrBranchBlock | null): CreateLaneFromPrBranchPreflight => ({ + repoOwner: repo.owner, + repoName: repo.name, + githubPrNumber, + githubUrl, + title, + headBranch, + headSha, + headRepoOwner, + headRepoName, + remoteBranch: importBranchRef, + importBranchRef, + targetLaneName, + baseBranch, + canCreate: block == null, + status: block == null ? "ready" : "blocked", + blockingConflict: block, + blockingConflicts: block ? [block] : [], + }); + + const existingPr = getRowForRepoPr(repo.owner, repo.name, githubPrNumber); + if (existingPr) { + const lane = (await laneService.list({ includeArchived: true, includeStatus: false })) + .find((entry) => entry.id === existingPr.lane_id); + return finish(createLaneFromPrBranchBlock( + "already_mapped", + `PR #${githubPrNumber} is already mapped to lane '${lane?.name ?? existingPr.lane_id}'.`, + { laneId: existingPr.lane_id, laneName: lane?.name ?? null }, + )); + } + + if (!headBranch) { + return finish(createLaneFromPrBranchBlock( + "missing_head_branch", + `PR #${githubPrNumber} does not have a head branch to import.`, + )); + } + + const lanes = await laneService.list({ includeArchived: true, includeStatus: false }); + const branchOwner = lanes.find((lane) => normalizeBranchName(branchNameFromRef(lane.branchRef)) === headBranch); + if (branchOwner) { + const isPrimary = branchOwner.laneType === "primary"; + const archived = branchOwner.archivedAt ? " archived" : ""; + return finish(createLaneFromPrBranchBlock( + isPrimary ? "default_branch" : "branch_owned", + isPrimary + ? `Branch '${headBranch}' is the primary workspace branch and cannot be imported as a lane.` + : `Branch '${headBranch}' is already owned by${archived} lane '${branchOwner.name}'.`, + { laneId: branchOwner.id, laneName: branchOwner.name }, + )); + } + + if (baseBranch && normalizeBranchName(baseBranch) === headBranch) { + return finish(createLaneFromPrBranchBlock( + "default_branch", + `PR #${githubPrNumber} uses '${headBranch}' as both head and base; ADE will not import the default branch as a lane.`, + )); + } + + const sameRepoHead = rawPullHasSameRepoHead(pr, repo); + if (!sameRepoHead) { + return finish(createLaneFromPrBranchBlock( + "fork_unavailable", + `Fork PR branch '${headRepoOwner ?? "unknown"}/${headRepoName ?? "unknown"}:${headBranch}' cannot be imported from the configured origin remote. Fetch the fork branch locally or add fork-remote import support before creating a lane.`, + )); + } + const remoteBlock = await resolveConfiguredRemoteBranch({ headBranch, headSha, sameRepoHead }); + if (remoteBlock) return finish(remoteBlock); + const localBranchBlock = await resolveLocalBranchForPrHead({ headBranch, headSha, githubPrNumber }); + if (localBranchBlock) return finish(localBranchBlock); + + const checkedOutPath = await findCheckedOutWorktreeForBranch(headBranch).catch(() => null); + if (checkedOutPath && path.resolve(checkedOutPath) !== path.resolve(projectRoot)) { + return finish(createLaneFromPrBranchBlock( + "worktree_collision", + `Branch '${headBranch}' is already checked out at '${checkedOutPath}'. Attach or remove that worktree before importing it as a lane.`, + { worktreePath: checkedOutPath }, + )); + } + + return finish(null); + }; + + const preflightCreateLaneFromPrBranch = async ( + args: CreateLaneFromPrBranchArgs, + ): Promise => { + const preflight = await buildCreateLaneFromPrBranchPreflight(args); + return { preflight, lane: null, pr: null }; + }; + + const createLaneFromPrBranch = async ( + args: CreateLaneFromPrBranchArgs, + ): Promise => { + const preflight = await buildCreateLaneFromPrBranchPreflight(args); + if (!preflight.canCreate || !preflight.importBranchRef || !preflight.headBranch) { + throw new Error(preflight.blockingConflict?.message || "PR branch cannot be imported as a lane."); + } + + const sameRepoHead = + Boolean(preflight.headRepoOwner && preflight.headRepoName) + && preflight.headRepoOwner?.toLowerCase() === preflight.repoOwner.toLowerCase() + && preflight.headRepoName?.toLowerCase() === preflight.repoName.toLowerCase(); + if (!sameRepoHead) { + throw new Error( + `Fork PR branch '${preflight.headRepoOwner ?? "unknown"}/${preflight.headRepoName ?? "unknown"}:${preflight.headBranch}' cannot be imported from the configured origin remote. Fetch the fork branch locally or add fork-remote import support before creating a lane.`, + ); + } + const remoteBlock = await resolveConfiguredRemoteBranch({ + headBranch: preflight.headBranch, + headSha: preflight.headSha, + sameRepoHead, + }); + if (remoteBlock) { + throw new Error(remoteBlock.message); + } + const localBranchBlock = await resolveLocalBranchForPrHead({ + headBranch: preflight.headBranch, + headSha: preflight.headSha, + githubPrNumber: preflight.githubPrNumber, + }); + if (localBranchBlock) { + throw new Error(localBranchBlock.message); + } + + let lane: Awaited> | null = null; + try { + lane = await laneService.importBranch({ + branchRef: preflight.importBranchRef, + name: preflight.targetLaneName, + ...(preflight.baseBranch ? { baseBranch: preflight.baseBranch } : {}), + }); + if (preflight.headSha) { + const importedHead = await runGit(["rev-parse", "HEAD"], { + cwd: lane.worktreePath, + timeoutMs: 8_000, + }); + const importedHeadSha = importedHead.exitCode === 0 ? importedHead.stdout.trim() : ""; + if (importedHeadSha !== preflight.headSha) { + throw new Error( + `Imported lane '${lane.name}' is at ${importedHeadSha || "an unknown commit"}, but PR #${preflight.githubPrNumber} is at ${preflight.headSha}. Fetch the PR branch and try again.`, + ); + } + } + const pr = await linkToLane({ + laneId: lane.id, + prUrlOrNumber: preflight.githubUrl, + }); + return { preflight, lane, pr }; + } catch (error) { + if (!lane) throw error; + try { + await laneService.delete({ + laneId: lane.id, + deleteBranch: false, + deleteRemoteBranch: false, + force: true, + }); + } catch (cleanupError) { + const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + logger.warn("prs.create_lane_from_pr_branch.cleanup_failed", { + laneId: lane.id, + error: cleanupMessage, + }); + const originalMessage = error instanceof Error ? error.message : String(error); + throw new Error(`${originalMessage} Imported lane cleanup failed: ${cleanupMessage}`); + } + throw error; + } + }; + const graphqlRequest = async (query: string, variables: Record): Promise => { const { data: payload } = await githubService.apiRequest<{ data?: T; @@ -2822,6 +3237,7 @@ export function createPrService({ const row = getRow(args.prId); if (!row) throw new Error(`PR not found: ${args.prId}`); const repo: GitHubRepoRef = { owner: row.repo_owner, name: row.repo_name }; + const localUnmapOnly = args.closeOnGitHub !== true && args.archiveLane !== true; let githubClosed = false; let githubCloseError: string | null = null; @@ -2843,6 +3259,10 @@ export function createPrService({ } } + if (localUnmapOnly) { + rememberAutoLinkIgnore(row); + } + db.run("delete from pr_group_members where pr_id = ?", [row.id]); db.run( ` @@ -2864,6 +3284,8 @@ export function createPrService({ db.run("delete from pr_convergence_state where pr_id = ?", [row.id]); db.run("delete from pr_pipeline_settings where pr_id = ?", [row.id]); db.run("delete from pr_issue_inventory where pr_id = ?", [row.id]); + db.run("delete from pull_request_ai_summaries where pr_id = ?", [row.id]); + db.run("delete from pull_request_snapshots where pr_id = ?", [row.id]); db.run("delete from pull_requests where id = ? and project_id = ?", [row.id, projectId]); let laneArchived = false; @@ -2878,6 +3300,8 @@ export function createPrService({ } } + invalidateGithubSnapshotCache(); + return { prId: row.id, laneId: row.lane_id, @@ -3172,6 +3596,12 @@ export function createPrService({ // with an already-existing PR for this branch, we need to adopt the row // that represents that PR (regardless of prior lane attribution). const prId = upsertRow(summary, { allowRepoPrAdoption: true }); + clearAutoLinkIgnore({ + repoOwner: repo.owner, + repoName: repo.name, + githubPrNumber: prNumber, + laneId: lane.id, + }); markHotRefresh([prId]); return await refreshOne(prId); @@ -3200,6 +3630,13 @@ export function createPrService({ // behavior (follow-up 3) instead of being treated as "unset". The // upsertRow path uses COALESCE so we never clobber an existing value. const existingRow = getRowForRepoPr(repo.owner, repo.name, locator.number); + if (existingRow && existingRow.lane_id !== lane.id) { + const existingLane = (await laneService.list({ includeArchived: true, includeStatus: false })) + .find((entry) => entry.id === existingRow.lane_id); + throw new Error( + `Cannot link PR #${locator.number} to lane "${lane.name}" because it is already mapped to lane "${existingLane?.name ?? existingRow.lane_id}".` + ); + } const creationStrategy: PrCreationStrategy = normalizePrCreationStrategy(existingRow?.creation_strategy) ?? "pr_target"; @@ -3227,6 +3664,12 @@ export function createPrService({ }; const prId = upsertRow(summary); + clearAutoLinkIgnore({ + repoOwner: repo.owner, + repoName: repo.name, + githubPrNumber: locator.number, + laneId: lane.id, + }); markHotRefresh([prId]); return await refreshOne(prId); }; @@ -5061,27 +5504,52 @@ export function createPrService({ cachedGithubSnapshotAt = capturedAt; }; - const getGithubSnapshotUncached = async (): Promise => { - const githubStatus = await githubService.getStatus(); + const clearGithubSnapshotAuthCache = (): void => { + invalidateGithubSnapshotCache(); + githubSnapshotInFlight = null; + }; + + const buildGithubSnapshotAuthError = (githubStatus: GitHubStatus): string => { if (!githubStatus.tokenStored) { - throw new Error("GitHub token missing. Set it in Settings to sync pull requests."); + return "GitHub token missing. Set it in Settings to sync pull requests."; } + if (githubStatus.tokenDecryptionFailed) { + return "GitHub token could not be decrypted. Reconnect GitHub in Settings to sync pull requests."; + } + if (githubStatus.repo && githubStatus.repoAccessError) { + return `GitHub token cannot access ${githubStatus.repo.owner}/${githubStatus.repo.name}: ${githubStatus.repoAccessError}. Update it in Settings to sync pull requests.`; + } + return "GitHub token is invalid or missing required access. Update it in Settings to sync pull requests."; + }; - const repo = githubStatus.repo; - if (!repo) { - return { - repo: null, - viewerLogin: githubStatus.userLogin, - repoPullRequests: [], - externalPullRequests: [], - syncedAt: nowIso(), - }; + const requireGithubSnapshotAuth = async (): Promise => { + const githubStatus = await githubService.getStatus(); + if (!githubStatus.tokenStored || !githubStatus.connected) { + clearGithubSnapshotAuthCache(); + throw new Error(buildGithubSnapshotAuthError(githubStatus)); } + return githubStatus; + }; + const githubSnapshotMatchesStatus = ( + snapshot: GitHubPrSnapshot, + githubStatus: GitHubStatus, + ): boolean => repoRefKey(snapshot.repo) === repoRefKey(githubStatus.repo); + + type GithubSnapshotMetadata = { + lanes: LaneSummary[]; + laneById: Map; + pullRequestRows: PullRequestRow[]; + linkedPrByRepoKey: Map; + groupByPrId: Map; + workflowByPrId: Map; + }; + + const loadGithubSnapshotMetadata = async (): Promise => { const lanes = await laneService.list({ includeArchived: true, includeStatus: false }); const laneById = new Map(lanes.map((lane) => [lane.id, lane])); - let pullRequestRows = listRows(); - let linkedPrByRepoKey = new Map( + const pullRequestRows = listRows(); + const linkedPrByRepoKey = new Map( pullRequestRows.map((row) => [repoPrKey(row.repo_owner, row.repo_name, Number(row.github_pr_number)), row] as const) ); const groupRows = db.all<{ pr_id: string; group_id: string; group_type: "queue" | "integration" }>( @@ -5099,17 +5567,45 @@ export function createPrService({ if (linkedPrId) workflowByPrId.set(linkedPrId, row); } - const deriveAdeKind = ( - workflow: IntegrationProposalRow | null, - group: { group_type: string } | null | undefined, - linked: PullRequestRow | null, - ): GitHubPrListItem["adeKind"] => { - if (workflow) return "integration"; - if (group?.group_type === "queue") return "queue"; - if (group?.group_type === "integration") return "integration"; - if (linked) return "single"; - return null; + return { + lanes, + laneById, + pullRequestRows, + linkedPrByRepoKey, + groupByPrId, + workflowByPrId, }; + }; + + const deriveGithubSnapshotAdeKind = ( + workflow: IntegrationProposalRow | null, + group: { group_type: string } | null | undefined, + linked: PullRequestRow | null, + ): GitHubPrListItem["adeKind"] => { + if (workflow) return "integration"; + if (group?.group_type === "queue") return "queue"; + if (group?.group_type === "integration") return "integration"; + if (linked) return "single"; + return null; + }; + + const getGithubSnapshotUncached = async ( + precheckedGithubStatus?: GitHubStatus, + ): Promise => { + const githubStatus = precheckedGithubStatus ?? await requireGithubSnapshotAuth(); + + const repo = githubStatus.repo; + if (!repo) { + return { + repo: null, + viewerLogin: githubStatus.userLogin, + repoPullRequests: [], + externalPullRequests: [], + syncedAt: nowIso(), + }; + } + + let metadata = await loadGithubSnapshotMetadata(); const toGitHubState = (rawPr: any): PrState => { if (rawPr?.merged_at) return "merged"; @@ -5127,9 +5623,9 @@ export function createPrService({ const repoOwner = asString(rawRepo?.owner?.login) || repositoryParts[0] || repo.owner; const repoName = asString(rawRepo?.name) || repositoryParts[1] || repo.name; const githubPrNumber = Number(rawPr?.number) || 0; - const linkedPrRow = linkedPrByRepoKey.get(repoPrKey(repoOwner, repoName, githubPrNumber)) ?? null; - const workflowRow = linkedPrRow ? workflowByPrId.get(linkedPrRow.id) ?? null : null; - const groupRow = linkedPrRow ? groupByPrId.get(linkedPrRow.id) ?? null : null; + const linkedPrRow = metadata.linkedPrByRepoKey.get(repoPrKey(repoOwner, repoName, githubPrNumber)) ?? null; + const workflowRow = linkedPrRow ? metadata.workflowByPrId.get(linkedPrRow.id) ?? null : null; + const groupRow = linkedPrRow ? metadata.groupByPrId.get(linkedPrRow.id) ?? null : null; return { id: asString(rawPr?.node_id) || `${scope}-${repoOwner}-${repoName}-${githubPrNumber}`, @@ -5151,8 +5647,8 @@ export function createPrService({ linkedPrId: linkedPrRow?.id ?? null, linkedGroupId: asString(workflowRow?.linked_group_id).trim() || groupRow?.group_id || null, linkedLaneId: linkedPrRow?.lane_id ?? null, - linkedLaneName: linkedPrRow ? (laneById.get(linkedPrRow.lane_id)?.name ?? linkedPrRow.lane_id) : null, - adeKind: deriveAdeKind(workflowRow, groupRow, linkedPrRow), + linkedLaneName: linkedPrRow ? (metadata.laneById.get(linkedPrRow.lane_id)?.name ?? linkedPrRow.lane_id) : null, + adeKind: deriveGithubSnapshotAdeKind(workflowRow, groupRow, linkedPrRow), workflowDisplayState: workflowRow ? parseWorkflowDisplayState(workflowRow.workflow_display_state) : null, cleanupState: workflowRow ? parseCleanupState(workflowRow.cleanup_state) : null, labels: Array.isArray(rawPr?.labels) @@ -5169,30 +5665,35 @@ export function createPrService({ path: `/repos/${repo.owner}/${repo.name}/pulls`, query: { state: "all", sort: "updated", direction: "desc" }, }); - repoPullRequestsRaw = await fetchMissingSameRepoLanePulls(repoPullRequestsRaw, repo, lanes); - if (backfillLanePrRowsFromGithubPulls(repoPullRequestsRaw, repo, lanes) > 0) { - pullRequestRows = listRows(); - linkedPrByRepoKey = new Map( - pullRequestRows.map((row) => [repoPrKey(row.repo_owner, row.repo_name, Number(row.github_pr_number)), row] as const) - ); + repoPullRequestsRaw = await fetchMissingSameRepoLanePulls(repoPullRequestsRaw, repo, metadata.lanes); + if (backfillLanePrRowsFromGithubPulls(repoPullRequestsRaw, repo, metadata.lanes) > 0) { + metadata = await loadGithubSnapshotMetadata(); } + const repoPullRequests = repoPullRequestsRaw.map((rawPr) => toGitHubItem(rawPr, "repo")); + const syncedAt = nowIso(); return { repo, viewerLogin: githubStatus.userLogin, - repoPullRequests: repoPullRequestsRaw.map((rawPr) => toGitHubItem(rawPr, "repo")), + repoPullRequests, externalPullRequests: [], - syncedAt: nowIso(), + syncedAt, }; }; const getGithubSnapshot = async (options: GithubSnapshotOptions = {}): Promise => { const force = options?.force === true; - const startSnapshotRequest = (allowStaleOnError: boolean): Promise => { - const staleFallback = cachedGithubSnapshot; + const githubStatus = await requireGithubSnapshotAuth(); + if (cachedGithubSnapshot && !githubSnapshotMatchesStatus(cachedGithubSnapshot, githubStatus)) { + invalidateGithubSnapshotCache(); + } + + const startSnapshotRequest = ( + precheckedGithubStatus: GitHubStatus, + ): Promise => { const requestEpoch = githubSnapshotCacheEpoch; let inFlight!: { request: Promise }; - const request = getGithubSnapshotUncached() + const request = getGithubSnapshotUncached(precheckedGithubStatus) .then((snapshot) => { const capturedAt = Date.now(); const canPublishSnapshot = @@ -5203,15 +5704,6 @@ export function createPrService({ } return snapshot; }) - .catch((error) => { - if (allowStaleOnError && staleFallback) { - logger.warn("prs.github_snapshot_refresh_failed_stale_returned", { - error: error instanceof Error ? error.message : String(error), - }); - return staleFallback; - } - throw error; - }) .finally(() => { if (githubSnapshotInFlight === inFlight) { githubSnapshotInFlight = null; @@ -5229,7 +5721,11 @@ export function createPrService({ return cachedSnapshot; } if (!githubSnapshotInFlight) { - void startSnapshotRequest(true).catch(() => {}); + void startSnapshotRequest(githubStatus).catch((error) => { + logger.warn("prs.github_snapshot_revalidation_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); } return cachedSnapshot; } @@ -5237,7 +5733,7 @@ export function createPrService({ return githubSnapshotInFlight.request; } - return startSnapshotRequest(false); + return startSnapshotRequest(githubStatus); }; const landQueueNext = async (args: LandQueueNextArgs): Promise => { @@ -6089,6 +6585,14 @@ export function createPrService({ return await linkToLane(args); }, + async preflightCreateLaneFromPrBranch(args: CreateLaneFromPrBranchArgs): Promise { + return await preflightCreateLaneFromPrBranch(args); + }, + + async createLaneFromPrBranch(args: CreateLaneFromPrBranchArgs): Promise { + return await createLaneFromPrBranch(args); + }, + async cleanupBranch(args: CleanupPrBranchArgs): Promise { return await cleanupBranch(args); }, diff --git a/apps/desktop/src/main/services/review/reviewService.test.ts b/apps/desktop/src/main/services/review/reviewService.test.ts index bf1c46413..e3ee6662c 100644 --- a/apps/desktop/src/main/services/review/reviewService.test.ts +++ b/apps/desktop/src/main/services/review/reviewService.test.ts @@ -146,6 +146,49 @@ function createInMemoryAdeDb(): { db: AdeDb; raw: Database } { created_at text not null ) `); + raw.run(` + create table review_reviewer_runs( + id text primary key, + run_id text not null, + reviewer_key text not null, + label text not null, + focus text not null, + status text not null, + chat_session_id text, + prompt_artifact_id text, + output_artifact_id text, + findings_artifact_id text, + candidate_count integer not null default 0, + kept_count integer not null default 0, + summary text, + error_message text, + started_at text, + ended_at text, + created_at text not null, + updated_at text not null + ) + `); + raw.run(` + create table review_candidate_findings( + id text primary key, + run_id text not null, + reviewer_run_id text not null, + reviewer_key text not null, + title text not null, + severity text not null, + finding_class text, + body text not null, + confidence real not null, + evidence_json text, + file_path text, + line integer, + anchor_state text not null, + evidence_score real not null, + low_signal integer not null default 0, + score real not null, + created_at text not null + ) + `); raw.run(` create table review_run_publications( id text primary key, @@ -184,6 +227,16 @@ async function waitFor(fn: () => T | Promise, predicate: (value: T) => boo throw new Error("Timed out waiting for review service state"); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function makeConfig(overrides: Partial = {}): ReviewRunConfig { return { compareAgainst: { kind: "default_branch" }, @@ -276,6 +329,16 @@ function makeOutput(summary: string, findings: Array>): return JSON.stringify({ summary, findings }); } +function makeTurnResult(summary: string, findings: Array>) { + return { + sessionId: "session-review", + provider: "codex", + model: "gpt-5.4", + modelId: "openai/gpt-5.4", + outputText: makeOutput(summary, findings), + }; +} + function makeContextPacket(overrides: Partial> = {}) { return { matchedRuleOverlays: [], @@ -466,9 +529,15 @@ function buildPublicationResult(args: { } function createHarness(args: { - outputs: string[]; + outputs: Array string)>; targetLabel?: string; publicationTarget?: ReviewPublicationDestination | null; + materializedTarget?: Partial<{ + targetLabel: string; + publicationTarget: ReviewPublicationDestination | null; + fullPatchText: string; + changedFiles: ReturnType[]; + }>; config?: Partial; target?: { mode: "lane_diff" | "pr" | "commit_range" | "working_tree"; laneId: string; prId?: string; baseCommit?: string; headCommit?: string }; }) { @@ -484,12 +553,15 @@ function createHarness(args: { mockMaterializer.materialize.mockResolvedValue(makeMaterializedTarget({ targetLabel: args.targetLabel, publicationTarget: args.publicationTarget ?? null, + ...(args.materializedTarget ?? {}), })); mockContextBuilder.buildContext.mockResolvedValue(makeContextPacket()); - const runSessionTurn = vi.fn(async () => { - const outputText = queuedOutputs.shift(); - if (!outputText) throw new Error("No mock review output left."); + const runSessionTurn = vi.fn(async (turnArgs?: { text?: string }) => { + const nextOutput = queuedOutputs.shift(); + const outputText = typeof nextOutput === "function" + ? nextOutput(turnArgs?.text ?? "") + : (nextOutput ?? makeOutput("No specialist findings.", [])); return { sessionId: `session-review-${sessionCount}`, provider: "codex", @@ -499,6 +571,18 @@ function createHarness(args: { }; }); + const createSession = vi.fn(async () => { + sessionCount += 1; + return { + id: `session-review-${sessionCount}`, + laneId: "lane-review", + provider: "codex", + model: "gpt-5.4", + modelId: "openai/gpt-5.4", + }; + }); + const interrupt = vi.fn(async () => undefined); + const service = createReviewService({ db: db as any, logger: { @@ -524,16 +608,7 @@ function createHarness(args: { 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", - modelId: "openai/gpt-5.4", - }; - }), + createSession, getSessionSummary: vi.fn(async (sessionId: string) => ({ sessionId, laneId: "lane-review", @@ -549,6 +624,7 @@ function createHarness(args: { lastOutputPreview: "Review output", summary: "Saved review transcript", })), + interrupt, runSessionTurn, } as any, sessionService: { @@ -597,6 +673,8 @@ function createHarness(args: { return { raw, service, + createSession, + interrupt, runSessionTurn, publishReviewPublication, start: (config?: Partial) => service.startRun({ @@ -642,11 +720,20 @@ describe("reviewService", () => { 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?.reviewerRuns).toHaveLength(5); + expect(detail?.candidateFindings).toHaveLength(2); + expect(detail?.artifacts.filter((artifact) => artifact.artifactType === "pass_prompt")).toHaveLength(5); + expect(detail?.artifacts.filter((artifact) => artifact.artifactType === "pass_output")).toHaveLength(5); + expect(detail?.artifacts.filter((artifact) => artifact.artifactType === "pass_findings")).toHaveLength(5); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "changed_file_manifest")).toBe(true); + expect(detail?.artifacts.some((artifact) => artifact.artifactType === "risk_map")).toBe(true); expect(detail?.artifacts.some((artifact) => artifact.artifactType === "adjudication_result")).toBe(true); expect(detail?.artifacts.some((artifact) => artifact.artifactType === "merged_findings")).toBe(true); + const firstPrompt = detail?.artifacts.find((artifact) => artifact.artifactType === "pass_prompt"); + expect(firstPrompt?.contentText).toContain("Diff and file inspection"); + expect(firstPrompt?.contentText).toContain("Full diff artifact:"); + expect(firstPrompt?.contentText).toContain("git diff -- "); + expect(firstPrompt?.contentText).not.toContain("diff --git a/src/review.ts b/src/review.ts"); 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"); @@ -654,6 +741,336 @@ describe("reviewService", () => { expect(String(persistedFindings[0]?.adjudication_json)).toContain("publicationEligible"); }); + it("classifies tsx and css changed paths as UI risk", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("No diff-risk issues.", []), + makeOutput("No cross-file issues.", []), + makeOutput("No checks issues.", []), + makeOutput("No security issues.", []), + makeOutput("No UI issues.", []), + ], + materializedTarget: { + fullPatchText: [ + "diff --git a/src/views/ReviewPage.tsx b/src/views/ReviewPage.tsx", + "diff --git a/src/styles/review.css b/src/styles/review.css", + ].join("\n"), + changedFiles: [ + makeChangedFile({ filePath: "src/views/ReviewPage.tsx" }), + makeChangedFile({ filePath: "src/styles/review.css" }), + ], + }, + }); + + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "completed"), + ); + + const detail = await harness.service.getRunDetail({ runId: run.id }); + const riskMap = JSON.parse(detail?.artifacts.find((artifact) => artifact.artifactType === "risk_map")?.contentText ?? "{}") as { + groups?: Array<{ risk: string; files: string[] }>; + }; + + expect(riskMap.groups).toEqual(expect.arrayContaining([ + expect.objectContaining({ + risk: "ui", + files: expect.arrayContaining(["src/views/ReviewPage.tsx", "src/styles/review.css"]), + }), + ])); + }); + + it("completes with partial coverage when one specialist reviewer fails", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("Cross-file clear.", []), + makeOutput("Checks clear.", []), + makeOutput("Security clear.", []), + makeOutput("UI clear.", []), + ], + }); + harness.runSessionTurn.mockRejectedValueOnce(new Error("model unavailable")); + + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "completed"), + ); + + const saved = (await harness.service.listRuns()).find((entry) => entry.id === run.id); + expect(saved?.errorMessage).toContain("Partial review"); + expect(saved?.summary).toContain("Partial review"); + const detail = await harness.service.getRunDetail({ runId: run.id }); + expect(detail?.reviewerRuns.some((reviewer) => reviewer.status === "failed")).toBe(true); + expect(detail?.findings).toEqual([]); + expect(detail?.artifacts.some((artifact) => artifact.title === "Reviewer failure summary")).toBe(true); + }); + + it("keeps partial PR reviews local even when auto-publish is enabled", 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" }, + outputs: [ + makeOutput("Cross-file found one issue.", [makeFinding()]), + makeOutput("Checks clear.", []), + makeOutput("Security clear.", []), + makeOutput("UI clear.", []), + ], + }); + harness.runSessionTurn.mockRejectedValueOnce(new Error("model unavailable")); + + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "completed"), + ); + + expect(harness.publishReviewPublication).not.toHaveBeenCalled(); + const detail = await harness.service.getRunDetail({ runId: run.id }); + expect(detail?.errorMessage).toContain("Partial review"); + expect(detail?.summary).toContain("Partial review"); + expect(detail?.findings).toHaveLength(1); + expect(detail?.findings[0]?.publicationState).toBe("local_only"); + expect(detail?.publications).toEqual([]); + expect(detail?.artifacts.some((artifact) => artifact.title === "Publication skipped for partial review")).toBe(true); + }); + + it("fails the review run when all specialist reviewers fail", async () => { + const harness = createHarness({ outputs: [] }); + harness.runSessionTurn.mockRejectedValue(new Error("model unavailable")); + + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "failed"), + ); + + const saved = (await harness.service.listRuns()).find((entry) => entry.id === run.id); + expect(saved?.errorMessage).toContain("specialist reviewer"); + const detail = await harness.service.getRunDetail({ runId: run.id }); + expect(detail?.reviewerRuns.every((reviewer) => reviewer.status === "failed")).toBe(true); + expect(detail?.findings).toEqual([]); + }); + + it("interrupts active specialist reviewer sessions when cancelling a run", async () => { + const harness = createHarness({ outputs: [] }); + const pendingTurns: Array<(value: ReturnType) => void> = []; + harness.runSessionTurn.mockImplementation((): Promise> => new Promise((resolve) => { + pendingTurns.push(resolve as (value: ReturnType) => void); + })); + + const run = await harness.start(); + await waitFor( + () => harness.createSession.mock.calls.length, + (count) => count === 5, + ); + + const cancelled = await harness.service.cancelRun({ runId: run.id }); + expect(cancelled?.status).toBe("cancelled"); + expect(harness.interrupt).toHaveBeenCalledTimes(5); + const interruptCalls = harness.interrupt.mock.calls as unknown as Array<[{ sessionId: string }]>; + expect(interruptCalls.map((call) => call[0].sessionId)).toEqual([ + "session-review-1", + "session-review-2", + "session-review-3", + "session-review-4", + "session-review-5", + ]); + + for (const resolve of pendingTurns) { + resolve(makeTurnResult("No findings after cancel.", [])); + } + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "cancelled"), + ); + }); + + it("does not dispatch specialist prompts after cancellation during context materialization", async () => { + const harness = createHarness({ outputs: [] }); + const context = deferred>(); + mockContextBuilder.buildContext.mockReturnValueOnce(context.promise); + + const run = await harness.start(); + await waitFor( + () => mockContextBuilder.buildContext.mock.calls.length, + (count) => count === 1, + ); + + await harness.service.cancelRun({ runId: run.id }); + context.resolve(makeContextPacket()); + + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "cancelled"), + ); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(harness.runSessionTurn).not.toHaveBeenCalled(); + }); + + it("enforces the final prompt budget without embedding the full diff bundle", async () => { + const longDiff = [ + "diff --git a/src/review.ts b/src/review.ts", + "@@ -1,2 +1,200 @@", + " context", + ...Array.from({ length: 1_000 }, (_, index) => `+exact changed line ${index}: ${"x".repeat(60)}`), + ].join("\n"); + const harness = createHarness({ + outputs: [ + makeOutput("No direct findings.", []), + makeOutput("No cross-file findings.", []), + makeOutput("No checks findings.", []), + makeOutput("No security findings.", []), + makeOutput("No UI findings.", []), + ], + config: { + budgets: { + maxFiles: 60, + maxDiffChars: 100_000, + maxPromptChars: 4_000, + maxFindings: 12, + maxFindingsPerPass: 6, + maxPublishedFindings: 6, + }, + }, + }); + mockMaterializer.materialize.mockResolvedValueOnce(makeMaterializedTarget({ + fullPatchText: longDiff, + changedFiles: [makeChangedFile({ excerpt: longDiff.slice(0, 4_000) })], + })); + + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "completed"), + ); + + const detail = await harness.service.getRunDetail({ runId: run.id }); + const passPrompts = detail?.artifacts.filter((artifact) => artifact.artifactType === "pass_prompt") ?? []; + expect(passPrompts).toHaveLength(5); + for (const prompt of passPrompts) { + expect(prompt.contentText ?? "").toHaveLength(4_000); + expect(prompt.contentText ?? "").toContain("...(truncated)..."); + expect(prompt.contentText ?? "").not.toContain("exact changed line 999"); + } + }); + + it("passes Codex fast mode to the automation chat while keeping plan permissions", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("No direct findings.", []), + makeOutput("No cross-file findings.", []), + makeOutput("No checks findings.", []), + ], + config: { codexFastMode: true }, + }); + + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "completed"), + ); + + expect(harness.createSession).toHaveBeenCalledWith(expect.objectContaining({ + codexFastMode: true, + permissionMode: "plan", + surface: "automation", + })); + }); + + it("preserves unlimited review budgets instead of clamping them", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("No direct findings.", []), + makeOutput("No cross-file findings.", []), + makeOutput("No checks findings.", []), + ], + config: { + budgets: { + unlimited: true, + maxFiles: 1, + maxDiffChars: 4_000, + maxPromptChars: 4_000, + maxFindings: 1, + maxFindingsPerPass: 1, + maxPublishedFindings: 1, + }, + }, + }); + + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "completed"), + ); + + const saved = (await harness.service.listRuns()).find((entry) => entry.id === run.id); + expect(saved?.config.budgets).toMatchObject({ + unlimited: true, + maxFiles: Number.MAX_SAFE_INTEGER, + maxFindings: Number.MAX_SAFE_INTEGER, + maxPublishedFindings: Number.MAX_SAFE_INTEGER, + }); + }); + + it("caps the prompt manifest even when review budgets are unlimited", async () => { + const changedFiles = Array.from({ length: 120 }, (_, index) => makeChangedFile({ + filePath: `src/file-${index}.ts`, + excerpt: `@@ -1 +1 @@\n+file ${index} ${"x".repeat(200)}`, + lineNumbers: [index + 1], + diffPositionsByLine: { [index + 1]: 1 }, + })); + const harness = createHarness({ + outputs: [ + makeOutput("No direct findings.", []), + makeOutput("No cross-file findings.", []), + makeOutput("No checks findings.", []), + makeOutput("No security findings.", []), + makeOutput("No UI findings.", []), + ], + materializedTarget: { + changedFiles, + }, + config: { + budgets: { + unlimited: true, + maxFiles: 1, + maxDiffChars: 4_000, + maxPromptChars: 4_000, + maxFindings: 1, + maxFindingsPerPass: 1, + maxPublishedFindings: 1, + }, + }, + }); + + const run = await harness.start(); + await waitFor( + () => harness.service.listRuns(), + (runs) => runs.some((entry) => entry.id === run.id && entry.status === "completed"), + ); + + const detail = await harness.service.getRunDetail({ runId: run.id }); + const prompt = detail?.artifacts.find((artifact) => artifact.artifactType === "pass_prompt")?.contentText ?? ""; + expect(prompt).toContain("src/file-99.ts"); + expect(prompt).not.toContain("src/file-100.ts"); + expect(prompt).toContain('"omittedFileCount": 20'); + + const manifest = detail?.artifacts.find((artifact) => artifact.artifactType === "changed_file_manifest")?.contentText ?? ""; + expect(manifest).toContain("src/file-119.ts"); + }); + it("persists provenance, rules, and validation artifacts and keeps renderer findings on the normal evidence path", async () => { const harness = createHarness({ outputs: [ @@ -855,6 +1272,93 @@ describe("reviewService", () => { expect(adjudicationArtifact?.contentText).toContain("rule_policy"); }); + it("keeps inspected file snapshot evidence for strict cross-boundary findings", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("Bridge mismatch.", [ + makeFinding({ + title: "Bridge rollout incomplete", + findingClass: "incomplete_rollout", + evidence: [ + { + kind: "diff_hunk", + summary: "The preload diff changes the exposed method name.", + filePath: "src/review.ts", + line: 2, + quote: "+exposeReviewV2()", + }, + { + kind: "file_snapshot", + summary: "The renderer still calls the old preload method.", + filePath: "src/renderer.ts", + line: 88, + quote: "window.ade.review.startRun()", + }, + ], + }), + ]), + makeOutput("No extra cross-file issues.", []), + makeOutput("No checks issues.", []), + ], + }); + mockContextBuilder.buildContext.mockResolvedValueOnce(makeContextPacket({ + matchedRuleOverlays: [ + { + 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" }], + }, + ], + 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" }, + }], + }, + metadata: { + summary: "1 rule overlay matched", + matchedRuleCount: 1, + ruleCount: 1, + pathCount: 1, + matchedRuleIds: ["preload-bridge"], + }, + }, + })); + + 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]?.evidence).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "diff_hunk", filePath: "src/review.ts", line: 2 }), + expect.objectContaining({ kind: "file_snapshot", filePath: "src/renderer.ts", line: 88 }), + ]), + ); + }); + it("cites validation and provenance artifacts when late-stage signals back the finding", async () => { const harness = createHarness({ outputs: [ @@ -930,6 +1434,51 @@ describe("reviewService", () => { expect(artifactKinds.size).toBeGreaterThanOrEqual(2); }); + it("keeps checks findings backed by validation artifacts without file anchors", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("No diff-risk issues.", []), + makeOutput("No cross-file issues.", []), + (prompt) => { + const validationArtifactId = prompt.match(/validation_signals artifact id: ([^\n]+)/)?.[1]?.trim(); + if (!validationArtifactId) throw new Error("Expected checks prompt to include validation artifact id."); + return makeOutput("Validation artifact-backed failure.", [ + makeFinding({ + title: "Unit validation failed after the review changes", + severity: "medium", + body: "The validation artifact reports a failing unit check for this review run.", + confidence: 0.82, + filePath: null, + line: null, + evidence: [{ + kind: "artifact", + summary: "The validation_signals artifact contains the failing unit check.", + filePath: null, + line: null, + quote: null, + artifactId: validationArtifactId, + }], + }), + ]); + }, + ], + }); + + 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]?.sourcePass).toBe("adjudicated"); + expect(detail?.findings[0]?.originatingPasses).toEqual(["checks-and-tests"]); + expect(detail?.findings[0]?.evidence).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "artifact", artifactId: expect.any(String) }), + ]), + ); + expect(detail?.summary).not.toContain("filtered out during adjudication"); + }); + it("uses late-stage regression when overlapping passes disagree on ADE-native class", async () => { const harness = createHarness({ outputs: [ @@ -992,6 +1541,42 @@ describe("reviewService", () => { expect(adjudicationArtifact?.contentText).toContain("low_evidence"); }); + it("filters reviewer evidence that cannot be anchored to the reviewed diff", async () => { + const harness = createHarness({ + outputs: [ + makeOutput("Unanchored finding.", [ + makeFinding({ + title: "External file risk", + severity: "high", + body: "This cites a file and line that are not in the reviewed diff.", + confidence: 0.93, + filePath: "src/unrelated.ts", + line: 999, + evidence: [{ + summary: "The reviewer cited a line outside the materialized patch.", + filePath: "src/unrelated.ts", + line: 999, + quote: "+outsideReviewedDiff()", + }], + }), + ]), + ], + }); + + 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?.candidateFindings).toHaveLength(1); + expect(detail?.candidateFindings[0]?.evidence).toEqual([]); + expect(detail?.findings).toEqual([]); + 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", @@ -1005,6 +1590,21 @@ describe("reviewService", () => { publicationTarget: destination, targetLabel: "PR #80 feature/pr-80 -> main", target: { mode: "pr", laneId: "lane-review", prId: "pr-80" }, + materializedTarget: { + changedFiles: [ + makeChangedFile({ + excerpt: "@@ -1,2 +1,5 @@\n context\n+return null;\n+missing fallback\n+missing test coverage\n", + lineNumbers: [2, 3, 200], + diffPositionsByLine: { 2: 1, 3: 2, 200: 3 }, + }), + makeChangedFile({ + filePath: "src/worker.ts", + excerpt: "@@ -7,2 +7,4 @@\n context\n+dispatchWithoutInvariant()\n", + lineNumbers: [10], + diffPositionsByLine: { 10: 4 }, + }), + ], + }, config: { publishBehavior: "auto_publish", budgets: { @@ -1062,9 +1662,13 @@ describe("reviewService", () => { makeOutput("First pass run.", [makeFinding()]), makeOutput("Cross-file overlap.", [makeFinding({ title: "Fallback path removed", confidence: 0.71 })]), makeOutput("Checks clear.", []), + makeOutput("First run security clear.", []), + makeOutput("First run UI clear.", []), makeOutput("Second run diff-risk.", [makeFinding()]), makeOutput("Second run cross-file.", [makeFinding({ title: "Fallback path removed", confidence: 0.71 })]), makeOutput("Second run checks.", []), + makeOutput("Second run security clear.", []), + makeOutput("Second run UI clear.", []), ], target: { mode: "commit_range", @@ -1090,9 +1694,10 @@ describe("reviewService", () => { (runs) => runs.some((run) => run.id === rerun.id && run.status === "completed"), ); - expect(harness.runSessionTurn).toHaveBeenCalledTimes(6); + expect(harness.runSessionTurn).toHaveBeenCalledTimes(10); const rerunDetail = await harness.service.getRunDetail({ runId: rerun.id }); - expect(rerunDetail?.artifacts.filter((artifact) => artifact.artifactType === "pass_prompt")).toHaveLength(3); + expect(rerunDetail?.reviewerRuns).toHaveLength(5); + expect(rerunDetail?.artifacts.filter((artifact) => artifact.artifactType === "pass_prompt")).toHaveLength(5); const rerunRecord = (await harness.service.listRuns()).find((entry) => entry.id === rerun.id); expect(rerunRecord?.target).toEqual({ diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index 29da7806d..c1708e6a2 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import type { ReviewArtifactType, ReviewDiffContext, @@ -11,6 +11,7 @@ import type { ReviewFindingAdjudication, ReviewFindingClass, ReviewFindingSuppressionMatch, + ReviewCandidateFinding, ReviewListSuppressionsArgs, ReviewPublication, ReviewPublicationDestination, @@ -24,6 +25,8 @@ import type { ReviewRunConfig, ReviewRunDetail, ReviewRunStatus, + ReviewReviewerRun, + ReviewReviewerRunStatus, ReviewPassKey, ReviewSeverity, ReviewSeveritySummary, @@ -141,6 +144,47 @@ type ReviewRunPublicationRow = { completed_at: string | null; }; +type ReviewReviewerRunRow = { + id: string; + run_id: string; + reviewer_key: string; + label: string; + focus: string; + status: string; + chat_session_id: string | null; + prompt_artifact_id: string | null; + output_artifact_id: string | null; + findings_artifact_id: string | null; + candidate_count: number; + kept_count: number; + summary: string | null; + error_message: string | null; + started_at: string | null; + ended_at: string | null; + created_at: string; + updated_at: string; +}; + +type ReviewCandidateFindingRow = { + id: string; + run_id: string; + reviewer_run_id: string; + reviewer_key: string; + title: string; + severity: string; + finding_class: string | null; + body: string; + confidence: number; + evidence_json: string | null; + file_path: string | null; + line: number | null; + anchor_state: string; + evidence_score: number; + low_signal: number; + score: number; + created_at: string; +}; + const REVIEW_MODEL_FALLBACK_ID = "openai/gpt-5.4"; function resolveBuiltinReviewModelId(): string { @@ -170,11 +214,15 @@ const DEFAULT_BUDGETS: ReviewRunConfig["budgets"] = { maxFindingsPerPass: 6, maxPublishedFindings: 6, }; +const UNLIMITED_BUDGET_VALUE = Number.MAX_SAFE_INTEGER; +const MANIFEST_PROMPT_FILE_LIMIT = 100; const REVIEW_PASS_ORDER: ReviewPassKey[] = [ "diff-risk", "cross-file-impact", "checks-and-tests", + "security-data", + "ui-regression", ]; const SEVERITY_SCORE: Record = { @@ -192,6 +240,11 @@ type MaterializedChangedFile = { diffPositionsByLine: Record; }; +type ChangedFileEvidenceContext = { + excerpt: string; + lineNumbers: Set; +}; + type PassDefinition = { key: ReviewPassKey; label: string; @@ -202,6 +255,7 @@ type PassDefinition = { type PassCandidateFinding = { id: string; runId: string; + reviewerRunId: string | null; passKey: ReviewPassKey; title: string; severity: ReviewSeverity; @@ -218,6 +272,9 @@ type PassCandidateFinding = { }; type ReviewContextArtifactIds = { + manifestArtifactId: string; + riskMapArtifactId: string; + diffBundleArtifactId: string | null; provenanceArtifactId: string; rulesArtifactId: string; validationArtifactId: string; @@ -225,6 +282,10 @@ type ReviewContextArtifactIds = { type PassExecutionResult = { pass: PassDefinition; + status: ReviewReviewerRunStatus; + errorMessage: string | null; + reviewerRunId: string; + sessionId: string | null; summary: string | null; candidates: PassCandidateFinding[]; promptArtifactId: string; @@ -266,7 +327,9 @@ function clampNumber(value: number, min: number, max: number): number { function truncateText(value: string, maxChars: number): string { if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}\n...(truncated)...\n`; + const marker = "\n...(truncated)...\n"; + if (maxChars <= marker.length) return value.slice(0, Math.max(0, maxChars)); + return `${value.slice(0, maxChars - marker.length)}${marker}`; } function cleanLine(value: string): string { @@ -321,6 +384,17 @@ function mergeFindingClass(classes: Array } function normalizeBudgetConfig(budgets?: Partial | null): ReviewRunConfig["budgets"] { + if (budgets?.unlimited === true) { + return { + unlimited: true, + maxFiles: UNLIMITED_BUDGET_VALUE, + maxDiffChars: UNLIMITED_BUDGET_VALUE, + maxPromptChars: UNLIMITED_BUDGET_VALUE, + maxFindings: UNLIMITED_BUDGET_VALUE, + maxFindingsPerPass: UNLIMITED_BUDGET_VALUE, + maxPublishedFindings: UNLIMITED_BUDGET_VALUE, + }; + } return { maxFiles: clampNumber(Number(budgets?.maxFiles ?? DEFAULT_BUDGETS.maxFiles), 1, 500), maxDiffChars: clampNumber(Number(budgets?.maxDiffChars ?? DEFAULT_BUDGETS.maxDiffChars), 4_000, 1_000_000), @@ -445,20 +519,103 @@ const REVIEW_PASSES: PassDefinition[] = [ "Use check/test context when present, but do not invent failures that were not supplied.", ], }, + { + key: "security-data", + label: "Security and data", + focus: "security regressions, data loss, privacy-sensitive behavior, auth boundaries, persistence, secrets, and unsafe trust changes", + extraInstructions: [ + "Prioritize concrete security, privacy, persistence, and data-integrity failures over generic hardening suggestions.", + "Cite the exact changed path or validation signal that makes the risk plausible.", + ], + }, + { + key: "ui-regression", + label: "UI and regression", + focus: "user-visible regressions, broken UI state, stale hydration, disabled-looking actions, copy clarity, and cross-surface UX fallout", + extraInstructions: [ + "Focus on user-visible behavior and state transitions caused by the diff.", + "Avoid aesthetic nits unless they make a workflow misleading, inaccessible, or functionally broken.", + ], + }, ]; -function buildChangedFilesSummary(changedFiles: Array<{ filePath: string }>): string { - const changedFilesSummary = changedFiles.length > 0 - ? changedFiles.map((entry) => `- ${entry.filePath}`).join("\n") +function buildChangedFilesSummary(changedFiles: Array<{ filePath: string }>, limit = changedFiles.length): string { + const visibleFiles = changedFiles.slice(0, limit); + const omittedCount = Math.max(0, changedFiles.length - visibleFiles.length); + const changedFilesSummary = visibleFiles.length > 0 + ? [ + ...visibleFiles.map((entry) => `- ${entry.filePath}`), + ...(omittedCount > 0 ? [`- ...${omittedCount} more changed files omitted from this prompt; inspect the manifest artifact for the full list.`] : []), + ].join("\n") : "- No changed files were detected."; return changedFilesSummary; } +function classifyChangedFileRisk(filePath: string): string[] { + const risks: string[] = []; + if (/\b(preload|ipc|shared\/types|types)\b/i.test(filePath)) risks.push("contract"); + if (/\b(kvDb|migration|sqlite|state|sync)\b/i.test(filePath)) risks.push("persistence"); + if (/\b(prs?|review|github|publish|merge|branch)\b/i.test(filePath)) risks.push("workflow"); + if (/\b(renderer|components)\b/i.test(filePath) || /\.(?:tsx|css)$/i.test(filePath)) risks.push("ui"); + if (/\b(auth|credential|secret|token|security|permission|sandbox)\b/i.test(filePath)) risks.push("security"); + if (/\b(test|spec)\b/i.test(filePath)) risks.push("test"); + return risks.length > 0 ? risks : ["direct-diff"]; +} + +function buildChangedFileManifestPayload(args: { + targetLabel: string; + compareTarget: ReviewResolvedCompareTarget | null; + changedFiles: MaterializedChangedFile[]; + budgets: ReviewRunConfig["budgets"]; +}): Record { + return { + targetLabel: args.targetLabel, + compareTarget: args.compareTarget, + fileCount: args.changedFiles.length, + budgetMode: args.budgets.unlimited === true ? "unlimited" : "bounded", + files: args.changedFiles.map((file) => ({ + path: file.filePath, + changedLineCount: file.lineNumbers.length, + changedLineSample: file.lineNumbers.slice(0, 20), + riskTags: classifyChangedFileRisk(file.filePath), + excerpt: truncateText(file.excerpt, 1_500), + })), + }; +} + +function buildRiskMapPayload(changedFiles: MaterializedChangedFile[]): Record { + const groups = new Map(); + for (const file of changedFiles) { + for (const risk of classifyChangedFileRisk(file.filePath)) { + const current = groups.get(risk) ?? []; + current.push(file.filePath); + groups.set(risk, current); + } + } + return { + groups: Array.from(groups.entries()).map(([risk, files]) => ({ + risk, + files, + fileCount: files.length, + })), + }; +} + +function buildManifestPrompt(manifest: Record, riskMap: Record): string { + return JSON.stringify({ + changedFileManifest: manifest, + riskMap, + }, null, 2); +} + function buildContextArtifactHints(args: { artifactIds: ReviewContextArtifactIds; includeValidation: boolean; }): string[] { const lines = [ + `- changed_file_manifest artifact id: ${args.artifactIds.manifestArtifactId}`, + `- risk_map artifact id: ${args.artifactIds.riskMapArtifactId}`, + ...(args.artifactIds.diffBundleArtifactId ? [`- diff_bundle artifact id: ${args.artifactIds.diffBundleArtifactId}`] : []), `- provenance_brief artifact id: ${args.artifactIds.provenanceArtifactId}`, `- rule_overlays artifact id: ${args.artifactIds.rulesArtifactId}`, ]; @@ -468,22 +625,61 @@ function buildContextArtifactHints(args: { return lines; } +function buildReadOnlyInspectionInstructions(args: { + run: ReviewRun; + artifactIds: ReviewContextArtifactIds; +}): string[] { + const lines = [ + "Use read-only inspection to gather exact evidence instead of relying on the prompt as the full diff.", + "The complete diff is saved as a review artifact; cite it by artifact id when it supports a finding.", + "Do not edit files, stage changes, commit, push, or run commands that mutate the workspace.", + `Full diff artifact: ${args.artifactIds.diffBundleArtifactId ?? "not available"}.`, + ]; + + switch (args.run.target.mode) { + case "commit_range": + lines.push( + `Exact changed hunks: git diff ${args.run.target.baseCommit}..${args.run.target.headCommit} -- `, + `Head file contents: git show ${args.run.target.headCommit}:`, + `Base file contents: git show ${args.run.target.baseCommit}:`, + ); + break; + case "working_tree": + lines.push( + "Exact unstaged hunks: git diff -- ", + "Exact staged hunks: git diff --cached -- ", + "Changed files: git status --short", + ); + break; + case "lane_diff": + case "pr": + lines.push( + "Exact hunks for checked-out changes: git diff -- ", + "Changed files: git status --short", + "When a commit range is visible in the manifest, prefer git diff .. -- .", + ); + break; + } + + return lines.map((line) => `- ${line}`); +} + function buildPassPrompt(args: { run: ReviewRun; pass: PassDefinition; - diffText: string; + manifestPrompt: string; changedFiles: Array<{ filePath: string }>; context: ReviewContextPacket; contextArtifactIds: ReviewContextArtifactIds; }): string { - const changedFilesSummary = buildChangedFilesSummary(args.changedFiles); + const changedFilesSummary = buildChangedFilesSummary(args.changedFiles, MANIFEST_PROMPT_FILE_LIMIT); 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}.`, + "Review only the local target represented by the supplied changed-file manifest, risk map, and context artifacts.", + `This specialist 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.", @@ -496,11 +692,14 @@ function buildPassPrompt(args: { '- "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, "artifactId": string|null}', - `Return at most ${args.run.config.budgets.maxFindingsPerPass ?? args.run.config.budgets.maxFindings} findings.`, + '- "evidence": array of objects with {"kind": "diff_hunk"|"file_snapshot"|"artifact"|"quote", "summary": string, "quote": string|null, "filePath": string|null, "line": number|null, "artifactId": string|null}', + '- Use "diff_hunk" for changed lines and "file_snapshot" for exact lines you inspected outside the changed hunks.', + args.run.config.budgets.unlimited === true + ? "Return every real issue you can substantiate from the supplied evidence." + : `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}`, + `Reviewer key: ${args.pass.key}`, `Review target: ${args.run.targetLabel}`, `Selection mode: ${args.run.config.selectionMode}`, `Publish behavior: ${args.run.config.publishBehavior}`, @@ -512,6 +711,15 @@ function buildPassPrompt(args: { "Changed files:", changedFilesSummary, "", + "Changed-file manifest and risk map:", + args.manifestPrompt, + "", + "Diff and file inspection:", + ...buildReadOnlyInspectionInstructions({ + run: args.run, + artifactIds: args.contextArtifactIds, + }), + "", "Context artifact ids you may cite in evidence when relevant:", ...buildContextArtifactHints({ artifactIds: args.contextArtifactIds, @@ -526,9 +734,6 @@ function buildPassPrompt(args: { "", "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"); } @@ -558,6 +763,14 @@ function parseEvidence(value: unknown): ReviewEvidence[] { }); } +function stableReviewId(prefix: string, parts: unknown[]): string { + const hash = createHash("sha256") + .update(JSON.stringify(parts)) + .digest("hex") + .slice(0, 24); + return `${prefix}-${hash}`; +} + function computeAnchorState(args: { filePath: string | null; line: number | null; @@ -572,10 +785,9 @@ function computeAnchorState(args: { function hasConcreteEvidence(evidence: ReviewEvidence[]): boolean { return evidence.some((entry) => { - if (entry.kind === "artifact") return false; + if (entry.kind === "artifact" || entry.kind === "tool_signal") return false; return Boolean( - (typeof entry.quote === "string" && entry.quote.trim().length > 0) - || (entry.filePath && entry.line != null) + (entry.filePath && entry.line != null) || (entry.filePath && entry.kind === "diff_hunk"), ); }); @@ -583,7 +795,12 @@ function hasConcreteEvidence(evidence: ReviewEvidence[]): boolean { 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 quoteCount = evidence.filter((entry) => + Boolean(entry.filePath) + && (entry.line != null || entry.kind === "diff_hunk") + && 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( @@ -721,34 +938,71 @@ function dedupeEvidenceEntries(evidence: ReviewEvidence[]): ReviewEvidence[] { return deduped; } +function sanitizeParsedEvidence( + evidence: ReviewEvidence[], + changedFilesByPath: Map, +): ReviewEvidence[] { + const hasReviewedDiffAnchor = evidence.some((entry) => { + if (entry.kind === "artifact" || !entry.filePath) return false; + const changedFile = changedFilesByPath.get(entry.filePath); + if (!changedFile) return false; + return entry.line == null || changedFile.lineNumbers.has(entry.line); + }); + + return evidence.flatMap((entry) => { + if (entry.kind === "artifact") return [entry]; + if (!entry.filePath) return []; + if (entry.kind === "file_snapshot") { + if (!hasReviewedDiffAnchor) return []; + if (entry.line == null) return []; + if (!entry.quote?.trim()) return []; + return [entry]; + } + const changedFile = changedFilesByPath.get(entry.filePath); + if (!changedFile) return []; + if (entry.line != null && !changedFile.lineNumbers.has(entry.line)) return []; + return [entry]; + }); +} + +function buildFallbackDiffEvidence(args: { + filePath: string | null; + line: number | null; + changedFilesByPath: Map; +}): ReviewEvidence[] { + if (!args.filePath) return []; + const fallbackFile = args.changedFilesByPath.get(args.filePath); + if (!fallbackFile) return []; + return [{ + kind: "diff_hunk" as const, + summary: `Relevant diff context from ${args.filePath}`, + filePath: args.filePath, + line: args.line != null && fallbackFile.lineNumbers.has(args.line) ? args.line : null, + quote: fallbackFile.excerpt || null, + artifactId: null, + }]; +} + function normalizeParsedFindings(args: { runId: string; + reviewerRunId: string; passKey: ReviewPassKey; parsed: Record | null; - changedFilesByPath: Map }>; + changedFilesByPath: Map; }): { summary: string | null; findings: PassCandidateFinding[] } { const findingsRaw = Array.isArray(args.parsed?.findings) ? args.parsed?.findings : []; - const findings = findingsRaw.flatMap((entry) => { + const findings = findingsRaw.flatMap((entry, index) => { 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 + const computedEvidence = sanitizeParsedEvidence(parseEvidence(entry.evidence), args.changedFilesByPath); + const fallbackEvidence = buildFallbackDiffEvidence({ filePath, line, changedFilesByPath: args.changedFilesByPath }); + const evidence: ReviewEvidence[] = computedEvidence.length > 0 && hasConcreteEvidence(computedEvidence) ? computedEvidence - : fallbackFile - ? [{ - kind: "diff_hunk" as const, - summary: `Relevant diff context from ${filePath}`, - filePath, - line, - quote: fallbackFile.excerpt || null, - artifactId: null, - }] - : []; + : dedupeEvidenceEntries([...computedEvidence, ...fallbackEvidence]); const anchorState = computeAnchorState({ filePath, line, @@ -757,8 +1011,9 @@ function normalizeParsedFindings(args: { const evidenceScore = scoreEvidence(evidence); const confidence = normalizeConfidence(entry.confidence); const finding: PassCandidateFinding = { - id: randomUUID(), + id: stableReviewId("candidate", [args.runId, args.passKey, index, title, filePath, line, body]), runId: args.runId, + reviewerRunId: args.reviewerRunId, passKey: args.passKey, title, severity: normalizeSeverity(entry.severity), @@ -835,7 +1090,7 @@ function combineConfidence(findings: PassCandidateFinding[]): number { } function groupPassCandidates(candidates: PassCandidateFinding[]): PassCandidateFinding[][] { - const remaining = [...candidates].sort((left, right) => right.score - left.score); + const remaining = [...candidates].sort(compareCandidatesStable); const groups: PassCandidateFinding[][] = []; while (remaining.length > 0) { const seed = remaining.shift(); @@ -856,6 +1111,22 @@ function groupPassCandidates(candidates: PassCandidateFinding[]): PassCandidateF return groups; } +function compareCandidatesStable(left: PassCandidateFinding, right: PassCandidateFinding): number { + const scoreDelta = right.score - left.score; + if (scoreDelta !== 0) return scoreDelta; + const severityDelta = SEVERITY_SCORE[right.severity] - SEVERITY_SCORE[left.severity]; + if (severityDelta !== 0) return severityDelta; + const passDelta = REVIEW_PASS_ORDER.indexOf(left.passKey) - REVIEW_PASS_ORDER.indexOf(right.passKey); + if (passDelta !== 0) return passDelta; + const pathDelta = String(left.filePath ?? "").localeCompare(String(right.filePath ?? "")); + if (pathDelta !== 0) return pathDelta; + const lineDelta = (left.line ?? Number.MAX_SAFE_INTEGER) - (right.line ?? Number.MAX_SAFE_INTEGER); + if (lineDelta !== 0) return lineDelta; + const titleDelta = left.title.localeCompare(right.title); + if (titleDelta !== 0) return titleDelta; + return left.id.localeCompare(right.id); +} + function getCandidatePathSet(group: PassCandidateFinding[]): string[] { return Array.from(new Set( group.flatMap((candidate) => [ @@ -869,7 +1140,7 @@ 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")) + .filter((entry) => Boolean(entry.filePath) && (entry.line != null || entry.kind === "diff_hunk")) .map((entry) => entry.filePath as string), ).size; } @@ -879,6 +1150,18 @@ function hasArtifactEvidence(evidence: ReviewEvidence[], artifactIds: string[]): return evidence.some((entry) => entry.kind === "artifact" && entry.artifactId && allowed.has(entry.artifactId)); } +function hasChecksValidationArtifactEvidence(args: { + group: PassCandidateFinding[]; + context: ReviewContextPacket; + artifactIds: ReviewContextArtifactIds; +}): boolean { + if (args.context.validation.payload.signals.length === 0) return false; + return args.group.some((candidate) => + candidate.passKey === "checks-and-tests" + && hasArtifactEvidence(candidate.evidence, [args.artifactIds.validationArtifactId]), + ); +} + function buildContextArtifactEvidence(args: { group: PassCandidateFinding[]; context: ReviewContextPacket; @@ -993,7 +1276,7 @@ function adjudicatePassFindings(args: { 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]; + const bestCandidate = [...group].sort(compareCandidatesStable)[0]; if (!bestCandidate) continue; const candidatePaths = getCandidatePathSet(group); const relevantOverlays = args.context.matchedRuleOverlays.filter((overlay) => @@ -1034,7 +1317,13 @@ function adjudicatePassFindings(args: { passCount: passes.length, }); - if (!hasConcreteEvidence(mergedEvidence)) { + const hasConcreteAdjudicationEvidence = hasConcreteEvidence(mergedEvidence) + || hasChecksValidationArtifactEvidence({ + group, + context: args.context, + artifactIds: args.artifactIds, + }); + if (!hasConcreteAdjudicationEvidence) { rejected.push({ candidateIds: group.map((candidate) => candidate.id), passKeys: passes, @@ -1098,7 +1387,14 @@ function adjudicatePassFindings(args: { }; findings.push({ - id: randomUUID(), + id: stableReviewId("finding", [ + args.runId, + preferredAnchor.filePath, + preferredAnchor.line, + bestCandidate.title, + bestCandidate.body, + passes, + ]), runId: args.runId, title: bestCandidate.title, severity, @@ -1119,7 +1415,17 @@ function adjudicatePassFindings(args: { } const keptFindings = findings - .sort((left, right) => (right.adjudication?.score ?? 0) - (left.adjudication?.score ?? 0)) + .sort((left, right) => { + const scoreDelta = (right.adjudication?.score ?? 0) - (left.adjudication?.score ?? 0); + if (scoreDelta !== 0) return scoreDelta; + const severityDelta = SEVERITY_SCORE[right.severity] - SEVERITY_SCORE[left.severity]; + if (severityDelta !== 0) return severityDelta; + const pathDelta = String(left.filePath ?? "").localeCompare(String(right.filePath ?? "")); + if (pathDelta !== 0) return pathDelta; + const lineDelta = (left.line ?? Number.MAX_SAFE_INTEGER) - (right.line ?? Number.MAX_SAFE_INTEGER); + if (lineDelta !== 0) return lineDelta; + return left.title.localeCompare(right.title); + }) .slice(0, args.budgets.maxFindings); const keptIds = new Set(keptFindings.map((finding) => finding.id)); for (const finding of findings) { @@ -1276,6 +1582,51 @@ function mapPublicationRow(row: ReviewRunPublicationRow): ReviewPublication { }; } +function mapReviewerRunRow(row: ReviewReviewerRunRow): ReviewReviewerRun { + return { + id: row.id, + runId: row.run_id, + reviewerKey: row.reviewer_key as ReviewPassKey, + label: row.label, + focus: row.focus, + status: (row.status as ReviewReviewerRunStatus) ?? "failed", + chatSessionId: row.chat_session_id, + promptArtifactId: row.prompt_artifact_id, + outputArtifactId: row.output_artifact_id, + findingsArtifactId: row.findings_artifact_id, + candidateCount: Number(row.candidate_count ?? 0), + keptCount: Number(row.kept_count ?? 0), + summary: row.summary, + errorMessage: row.error_message, + startedAt: row.started_at, + endedAt: row.ended_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapCandidateFindingRow(row: ReviewCandidateFindingRow): ReviewCandidateFinding { + return { + id: row.id, + runId: row.run_id, + reviewerRunId: row.reviewer_run_id, + reviewerKey: row.reviewer_key as ReviewPassKey, + 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, []), + filePath: row.file_path, + line: typeof row.line === "number" ? row.line : null, + anchorState: (row.anchor_state as ReviewCandidateFinding["anchorState"]) ?? "missing", + evidenceScore: clampNumber(Number(row.evidence_score ?? 0), 0, 1), + lowSignal: Number(row.low_signal ?? 0) === 1, + score: Number(row.score ?? 0), + createdAt: row.created_at, + }; +} + export function createReviewService({ db, logger, @@ -1300,7 +1651,7 @@ export function createReviewService({ projectDefaultBranch: string | null; laneService: Pick, "getLaneBaseAndBranch" | "getStateSnapshot" | "list">; gitService: Pick, "listRecentCommits">; - agentChatService: Pick, "createSession" | "getSessionSummary" | "runSessionTurn">; + agentChatService: Pick, "createSession" | "getSessionSummary" | "interrupt" | "runSessionTurn">; sessionService: Pick, "updateMeta">; sessionDeltaService: Pick, "listRecentLaneSessionDeltas">; testService: Pick, "listRuns" | "getLogTail" | "listSuites">; @@ -1328,6 +1679,7 @@ export function createReviewService({ }); const activeRuns = new Set(); const cancelledRuns = new Set(); + const activeReviewerSessions = new Map>(); let disposed = false; const configuredDefaultModelId = getDefaultModelDescriptor("codex")?.id @@ -1462,6 +1814,101 @@ export function createReviewService({ return record; } + function insertReviewerRun(runId: string, pass: PassDefinition): ReviewReviewerRun { + const now = nowIso(); + const record: ReviewReviewerRun = { + id: randomUUID(), + runId, + reviewerKey: pass.key, + label: pass.label, + focus: pass.focus, + status: "queued", + chatSessionId: null, + promptArtifactId: null, + outputArtifactId: null, + findingsArtifactId: null, + candidateCount: 0, + keptCount: 0, + summary: null, + errorMessage: null, + startedAt: null, + endedAt: null, + createdAt: now, + updatedAt: now, + }; + db.run( + `insert into review_reviewer_runs ( + id, + run_id, + reviewer_key, + label, + focus, + status, + chat_session_id, + prompt_artifact_id, + output_artifact_id, + findings_artifact_id, + candidate_count, + kept_count, + summary, + error_message, + started_at, + ended_at, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + record.id, + record.runId, + record.reviewerKey, + record.label, + record.focus, + record.status, + record.chatSessionId, + record.promptArtifactId, + record.outputArtifactId, + record.findingsArtifactId, + record.candidateCount, + record.keptCount, + record.summary, + record.errorMessage, + record.startedAt, + record.endedAt, + record.createdAt, + record.updatedAt, + ], + ); + return record; + } + + function updateReviewerRun( + reviewerRunId: string, + patch: Partial<{ + status: ReviewReviewerRunStatus; + chat_session_id: string | null; + prompt_artifact_id: string | null; + output_artifact_id: string | null; + findings_artifact_id: string | null; + candidate_count: number; + kept_count: number; + summary: string | null; + error_message: string | null; + started_at: 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(reviewerRunId); + db.run(`update review_reviewer_runs set ${sets.join(", ")} where id = ?`, params); + } + function insertPublication(publication: ReviewPublication): void { db.run( `insert into review_run_publications ( @@ -1542,6 +1989,49 @@ export function createReviewService({ ); } + function insertCandidateFinding(reviewerRunId: string, candidate: PassCandidateFinding): void { + db.run( + `insert or replace into review_candidate_findings ( + id, + run_id, + reviewer_run_id, + reviewer_key, + title, + severity, + finding_class, + body, + confidence, + evidence_json, + file_path, + line, + anchor_state, + evidence_score, + low_signal, + score, + created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + candidate.id, + candidate.runId, + reviewerRunId, + candidate.passKey, + candidate.title, + candidate.severity, + candidate.findingClass, + candidate.body, + candidate.confidence, + JSON.stringify(candidate.evidence), + candidate.filePath, + candidate.line, + candidate.anchorState, + candidate.evidenceScore, + candidate.lowSignal ? 1 : 0, + candidate.score, + nowIso(), + ], + ); + } + function updateFindingPublicationState(runId: string, findingId: string, publicationState: ReviewPublicationState): void { db.run( "update review_findings set publication_state = ? where id = ? and run_id = ?", @@ -1580,6 +2070,7 @@ export function createReviewService({ dirtyOnly: partial?.dirtyOnly ?? target.mode === "working_tree", modelId: partial?.modelId?.trim() || defaultReviewModelId, reasoningEffort: partial?.reasoningEffort?.trim() || null, + codexFastMode: partial?.codexFastMode === true, budgets: normalizeBudgetConfig(partial?.budgets), publishBehavior: target.mode === "pr" && partial?.publishBehavior === "auto_publish" ? "auto_publish" @@ -1664,94 +2155,282 @@ export function createReviewService({ async function executePass(args: { runId: string; run: ReviewRun; - sessionId: string; sessionTitle: string; descriptorId: string; + provider: string; + model: string; pass: PassDefinition; - diffText: string; + manifestPrompt: 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 reviewerRun = insertReviewerRun(args.runId, args.pass); + if (cancelledRuns.has(args.runId)) { + const endedAt = nowIso(); + updateReviewerRun(reviewerRun.id, { + status: "cancelled", + error_message: "Review run cancelled before reviewer session started.", + ended_at: endedAt, + updated_at: endedAt, + }); + emit({ + type: "reviewer-updated", + runId: args.runId, + laneId: args.run.laneId, + reviewerRunId: reviewerRun.id, + reviewerKey: args.pass.key, + status: "cancelled", + }); + return { + pass: args.pass, + status: "cancelled", + errorMessage: "Review run cancelled before reviewer session started.", + reviewerRunId: reviewerRun.id, + sessionId: null, + summary: null, + candidates: [], + promptArtifactId: "", + outputArtifactId: "", + findingsArtifactId: "", + budgetTrimmedCount: 0, + }; + } + const startedAt = nowIso(); + updateReviewerRun(reviewerRun.id, { + status: "running", + started_at: startedAt, + updated_at: startedAt, }); - const parsed = extractJsonObject(result.outputText); - const normalized = normalizeParsedFindings({ + emit({ + type: "reviewer-updated", runId: args.runId, - passKey: args.pass.key, - parsed, - changedFilesByPath: args.changedFilesByPath, + laneId: args.run.laneId, + reviewerRunId: reviewerRun.id, + reviewerKey: args.pass.key, + status: "running", }); - 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({ + + let sessionId: string | null = null; + let promptArtifactId: string | null = null; + let outputArtifactId: string | null = null; + let findingsArtifactId: string | null = null; + + try { + const session = await agentChatService.createSession({ + laneId: args.run.laneId, + provider: args.provider as never, + model: args.model, + modelId: args.descriptorId, + reasoningEffort: args.run.config.reasoningEffort, + codexFastMode: args.run.config.codexFastMode === true, + permissionMode: "plan", + sessionProfile: "workflow", + surface: "automation", + }); + sessionId = session.id; + const runSessions = activeReviewerSessions.get(args.runId) ?? new Set(); + runSessions.add(sessionId); + activeReviewerSessions.set(args.runId, runSessions); + sessionService.updateMeta({ + sessionId, + title: `${args.sessionTitle} · ${args.pass.label}`, + }); + updateReviewerRun(reviewerRun.id, { + chat_session_id: sessionId, + updated_at: nowIso(), + }); + db.run( + "update review_runs set chat_session_id = coalesce(chat_session_id, ?), updated_at = ? where id = ? and project_id = ?", + [sessionId, nowIso(), args.runId, projectId], + ); + + if (cancelledRuns.has(args.runId)) { + await agentChatService.interrupt({ sessionId }).catch((error) => { + logger.warn("review.cancel_reviewer_interrupt_failed", { + runId: args.runId, + reviewerKey: args.pass.key, + sessionId, + error: getErrorMessage(error), + }); + }); + throw new Error("Review run cancelled before reviewer prompt dispatch."); + } + + const prompt = truncateText(buildPassPrompt({ + run: args.run, + pass: args.pass, + manifestPrompt: truncateText(args.manifestPrompt, args.run.config.budgets.maxPromptChars), + changedFiles: args.changedFiles, + context: args.context, + contextArtifactIds: args.contextArtifactIds, + }), args.run.config.budgets.maxPromptChars); + 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 ?? [], + }, + }); + promptArtifactId = promptArtifact.id; + updateReviewerRun(reviewerRun.id, { + prompt_artifact_id: promptArtifactId, + updated_at: nowIso(), + }); + const result = await agentChatService.runSessionTurn({ + 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, + }, + }); + outputArtifactId = outputArtifact.id; + updateReviewerRun(reviewerRun.id, { + output_artifact_id: outputArtifactId, + updated_at: nowIso(), + }); + const parsed = extractJsonObject(result.outputText); + const normalized = normalizeParsedFindings({ + runId: args.runId, + reviewerRunId: reviewerRun.id, passKey: args.pass.key, + parsed, + changedFilesByPath: args.changedFilesByPath, + }); + const candidates = [...normalized.findings] + .sort(compareCandidatesStable) + .slice(0, args.run.config.budgets.maxFindingsPerPass ?? args.run.config.budgets.maxFindings); + for (const candidate of normalized.findings) { + insertCandidateFinding(reviewerRun.id, candidate); + } + 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), + }, + }); + findingsArtifactId = findingsArtifact.id; + const status: ReviewReviewerRunStatus = cancelledRuns.has(args.runId) ? "cancelled" : "completed"; + const endedAt = nowIso(); + updateReviewerRun(reviewerRun.id, { + status, + findings_artifact_id: findingsArtifactId, + candidate_count: normalized.findings.length, + kept_count: candidates.length, 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, + ended_at: endedAt, + updated_at: endedAt, + }); + emit({ + type: "reviewer-updated", + runId: args.runId, + laneId: args.run.laneId, + reviewerRunId: reviewerRun.id, + reviewerKey: args.pass.key, + status, + }); + return { + pass: args.pass, + status, + errorMessage: null, + reviewerRunId: reviewerRun.id, + sessionId, summary: normalized.summary, - totalParsedCount: normalized.findings.length, - keptCount: candidates.length, + candidates, + promptArtifactId: promptArtifact.id, + outputArtifactId: outputArtifact.id, + findingsArtifactId: findingsArtifact.id, 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), - }; + }; + } catch (error) { + const status: ReviewReviewerRunStatus = cancelledRuns.has(args.runId) ? "cancelled" : "failed"; + const endedAt = nowIso(); + const outputArtifact = insertArtifact(args.runId, { + artifactType: "pass_output", + title: `${args.pass.label} error`, + mimeType: "application/json", + contentText: JSON.stringify({ error: getErrorMessage(error) }, null, 2), + metadata: { + passKey: args.pass.key, + modelId: args.descriptorId, + failed: true, + }, + }); + outputArtifactId = outputArtifact.id; + updateReviewerRun(reviewerRun.id, { + status, + prompt_artifact_id: promptArtifactId, + output_artifact_id: outputArtifactId, + findings_artifact_id: findingsArtifactId, + error_message: getErrorMessage(error), + ended_at: endedAt, + updated_at: endedAt, + }); + logger.warn("review.reviewer_failed", { + runId: args.runId, + reviewerKey: args.pass.key, + error: getErrorMessage(error), + }); + emit({ + type: "reviewer-updated", + runId: args.runId, + laneId: args.run.laneId, + reviewerRunId: reviewerRun.id, + reviewerKey: args.pass.key, + status, + }); + return { + pass: args.pass, + status, + errorMessage: getErrorMessage(error), + reviewerRunId: reviewerRun.id, + sessionId, + summary: null, + candidates: [], + promptArtifactId: promptArtifactId ?? "", + outputArtifactId, + findingsArtifactId: findingsArtifactId ?? "", + budgetTrimmedCount: 0, + }; + } finally { + if (sessionId) { + const runSessions = activeReviewerSessions.get(args.runId); + runSessions?.delete(sessionId); + if (runSessions?.size === 0) activeReviewerSessions.delete(args.runId); + } + } } async function executeRun(runId: string): Promise { @@ -1795,9 +2474,13 @@ export function createReviewService({ updated_at: nowIso(), }); + let diffBundleArtifactId: string | null = null; for (const artifact of materialized.artifacts) { if (disposed) return; - insertArtifact(runId, artifact); + const inserted = insertArtifact(runId, artifact); + if (inserted.artifactType === "diff_bundle") { + diffBundleArtifactId = inserted.id; + } } if (disposed) return; @@ -1822,33 +2505,55 @@ export function createReviewService({ 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", - }); - if (disposed) return; const sessionTitle = `Review: ${materialized.targetLabel}`; - sessionService.updateMeta({ - sessionId: session.id, - title: sessionTitle, - }); - updateRun(runId, { - chat_session_id: session.id, - updated_at: nowIso(), - }); 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 promptChangedFiles = changedFiles.slice(0, MANIFEST_PROMPT_FILE_LIMIT); + const manifestPayload = buildChangedFileManifestPayload({ + targetLabel: materialized.targetLabel, + compareTarget: materialized.compareTarget, + changedFiles, + budgets: effectiveRun.config.budgets, + }); + const riskMapPayload = buildRiskMapPayload(changedFiles); + const promptManifestPayload = { + ...buildChangedFileManifestPayload({ + targetLabel: materialized.targetLabel, + compareTarget: materialized.compareTarget, + changedFiles: promptChangedFiles, + budgets: effectiveRun.config.budgets, + }), + totalFileCount: changedFiles.length, + omittedFileCount: Math.max(0, changedFiles.length - promptChangedFiles.length), + promptFileLimit: MANIFEST_PROMPT_FILE_LIMIT, + }; + const promptRiskMapPayload = buildRiskMapPayload(promptChangedFiles); + const manifestArtifact = insertArtifact(runId, { + artifactType: "changed_file_manifest", + title: "Changed-file manifest", + mimeType: "application/json", + contentText: JSON.stringify(manifestPayload, null, 2), + metadata: { + fileCount: changedFiles.length, + totalFileCount: materialized.changedFiles.length, + budgetMode: effectiveRun.config.budgets.unlimited === true ? "unlimited" : "bounded", + }, + }); + const riskMapArtifact = insertArtifact(runId, { + artifactType: "risk_map", + title: "Review risk map", + mimeType: "application/json", + contentText: JSON.stringify(riskMapPayload, null, 2), + metadata: { + fileCount: changedFiles.length, + riskGroupCount: Array.isArray(riskMapPayload.groups) ? riskMapPayload.groups.length : 0, + }, + }); + const manifestPrompt = buildManifestPrompt(promptManifestPayload, promptRiskMapPayload); const reviewContext = await contextBuilder.buildContext({ run: effectiveRun, materialized: { @@ -1878,16 +2583,20 @@ export function createReviewService({ metadata: reviewContext.validation.metadata, }); const contextArtifactIds: ReviewContextArtifactIds = { + manifestArtifactId: manifestArtifact.id, + riskMapArtifactId: riskMapArtifact.id, + diffBundleArtifactId, provenanceArtifactId: provenanceArtifact.id, rulesArtifactId: rulesArtifact.id, validationArtifactId: validationArtifact.id, }; insertArtifact(runId, { artifactType: "prompt", - title: "Review harness plan", + title: "Review orchestration plan", mimeType: "application/json", contentText: JSON.stringify({ targetLabel: materialized.targetLabel, + architecture: "parallel_specialist_reviewers", passKeys: REVIEW_PASSES.map((pass) => pass.key), budgets: effectiveRun.config.budgets, changedFiles: changedFiles.map((entry) => entry.filePath), @@ -1910,67 +2619,131 @@ export function createReviewService({ }, }); - const changedFilesByPath = new Map(changedFiles.map((entry) => [ + const changedFilesByPath = new Map }>(changedFiles.map((entry) => [ entry.filePath, { excerpt: entry.excerpt, lineNumbers: new Set(entry.lineNumbers), - diffPositionsByLine: entry.diffPositionsByLine, }, ])); - 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({ + if (cancelledRuns.has(runId)) { + cancelledRuns.delete(runId); + const endedAt = nowIso(); + updateRun(runId, { + status: "cancelled", + summary: "Run cancelled before reviewer prompts were sent.", + 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 passResults = await Promise.all(REVIEW_PASSES.map((pass) => executePass({ runId, run: effectiveRun, - sessionId: session.id, sessionTitle, descriptorId: descriptor.id, + provider, + model, pass, - diffText, + manifestPrompt, changedFiles, changedFilesByPath, context: reviewContext, contextArtifactIds, + }))); + const firstSessionId = passResults.find((result) => result.sessionId)?.sessionId ?? null; + if (firstSessionId) { + updateRun(runId, { + chat_session_id: firstSessionId, + updated_at: nowIso(), }); - passResults.push(passResult); + } + if (disposed) return; + if (cancelledRuns.has(runId) || passResults.some((result) => result.status === "cancelled")) { + 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 completedPassResults = passResults.filter((result) => result.status === "completed"); + const failedPassResults = passResults.filter((result) => result.status === "failed"); + if (failedPassResults.length > 0) { + const failedLabels = failedPassResults.map((result) => result.pass.label).join(", "); + if (completedPassResults.length > 0) { + insertArtifact(runId, { + artifactType: "tool_evidence", + title: "Reviewer failure summary", + mimeType: "application/json", + contentText: JSON.stringify({ + failedReviewers: failedPassResults.map((result) => ({ + passKey: result.pass.key, + label: result.pass.label, + errorMessage: result.errorMessage, + })), + completedReviewerCount: completedPassResults.length, + }, null, 2), + metadata: { + stage: "reviewer_failures", + failedReviewerCount: failedPassResults.length, + completedReviewerCount: completedPassResults.length, + }, + }); + } else { + const endedAt = nowIso(); + updateRun(runId, { + status: "failed", + summary: null, + error_message: `Review failed because ${failedPassResults.length} specialist reviewer${failedPassResults.length === 1 ? "" : "s"} failed: ${failedLabels}.`, + ended_at: endedAt, + updated_at: endedAt, + }); + emit({ type: "run-completed", runId, laneId: run.laneId, status: "failed" }); + emit({ type: "runs-updated", runId, laneId: run.laneId, status: "failed" }); + return; + } } if (disposed) return; const adjudication = adjudicatePassFindings({ runId, - passResults, + passResults: completedPassResults, budgets: effectiveRun.config.budgets, context: reviewContext, artifactIds: contextArtifactIds, }); + const failedReviewerWarning = failedPassResults.length > 0 + ? `Partial review: ${failedPassResults.length} specialist reviewer${failedPassResults.length === 1 ? "" : "s"} failed (${failedPassResults.map((result) => result.pass.label).join(", ")}).` + : null; + const failedReviewerSummary = failedReviewerWarning + ? ` ${failedReviewerWarning} ADE adjudicated only the completed reviewer outputs.` + : ""; + const finalSummary = `${adjudication.summary}${failedReviewerSummary}`; insertArtifact(runId, { artifactType: "adjudication_result", title: "Review adjudication", mimeType: "application/json", contentText: JSON.stringify({ - summary: adjudication.summary, + summary: finalSummary, totalCandidateCount: adjudication.totalCandidateCount, publicationEligibleCount: adjudication.publicationEligibleCount, rejected: adjudication.rejected, passSummaries: passResults.map((result) => ({ passKey: result.pass.key, + status: result.status, summary: result.summary, + errorMessage: result.errorMessage, keptCount: result.candidates.length, budgetTrimmedCount: result.budgetTrimmedCount, findingsArtifactId: result.findingsArtifactId, @@ -1987,7 +2760,7 @@ export function createReviewService({ title: "Merged review findings", mimeType: "application/json", contentText: JSON.stringify({ - summary: adjudication.summary, + summary: finalSummary, findings: adjudication.findings, }, null, 2), metadata: { @@ -2000,7 +2773,7 @@ export function createReviewService({ title: "Adjudicated review output", mimeType: "application/json", contentText: JSON.stringify({ - summary: adjudication.summary, + summary: finalSummary, findings: adjudication.findings, }, null, 2), metadata: { @@ -2075,18 +2848,38 @@ export function createReviewService({ metadata: { suppressedCount }, }); } - await publishRun({ - runId, - targetLabel: materialized.targetLabel, - summary: adjudication.summary, - config: effectiveRun.config, - findings: publishableFindings, - publicationTarget: materialized.publicationTarget, - changedFiles: materialized.changedFiles.map((entry) => ({ - filePath: entry.filePath, - diffPositionsByLine: entry.diffPositionsByLine, - })), - }); + if (failedPassResults.length > 0) { + insertArtifact(runId, { + artifactType: "tool_evidence", + title: "Publication skipped for partial review", + mimeType: "application/json", + contentText: JSON.stringify({ + reason: "partial_review", + failedReviewers: failedPassResults.map((result) => ({ + passKey: result.pass.key, + label: result.pass.label, + errorMessage: result.errorMessage, + })), + }, null, 2), + metadata: { + skippedPublication: true, + failedReviewerCount: failedPassResults.length, + }, + }); + } else { + await publishRun({ + runId, + targetLabel: materialized.targetLabel, + summary: finalSummary, + config: effectiveRun.config, + findings: publishableFindings, + publicationTarget: materialized.publicationTarget, + changedFiles: materialized.changedFiles.map((entry) => ({ + filePath: entry.filePath, + diffPositionsByLine: entry.diffPositionsByLine, + })), + }); + } if (disposed) return; const severitySummary = tallySeveritySummary(findings); const endedAt = nowIso(); @@ -2094,8 +2887,8 @@ export function createReviewService({ if (cancelledDuringPublish) cancelledRuns.delete(runId); updateRun(runId, { status: cancelledDuringPublish ? "cancelled" : "completed", - summary: adjudication.summary, - error_message: null, + summary: finalSummary, + error_message: failedReviewerWarning, finding_count: findings.length, severity_summary_json: serializeSeveritySummary(severitySummary), ended_at: endedAt, @@ -2238,6 +3031,18 @@ export function createReviewService({ "select * from review_run_artifacts where run_id = ? order by created_at asc", [args.runId], ).map(mapArtifactRow); + const reviewerRuns = db.all( + `select * from review_reviewer_runs + where run_id = ? + order by created_at asc, reviewer_key asc`, + [args.runId], + ).map(mapReviewerRunRow); + const candidateFindings = db.all( + `select * from review_candidate_findings + where run_id = ? + order by score desc, created_at asc, title asc`, + [args.runId], + ).map(mapCandidateFindingRow); const publications = db.all( "select * from review_run_publications where run_id = ? order by created_at asc", [args.runId], @@ -2249,6 +3054,8 @@ export function createReviewService({ ...run, findings, artifacts, + reviewerRuns, + candidateFindings, publications, chatSession, }; @@ -2265,10 +3072,22 @@ export function createReviewService({ const endedAt = nowIso(); updateRun(args.runId, { status: "cancelled", - summary: row.summary ?? "Cancellation requested; finishing current pass.", + summary: row.summary ?? "Cancellation requested; stopping active reviewers.", ended_at: endedAt, updated_at: endedAt, }); + const activeSessions = Array.from(activeReviewerSessions.get(args.runId) ?? []); + await Promise.all(activeSessions.map(async (sessionId) => { + try { + await agentChatService.interrupt({ sessionId }); + } catch (error) { + logger.warn("review.cancel_reviewer_interrupt_failed", { + runId: args.runId, + sessionId, + error: getErrorMessage(error), + }); + } + })); const refreshed = getRunRow(args.runId); if (refreshed) { emit({ type: "runs-updated", runId: args.runId, laneId: refreshed.lane_id, status: "cancelled" }); diff --git a/apps/desktop/src/main/services/state/kvDb.test.ts b/apps/desktop/src/main/services/state/kvDb.test.ts index 444b0f463..dd581d907 100644 --- a/apps/desktop/src/main/services/state/kvDb.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.test.ts @@ -222,6 +222,93 @@ describe("openKvDb SQL binding", () => { }); describe.skipIf(!isCrsqliteAvailable())("openKvDb CRR repair", () => { + it("keeps composite-key PR AI summary cache local-only", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-ai-summary-local-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + + expect( + db.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'table' and name = 'pull_request_ai_summaries__crsql_clock' limit 1", + ), + ).toBeNull(); + expect( + db.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'table' and name = 'pull_request_ai_summaries__crsql_pks' limit 1", + ), + ).toBeNull(); + }); + + it("removes stale CRR metadata for local-only PR AI summaries", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-ai-summary-crr-cleanup-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const first = await openKvDb(dbPath, createLogger() as any); + first.run("create table pull_request_ai_summaries__crsql_clock(dummy integer)"); + first.run("create table pull_request_ai_summaries__crsql_pks(dummy integer)"); + first.run(` + create trigger pull_request_ai_summaries__crsql_utrig + after update on pull_request_ai_summaries + begin + select 1; + end + `); + first.close(); + + const reopened = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => reopened.close()); + + expect( + reopened.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'table' and name = 'pull_request_ai_summaries__crsql_clock' limit 1", + ), + ).toBeNull(); + expect( + reopened.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'table' and name = 'pull_request_ai_summaries__crsql_pks' limit 1", + ), + ).toBeNull(); + expect( + reopened.get<{ present: number }>( + "select 1 as present from sqlite_master where type = 'trigger' and name = 'pull_request_ai_summaries__crsql_utrig' limit 1", + ), + ).toBeNull(); + }); + + it("removes metadata-only CRR rows for local-only PR AI summaries", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-ai-summary-crr-metadata-only-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const first = await openKvDb(dbPath, createLogger() as any); + insertProjectGraph(first); + insertSessionAndPr(first); + first.get("select crsql_as_crr(?)", ["pull_request_ai_summaries"]); + first.run( + `insert into pull_request_ai_summaries(pr_id, head_sha, summary_json, generated_at) + values (?, ?, ?, ?)`, + ["pr-1", "head-1", "{}", "2026-03-17T00:00:00.000Z"], + ); + expect(first.get<{ count: number }>("select count(1) as count from crsql_changes where [table] = ?", ["pull_request_ai_summaries"])?.count).toBeGreaterThan(0); + for (const trigger of first.all<{ name: string }>( + "select name from sqlite_master where type = 'trigger' and tbl_name = ? and name like ?", + ["pull_request_ai_summaries", "pull_request_ai_summaries__crsql_%trig"], + )) { + first.run(`drop trigger if exists "${trigger.name}"`); + } + first.run("drop table pull_request_ai_summaries__crsql_clock"); + first.run("drop table pull_request_ai_summaries__crsql_pks"); + first.close(); + + const reopened = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => reopened.close()); + + expect( + reopened.get<{ count: number }>( + "select count(1) as count from crsql_changes where [table] = ?", + ["pull_request_ai_summaries"], + )?.count, + ).toBe(0); + }); + it("backfills phone-critical tables whose rows predate CRR enablement", async () => { const projectRoot = makeProjectRoot("ade-kvdb-pre-crr-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 7a44998a6..d6a313c4d 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -146,6 +146,11 @@ function rawHasTable(db: DatabaseSyncType, tableName: string): boolean { return Boolean(getRow(db, "select 1 as present from sqlite_master where type = 'table' and name = ? limit 1", [tableName])); } +function rawHasColumn(db: DatabaseSyncType, tableName: string, columnName: string): boolean { + return allRows<{ name: string }>(db, `pragma table_info('${tableName.replace(/'/g, "''")}')`) + .some((column) => column.name === columnName); +} + function isReadonlyDatabaseError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /readonly database|SQLITE_READONLY/i.test(message); @@ -419,6 +424,13 @@ function writeMigrationBackupIfNeeded(dbPath: string): void { } } +const LOCAL_ONLY_CRR_EXCLUDED_TABLES = new Set([ + "lane_detail_snapshots", + "lane_list_snapshots", + "pr_auto_link_ignores", + "pull_request_ai_summaries", +]); + function listEligibleCrrTables(db: DatabaseSyncType): string[] { const tables = allRows<{ name: string; sql: string | null }>( db, @@ -433,6 +445,7 @@ function listEligibleCrrTables(db: DatabaseSyncType): string[] { and name not like 'unified_memories_fts%'` ); return tables + .filter((table) => !LOCAL_ONLY_CRR_EXCLUDED_TABLES.has(table.name)) .filter((table) => !table.sql?.toLowerCase().startsWith("create virtual table")) .filter((table) => allRows<{ pk: number }>(db, `pragma table_info('${table.name.replace(/'/g, "''")}')`).some((column) => column.pk > 0)) .map((table) => table.name); @@ -497,6 +510,68 @@ function listCrrTriggers(db: DatabaseSyncType, tableName: string): string[] { ).map((row) => row.name); } +function dropCrrTriggers(db: DatabaseSyncType, tableName: string, logger?: Logger): number { + const triggers = listCrrTriggers(db, tableName); + for (const triggerName of triggers) { + try { + runStatement(db, `drop trigger if exists ${quoteIdentifier(triggerName)}`); + } catch (error) { + logger?.warn("db.crr_trigger_drop_failed", { + tableName, + triggerName, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return triggers.length; +} + +function removeExcludedCrrMetadata(db: DatabaseSyncType, logger?: Logger): void { + for (const tableName of LOCAL_ONLY_CRR_EXCLUDED_TABLES) { + const clockTableName = `${tableName}__crsql_clock`; + const pksTableName = `${tableName}__crsql_pks`; + const hasClockTable = rawHasTable(db, clockTableName); + const hasPksTable = rawHasTable(db, pksTableName); + const triggerCount = listCrrTriggers(db, tableName).length; + const hasMasterRows = rawHasTable(db, "crsql_master") + && rawHasColumn(db, "crsql_master", "tbl_name") + && Boolean(getRow(db, "select 1 as present from crsql_master where tbl_name = ? limit 1", [tableName])); + const hasChangesRows = rawHasTable(db, "crsql_changes") + && rawHasColumn(db, "crsql_changes", "table") + && Boolean(getRow(db, "select 1 as present from crsql_changes where [table] = ? limit 1", [tableName])); + + if (!hasClockTable && !hasPksTable && triggerCount === 0 && !hasMasterRows && !hasChangesRows) { + continue; + } + + let deletedMetadataCount = 0; + if (hasMasterRows) { + deletedMetadataCount += runStatement(db, "delete from crsql_master where tbl_name = ?", [tableName]).changes; + } + if (hasChangesRows) { + deletedMetadataCount += runStatement(db, "delete from crsql_changes where [table] = ?", [tableName]).changes; + } + + try { + getRow(db, "select crsql_as_table(?) as ok", [tableName]); + } catch { + // Older or partial CRR metadata may not be registered enough for + // crsql_as_table; explicit shadow-table cleanup below is still safe. + } + const droppedTriggerCount = dropCrrTriggers(db, tableName, logger); + runStatement(db, `drop table if exists ${quoteIdentifier(clockTableName)}`); + runStatement(db, `drop table if exists ${quoteIdentifier(pksTableName)}`); + + logger?.info("db.crr_excluded_metadata_removed", { + tableName, + hadClockTable: hasClockTable, + hadPksTable: hasPksTable, + droppedTriggerCount, + deletedMetadataCount, + }); + } +} + function tableNeedsCrrTriggerRepair(db: DatabaseSyncType, tableName: string): boolean { if (!rawHasTable(db, `${tableName}__crsql_clock`)) { return false; @@ -588,6 +663,8 @@ function rebuildCrrTableWithBackfill(db: DatabaseSyncType, tableName: string): v } function ensureCrrTables(db: DatabaseSyncType, logger?: Logger): void { + removeExcludedCrrMetadata(db, logger); + const repairTargets = new Set(PHONE_CRITICAL_CRR_TABLES); for (const tableName of listEligibleCrrTables(db)) { if (rawHasTable(db, `${tableName}__crsql_clock`)) { @@ -1404,6 +1481,24 @@ function migrate(db: MigrationDb) { try { db.run("alter table pull_requests add column head_sha text"); } catch {} try { db.run("alter table pull_requests add column creation_strategy text"); } catch {} + db.run("drop table if exists github_pr_cache"); + + db.run(` + create table if not exists pr_auto_link_ignores ( + project_id text not null, + repo_owner text not null, + repo_name text not null, + github_pr_number integer not null, + lane_id text not null, + head_branch text, + created_at text not null, + primary key(project_id, repo_owner, repo_name, github_pr_number, lane_id), + foreign key(project_id) references projects(id), + foreign key(lane_id) references lanes(id) + ) + `); + db.run("create index if not exists idx_pr_auto_link_ignores_project_repo on pr_auto_link_ignores(project_id, repo_owner, repo_name)"); + // Phase 21: AI PR summary cache (keyed by PR + headSha so pushes invalidate). db.run(` create table if not exists pull_request_ai_summaries ( @@ -3542,6 +3637,58 @@ function migrate(db: MigrationDb) { ) `); db.run("create index if not exists idx_review_run_artifacts_run on review_run_artifacts(run_id, created_at)"); + + db.run(` + create table if not exists review_reviewer_runs ( + id text primary key, + run_id text not null, + reviewer_key text not null, + label text not null, + focus text not null, + status text not null, + chat_session_id text, + prompt_artifact_id text, + output_artifact_id text, + findings_artifact_id text, + candidate_count integer not null default 0, + kept_count integer not null default 0, + summary text, + error_message text, + started_at text, + ended_at text, + created_at text not null, + updated_at text not null, + foreign key(run_id) references review_runs(id) on delete cascade + ) + `); + db.run("create index if not exists idx_review_reviewer_runs_run on review_reviewer_runs(run_id, created_at)"); + db.run("create index if not exists idx_review_reviewer_runs_run_key on review_reviewer_runs(run_id, reviewer_key)"); + + db.run(` + create table if not exists review_candidate_findings ( + id text primary key, + run_id text not null, + reviewer_run_id text not null, + reviewer_key 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, + file_path text, + line integer, + anchor_state text not null, + evidence_score real not null default 0, + low_signal integer not null default 0, + score real not null default 0, + created_at text not null, + foreign key(run_id) references review_runs(id) on delete cascade, + foreign key(reviewer_run_id) references review_reviewer_runs(id) on delete cascade + ) + `); + db.run("create index if not exists idx_review_candidate_findings_run on review_candidate_findings(run_id)"); + db.run("create index if not exists idx_review_candidate_findings_reviewer on review_candidate_findings(reviewer_run_id)"); 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 {} @@ -3830,6 +3977,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { const migrateDb = makeMigrateDb(); repairUnifiedMemoryFtsSchemaForRuntime(migrateDb); migrate(migrateDb); + removeExcludedCrrMetadata(db, logger); if (existedBeforeOpen && !hasCrsqlMetadata(db)) { writeMigrationBackupIfNeeded(dbPath); @@ -3852,6 +4000,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { const remigrateDb = makeMigrateDb(); repairUnifiedMemoryFtsSchemaForRuntime(remigrateDb); migrate(remigrateDb); + removeExcludedCrrMetadata(db, logger); } let retrofittedForeignKeySchema = false; @@ -3871,6 +4020,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { const remigrateDb = makeMigrateDb(); repairUnifiedMemoryFtsSchemaForRuntime(remigrateDb); migrate(remigrateDb); + removeExcludedCrrMetadata(db, logger); } if (crsqliteLoaded) { diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 3772d425b..abd81ff8b 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -2806,14 +2806,16 @@ describe("createSyncRemoteCommandService", () => { }); }); - it("git.stashApply requires laneId and stashRef", async () => { + it("git.stashApply passes an optional stashOid", async () => { await service.execute(makePayload("git.stashApply", { laneId: "lane-1", stashRef: "stash@{0}", + stashOid: "oid-0", })); expect(gitService.stashApply).toHaveBeenCalledWith({ laneId: "lane-1", stashRef: "stash@{0}", + stashOid: "oid-0", }); }); @@ -2821,6 +2823,39 @@ describe("createSyncRemoteCommandService", () => { await expect(service.execute(makePayload("git.stashApply", { laneId: "lane-1" }))) .rejects.toThrow("git.stashApply requires stashRef."); }); + + it("git.stashPop and git.stashDrop require stashOid", async () => { + await expect(service.execute(makePayload("git.stashPop", { + laneId: "lane-1", + stashRef: "stash@{0}", + }))).rejects.toThrow("git.stashPop requires stashOid."); + await expect(service.execute(makePayload("git.stashDrop", { + laneId: "lane-1", + stashRef: "stash@{0}", + }))).rejects.toThrow("git.stashDrop requires stashOid."); + + await service.execute(makePayload("git.stashPop", { + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + })); + await service.execute(makePayload("git.stashDrop", { + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + })); + + expect(gitService.stashPop).toHaveBeenCalledWith({ + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + }); + expect(gitService.stashDrop).toHaveBeenCalledWith({ + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-0", + }); + }); }); // --------------------------------------------------------------- diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 3f43f17dc..eb16cef12 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -294,6 +294,9 @@ import type { GitSyncArgs, GitHubRepoRef, GitHubStatus, + CreateLaneFromPrBranchArgs, + CreateLaneFromPrBranchPreflightResult, + CreateLaneFromPrBranchResult, CreatePrFromLaneArgs, CreateQueuePrsArgs, CreateQueuePrsResult, @@ -1970,6 +1973,12 @@ declare global { prs: { createFromLane: (args: CreatePrFromLaneArgs) => Promise; linkToLane: (args: LinkPrToLaneArgs) => Promise; + preflightCreateLaneFromPrBranch: ( + args: CreateLaneFromPrBranchArgs, + ) => Promise; + createLaneFromPrBranch: ( + args: CreateLaneFromPrBranchArgs, + ) => Promise; getForLane: (laneId: string) => Promise; listAll: () => Promise; listOpenForRepo: () => Promise; @@ -2066,7 +2075,7 @@ declare global { includeCompleted?: boolean; limit?: number; }) => Promise; - getConflictAnalysis: (prId: string) => Promise; + getConflictAnalysis: (prId: string) => Promise; getMergeContext: (prId: string) => Promise; getMergeContexts: ( prIds: string[], diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index ef71543ca..67a769fa9 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -174,6 +174,146 @@ describe("preload OAuth bridge", () => { expect(removeListener).toHaveBeenCalledWith(IPC.reviewEvent, listener); }); + it("routes review.startRun through a bound local runtime without dropping unlimited budgets", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const startArgs = { + target: { mode: "lane_diff", laneId: "lane-1" }, + config: { + compareAgainst: { kind: "default_branch" }, + selectionMode: "full_diff", + dirtyOnly: false, + modelId: "openai/gpt-5.4", + reasoningEffort: "medium", + budgets: { + unlimited: true, + maxFiles: Number.MAX_SAFE_INTEGER, + maxDiffChars: Number.MAX_SAFE_INTEGER, + maxPromptChars: Number.MAX_SAFE_INTEGER, + maxFindings: Number.MAX_SAFE_INTEGER, + maxFindingsPerPass: Number.MAX_SAFE_INTEGER, + maxPublishedFindings: Number.MAX_SAFE_INTEGER, + }, + publishBehavior: "local_only", + }, + }; + const run = { + id: "review-run-1", + projectId: "project-1", + laneId: "lane-1", + target: startArgs.target, + config: startArgs.config, + targetLabel: "Lane 1", + compareTarget: null, + status: "queued", + summary: null, + errorMessage: null, + findingCount: 0, + severitySummary: {}, + chatSessionId: null, + createdAt: "2026-05-19T12:00:00.000Z", + startedAt: "2026-05-19T12:00:00.000Z", + endedAt: null, + updatedAt: "2026-05-19T12:00:00.000Z", + }; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.localRuntimeCallAction) { + const request = (arg as { request?: { domain?: string; action?: string; args?: unknown } } | undefined)?.request; + expect(request?.domain).toBe("review"); + expect(request?.action).toBe("startRun"); + expect(request?.args).toEqual(startArgs); + return { result: run }; + } + if (channel === IPC.reviewStartRun) { + throw new Error("runtime-bound review.startRun should not call desktop review IPC"); + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + 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 expect(bridge.review.startRun(startArgs)).resolves.toEqual(run); + + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + request: { domain: "review", action: "startRun", args: startArgs }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.reviewStartRun, expect.anything()); + }); + + it("does not fall through to in-process review IPC when a bound local runtime cannot call review.startRun", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.localRuntimeCallAction) { + const request = (arg as { request?: { domain?: string; action?: string } } | undefined)?.request; + throw new Error(`Action '${request?.domain}.${request?.action}' is not callable.`); + } + if (channel === IPC.reviewStartRun) { + throw new Error("runtime-bound review.startRun should not call desktop review IPC"); + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + 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; + const args = { target: { mode: "lane_diff", laneId: "lane-1" } }; + + await expect(bridge.review.startRun(args)).rejects.toThrow("not callable"); + + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + request: { domain: "review", action: "startRun", args }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.reviewStartRun, expect.anything()); + }); + it("exposes macOS VM IPC methods and cleans up listeners", async () => { const invoke = vi.fn(async () => undefined); const on = vi.fn(); @@ -580,7 +720,7 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.agentChatModelCatalog, { mode: "cached" }); }); - it("uses in-process IPC for local PR tab reads instead of waiting on the runtime daemon", async () => { + it("routes local PR tab reads through the project runtime", async () => { const binding = { kind: "local", key: "local:/repo", @@ -634,11 +774,15 @@ describe("preload OAuth bridge", () => { return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; } if (channel === IPC.localRuntimeCallAction) { - throw new Error("PR tab reads should not call the local runtime daemon"); + const request = (arg as { request?: { action?: string } } | undefined)?.request; + if (request?.action === "getDetail") return { result: detail }; + if (request?.action === "listWithConflicts") return { result: prs }; + if (request?.action === "getGithubSnapshot") return { result: snapshot }; + throw new Error(`unexpected local PR action: ${request?.action}`); + } + if (channel === IPC.prsGetDetail || channel === IPC.prsListWithConflicts || channel === IPC.prsGetGitHubSnapshot) { + throw new Error("local runtime PR reads should not call desktop PR IPC"); } - if (channel === IPC.prsGetDetail) return detail; - if (channel === IPC.prsListWithConflicts) return prs; - if (channel === IPC.prsGetGitHubSnapshot) return snapshot; throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); }); const on = vi.fn(); @@ -665,13 +809,143 @@ describe("preload OAuth bridge", () => { await expect(bridge.prs.listWithConflicts()).resolves.toEqual(prs); await expect(bridge.prs.getGitHubSnapshot()).resolves.toEqual(snapshot); - expect(invoke).toHaveBeenCalledWith(IPC.prsGetDetail, { prId: "pr-1" }); - expect(invoke).toHaveBeenCalledWith(IPC.prsListWithConflicts, {}); - expect(invoke).toHaveBeenCalledWith(IPC.prsGetGitHubSnapshot, {}); - expect(invoke).not.toHaveBeenCalledWith( - IPC.localRuntimeCallAction, - expect.anything(), - ); + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + request: { domain: "pr", action: "getDetail", arg: "pr-1" }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + request: { domain: "pr", action: "listWithConflicts", args: {} }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + request: { domain: "pr", action: "getGithubSnapshot", args: {} }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsGetDetail, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsListWithConflicts, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsGetGitHubSnapshot, expect.anything()); + }); + + it("does not fall through to in-process PR branch import IPC when a bound local runtime action is missing", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.localRuntimeCallAction) { + const request = (arg as { request?: { domain?: string; action?: string } } | undefined)?.request; + throw new Error(`Action '${request?.domain}.${request?.action}' is not callable.`); + } + if (channel === IPC.prsPreflightCreateLaneFromPrBranch || channel === IPC.prsCreateLaneFromPrBranch) { + throw new Error("runtime-bound PR branch import should not call desktop PR IPC"); + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + 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; + const args = { repoOwner: "owner", repoName: "repo", githubPrNumber: 12 }; + + await expect(bridge.prs.preflightCreateLaneFromPrBranch(args)).rejects.toThrow("not callable"); + await expect(bridge.prs.createLaneFromPrBranch(args)).rejects.toThrow("not callable"); + + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + request: { domain: "pr", action: "preflightCreateLaneFromPrBranch", args }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + request: { domain: "pr", action: "createLaneFromPrBranch", args }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsPreflightCreateLaneFromPrBranch, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsCreateLaneFromPrBranch, expect.anything()); + }); + + it("falls back to in-process PR branch import IPC when no runtime is bound", async () => { + const args = { repoOwner: "owner", repoName: "repo", githubPrNumber: 12 }; + const preflightResult = { + preflight: { + repoOwner: "owner", + repoName: "repo", + githubPrNumber: 12, + githubUrl: "https://github.com/owner/repo/pull/12", + title: "Import branch", + headBranch: "feature/import", + headSha: "abc123", + headRepoOwner: "owner", + headRepoName: "repo", + remoteBranch: "origin/feature/import", + importBranchRef: "origin/feature/import", + targetLaneName: "Import branch", + baseBranch: "main", + canCreate: true, + status: "ready", + blockingConflict: null, + blockingConflicts: [], + }, + lane: null, + pr: null, + }; + const createResult = { + ...preflightResult, + lane: { id: "lane-created" }, + pr: { id: "pr-created" }, + }; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding: null }; + } + if (channel === IPC.prsPreflightCreateLaneFromPrBranch) return preflightResult; + if (channel === IPC.prsCreateLaneFromPrBranch) return createResult; + if (channel === IPC.localRuntimeCallAction || channel === IPC.remoteRuntimeCallAction) { + throw new Error("unbound project should not call runtime IPC"); + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + 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 expect(bridge.prs.preflightCreateLaneFromPrBranch(args)).resolves.toEqual(preflightResult); + await expect(bridge.prs.createLaneFromPrBranch(args)).resolves.toEqual(createResult); + + expect(invoke).toHaveBeenCalledWith(IPC.prsPreflightCreateLaneFromPrBranch, args); + expect(invoke).toHaveBeenCalledWith(IPC.prsCreateLaneFromPrBranch, args); + expect(invoke).not.toHaveBeenCalledWith(IPC.localRuntimeCallAction, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, expect.anything()); }); it("keeps remote runtime routing for PR tab reads when the project is remote", async () => { @@ -1789,6 +2063,16 @@ describe("preload OAuth bridge", () => { await bridge.git.commit({ laneId: "lane-1", message: "checkpoint" }); await bridge.git.push({ laneId: "lane-1" }); await bridge.prs.createFromLane({ laneId: "lane-1", title: "Remote PR", body: "Proof" }); + await bridge.prs.preflightCreateLaneFromPrBranch({ + repoOwner: "owner", + repoName: "repo", + githubPrNumber: 12, + }); + await bridge.prs.createLaneFromPrBranch({ + repoOwner: "owner", + repoName: "repo", + githubPrNumber: 12, + }); const actions = invoke.mock.calls .filter(([channel]) => channel === IPC.remoteRuntimeCallAction) @@ -1800,12 +2084,16 @@ describe("preload OAuth bridge", () => { "git.commit", "git.push", "pr.createFromLane", + "pr.preflightCreateLaneFromPrBranch", + "pr.createLaneFromPrBranch", ]); expect(invoke).not.toHaveBeenCalledWith(IPC.lanesCreate, expect.anything()); expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatCreate, expect.anything()); expect(invoke).not.toHaveBeenCalledWith(IPC.gitCommit, expect.anything()); expect(invoke).not.toHaveBeenCalledWith(IPC.gitPush, expect.anything()); expect(invoke).not.toHaveBeenCalledWith(IPC.prsCreateFromLane, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsPreflightCreateLaneFromPrBranch, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.prsCreateLaneFromPrBranch, expect.anything()); }); it("routes bulk PR merge context hydration with positional runtime args", async () => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e4a85c98b..4bc25da72 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -209,6 +209,9 @@ import type { GitSyncArgs, GitHubRepoRef, GitHubStatus, + CreateLaneFromPrBranchArgs, + CreateLaneFromPrBranchPreflightResult, + CreateLaneFromPrBranchResult, CreatePrFromLaneArgs, DeletePrArgs, DeletePrResult, @@ -1291,6 +1294,18 @@ async function callProjectRuntimeActionOr( return runtime.handled ? runtime.result : local(); } +async function callProjectRuntimeActionStrictOr( + domain: string, + action: string, + request: Omit, + local: () => Promise, +): Promise { + const remote = await callRemoteProjectActionIfBound(domain, action, request); + if (remote.handled) return remote.result; + const localRuntime = await callLocalProjectActionStrictIfBound(domain, action, request); + return localRuntime.handled ? localRuntime.result : local(); +} + async function callRemoteProjectRuntimeActionOr( domain: string, action: string, @@ -1310,7 +1325,7 @@ function callPrReadRuntimeActionOr( request: Omit, local: () => Promise, ): Promise { - return callRemoteProjectRuntimeActionOr("pr", action, request, local); + return callProjectRuntimeActionOr("pr", action, request, local); } async function callProjectFileRuntimeActionOr( @@ -3570,7 +3585,7 @@ contextBridge.exposeInMainWorld("ade", { () => ipcRenderer.invoke(IPC.reviewGetRunDetail, { runId }), ), startRun: async (args: ReviewStartRunArgs): Promise => - callProjectRuntimeActionOr("review", "startRun", { args }, () => + callProjectRuntimeActionStrictOr("review", "startRun", { args }, () => ipcRenderer.invoke(IPC.reviewStartRun, args), ), rerun: async (runId: string): Promise => @@ -7034,6 +7049,18 @@ contextBridge.exposeInMainWorld("ade", { callProjectRuntimeActionOr("pr", "linkToLane", { args }, () => ipcRenderer.invoke(IPC.prsLinkToLane, args), ), + preflightCreateLaneFromPrBranch: async ( + args: CreateLaneFromPrBranchArgs, + ): Promise => + callProjectRuntimeActionStrictOr("pr", "preflightCreateLaneFromPrBranch", { args }, () => + ipcRenderer.invoke(IPC.prsPreflightCreateLaneFromPrBranch, args), + ), + createLaneFromPrBranch: async ( + args: CreateLaneFromPrBranchArgs, + ): Promise => + callProjectRuntimeActionStrictOr("pr", "createLaneFromPrBranch", { args }, () => + ipcRenderer.invoke(IPC.prsCreateLaneFromPrBranch, args), + ), getForLane: async (laneId: string): Promise => callPrReadRuntimeActionOr("getForLane", { arg: laneId }, () => ipcRenderer.invoke(IPC.prsGetForLane, { laneId }), @@ -7221,7 +7248,7 @@ contextBridge.exposeInMainWorld("ade", { { args: args ?? {} }, () => ipcRenderer.invoke(IPC.prsListQueueStates, args ?? {}), ), - getConflictAnalysis: (prId: string): Promise => + getConflictAnalysis: (prId: string): Promise => callProjectRuntimeActionOr( "pr", "getConflictAnalysis", diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 639fc532e..5efccb00d 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -1380,7 +1380,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { toast.event.kind === "changes_requested" || toast.event.kind === "review_requested" ) { - detailTab = "activity"; + detailTab = "overview"; } const search = buildPrsRouteSearch({ activeTab: "normal", diff --git a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts index a60f66bce..6bda7deeb 100644 --- a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts +++ b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts @@ -431,28 +431,40 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ domain: "git", action: "stashApply", label: "Stash apply", - description: "Apply a stash entry without removing it from the stash list.", - params: [LANE_ID_PARAM, { name: "stashRef", type: "string", required: true, description: "Stash ref e.g. stash@{0}." }], + description: "Apply a branch stash entry without removing it from the stash list.", + params: [ + LANE_ID_PARAM, + { name: "stashRef", type: "string", required: true, description: "Stash ref e.g. stash@{0}." }, + { name: "stashOid", type: "string", description: "Optional stash commit OID from listStashes." }, + ], }, { domain: "git", action: "stashPop", label: "Stash pop", - description: "Apply a stash entry and remove it from the stash list.", - params: [LANE_ID_PARAM, { name: "stashRef", type: "string", required: true }], + description: "Apply a branch stash entry and remove it from the stash list.", + params: [ + LANE_ID_PARAM, + { name: "stashRef", type: "string", required: true }, + { name: "stashOid", type: "string", required: true, description: "Stash commit OID from listStashes." }, + ], }, { domain: "git", action: "stashDrop", label: "Stash drop", - description: "Discard a single stash entry without applying it.", - params: [LANE_ID_PARAM, { name: "stashRef", type: "string", required: true }], + description: "Discard a single branch stash entry without applying it.", + params: [ + LANE_ID_PARAM, + { name: "stashRef", type: "string", required: true }, + { name: "stashOid", type: "string", required: true, description: "Stash commit OID from listStashes." }, + ], }, { domain: "git", action: "stashClear", label: "Stash clear", - description: "Discard every stash entry on the lane.", + description: "Discard every stash entry saved for the lane branch.", params: [LANE_ID_PARAM], }, { diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index a5cb762d8..853ef7ee1 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -110,6 +110,7 @@ import { ConflictPanel as GraphConflictPanel } from "./graphDialogs/ConflictPane import { RiskEdge } from "./graphEdges/RiskEdge"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { PrDetailPane } from "../prs/detail/PrDetailPane"; +import { PrsProvider } from "../prs/state/PrsContext"; import { buildGraphPrOverlay } from "./graphPrData"; import { getPrChecksBadge, getPrReviewsBadge, InlinePrBadge } from "../prs/shared/prVisuals"; @@ -118,12 +119,45 @@ const edgeTypes = { custom: RiskEdge }; const MERGE_SUCCESS_ANIMATION_MS = 1200; const GRAPH_ACTIVITY_SESSION_LIMIT = 150; const GRAPH_ACTIVITY_OPERATION_LIMIT = 150; +const GRAPH_TOPOLOGY_CACHE_TTL_MS = 30_000; +const GRAPH_PR_CACHE_TTL_MS = 5 * 60_000; + +const graphTopologyRefreshedAtByProject = new Map(); +const graphPrCacheByProject = new Map(); + +function isGraphTopologyCacheFresh(projectRoot: string | null): boolean { + if (!projectRoot) return false; + const refreshedAt = graphTopologyRefreshedAtByProject.get(projectRoot) ?? 0; + return refreshedAt > 0 && Date.now() - refreshedAt < GRAPH_TOPOLOGY_CACHE_TTL_MS; +} + +function markGraphTopologyCacheFresh(projectRoot: string | null): void { + if (!projectRoot) return; + graphTopologyRefreshedAtByProject.set(projectRoot, Date.now()); +} + +function readGraphPrCache(projectRoot: string | null): PrWithConflicts[] { + if (!projectRoot) return []; + const cached = graphPrCacheByProject.get(projectRoot); + if (!cached) return []; + if (Date.now() - cached.cachedAt > GRAPH_PR_CACHE_TTL_MS) { + graphPrCacheByProject.delete(projectRoot); + return []; + } + return cached.prs; +} + +function writeGraphPrCache(projectRoot: string | null, prs: PrWithConflicts[]): void { + if (!projectRoot) return; + graphPrCacheByProject.set(projectRoot, { prs, cachedAt: Date.now() }); +} function GraphInner({ active = true }: { active?: boolean }) { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const reactFlow = useReactFlow, Edge>(); const project = useAppStore((s) => s.project); + const projectRoot = project?.rootPath ?? null; const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const lanes = useAppStore((s) => s.lanes); const lanesKey = React.useMemo(() => lanes.map((l) => l.id).join(","), [lanes]); @@ -138,7 +172,7 @@ function GraphInner({ active = true }: { active?: boolean }) { [refreshLanes] ); const [environmentMappings, setEnvironmentMappings] = React.useState([]); - const [prs, setPrs] = React.useState([]); + const [prs, setPrs] = React.useState(() => readGraphPrCache(projectRoot)); const [syncByLaneId, setSyncByLaneId] = React.useState>({}); const [autoRebaseByLaneId, setAutoRebaseByLaneId] = React.useState>({}); const syncRefreshInFlightRef = React.useRef(false); @@ -155,11 +189,17 @@ function GraphInner({ active = true }: { active?: boolean }) { const nodesRef = React.useRef>>([]); const handledFocusLaneRef = React.useRef(null); const handledFocusProposalRef = React.useRef(null); + const projectRootRef = React.useRef(projectRoot); React.useEffect(() => { lanesRef.current = lanes; }, [lanes]); + React.useEffect(() => { + projectRootRef.current = projectRoot; + setPrs(readGraphPrCache(projectRoot)); + }, [projectRoot]); + const reportGraphIssue = React.useCallback((message: string, error?: unknown) => { if (error) console.warn(`[Graph] ${message}`, error); setErrorBanner((prev) => prev ?? message); @@ -175,7 +215,10 @@ function GraphInner({ active = true }: { active?: boolean }) { }, []); const refreshPrs = React.useCallback(async () => { - const next = await window.ade.prs.listWithConflicts(); + const requestProjectRoot = projectRootRef.current; + const next = await window.ade.prs.listWithConflicts({ includeConflictAnalysis: false }); + if (requestProjectRoot !== projectRootRef.current) return; + writeGraphPrCache(requestProjectRoot, next); setPrs(next); }, []); @@ -269,7 +312,7 @@ function GraphInner({ active = true }: { active?: boolean }) { }, [nodes]); const [batch, setBatch] = React.useState(null); const [batchProgress, setBatchProgress] = React.useState(null); - const [loadingTopology, setLoadingTopology] = React.useState(true); + const [loadingTopology, setLoadingTopology] = React.useState(() => lanes.length === 0); const [loadingRisk, setLoadingRisk] = React.useState(true); const [errorBanner, setErrorBanner] = React.useState(null); const [contextMenu, setContextMenu] = React.useState<{ laneId: string; x: number; y: number } | null>(null); @@ -772,26 +815,36 @@ function GraphInner({ active = true }: { active?: boolean }) { React.useEffect(() => { if (!active) return; if (!project?.rootPath) return; + const rootPath = project.rootPath; let cancelled = false; let riskTimer: number | null = null; let activityTimer: number | null = null; let syncTimer: number | null = null; let autoRebaseTimer: number | null = null; - setLoadingTopology(true); + const hasCachedTopology = lanesRef.current.length > 0; + setLoadingTopology(!hasCachedTopology); setLoadedGraphPreferences(false); setSessionState(createSessionState()); setViewMode("all"); setSelectedLaneIds([]); setShowFiltersPanel(false); - - void refreshGraphLanes() - .catch((err) => { - console.warn("[Graph] refreshLanes failed:", err); - reportGraphIssue("The graph could not load the latest lanes.", err); - }) - .finally(() => { - if (!cancelled) setLoadingTopology(false); - }); + setPrs(readGraphPrCache(rootPath)); + + if (hasCachedTopology && isGraphTopologyCacheFresh(rootPath)) { + setLoadingTopology(false); + } else { + void refreshGraphLanes() + .then(() => { + markGraphTopologyCacheFresh(rootPath); + }) + .catch((err) => { + console.warn("[Graph] refreshLanes failed:", err); + if (!hasCachedTopology) reportGraphIssue("The graph could not load the latest lanes.", err); + }) + .finally(() => { + if (!cancelled) setLoadingTopology(false); + }); + } riskTimer = window.setTimeout(() => { if (cancelled || document.visibilityState !== "visible") return; @@ -1571,6 +1624,7 @@ function GraphInner({ active = true }: { active?: boolean }) { }; })); }, [ + active, baseGraph, connectedToHoveredNode, focusLaneId, @@ -2023,7 +2077,8 @@ function GraphInner({ active = true }: { active?: boolean }) { await refreshPrs(); return; } - const refreshedPrs = await window.ade.prs.listWithConflicts(); + const refreshedPrs = await window.ade.prs.listWithConflicts({ includeConflictAnalysis: false }); + writeGraphPrCache(projectRootRef.current, refreshedPrs); setPrs(refreshedPrs); const refreshed = refreshedPrs.find((entry) => entry.id === prDialog.existingPr?.id) ?? null; if (!refreshed) { @@ -3898,7 +3953,8 @@ function GraphInner({ active = true }: { active?: boolean }) { baseBranch: prDialog.baseBranch }) .then(async (created) => { - const refreshed = await window.ade.prs.listWithConflicts(); + const refreshed = await window.ade.prs.listWithConflicts({ includeConflictAnalysis: false }); + writeGraphPrCache(projectRootRef.current, refreshed); setPrs(refreshed); const createdPr = refreshed.find((entry) => entry.id === created.id) ?? null; const [status, checks, reviews, comments] = await Promise.all([ @@ -3968,19 +4024,21 @@ function GraphInner({ active = true }: { active?: boolean }) {
- navigate(path)} - onShowInGraph={(laneId) => navigate(`/graph?focusLane=${encodeURIComponent(laneId)}`)} - /> + + navigate(path)} + onShowInGraph={(laneId) => navigate(`/graph?focusLane=${encodeURIComponent(laneId)}`)} + /> +
) : null} diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index c536eee50..2484833d4 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -68,8 +68,14 @@ function buildLane(overrides: Partial = {}): LaneSummary { }; } -function buildStash(ref: string, subject: string, createdAt = "2026-03-31T12:00:00.000Z"): GitStashSummary { +function buildStash( + ref: string, + subject: string, + createdAt = "2026-03-31T12:00:00.000Z", + oid = `${ref}-oid`, +): GitStashSummary { return { + oid, ref, subject, createdAt, @@ -507,11 +513,14 @@ describe("LaneGitActionsPane rescue action", () => { it("updates the stash count after deleting a stash", async () => { const user = userEvent.setup(); mockStashesByLaneId["lane-1"] = [ - buildStash("stash@{0}", "drop me"), + buildStash("stash@{0}", "drop me", "2026-03-31T12:00:00.000Z", "oid-drop"), buildStash("stash@{1}", "keep me", "2026-03-30T12:00:00.000Z"), ]; - (window.ade.git.stashDrop as any).mockImplementationOnce(async ({ stashRef }: { stashRef: string }) => { + (window.ade.git.stashDrop as any).mockImplementationOnce(async ( + { stashRef, stashOid }: { stashRef: string; stashOid?: string }, + ) => { expect(stashRef).toBe("stash@{0}"); + expect(stashOid).toBe("oid-drop"); mockStashesByLaneId["lane-1"] = [buildStash("stash@{0}", "keep me", "2026-03-30T12:00:00.000Z")]; return { operationId: "stash-drop", preHeadSha: "abc", postHeadSha: "abc" }; }); @@ -526,7 +535,11 @@ describe("LaneGitActionsPane rescue action", () => { await user.click(screen.getByRole("button", { name: "DELETE STASH" })); await waitFor(() => { - expect(window.ade.git.stashDrop).toHaveBeenCalledWith({ laneId: "lane-1", stashRef: "stash@{0}" }); + expect(window.ade.git.stashDrop).toHaveBeenCalledWith({ + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-drop", + }); }); await waitFor(() => { expect(screen.getByText("1 saved")).toBeTruthy(); @@ -538,11 +551,14 @@ describe("LaneGitActionsPane rescue action", () => { it("updates the stash count after restoring a stash", async () => { const user = userEvent.setup(); mockStashesByLaneId["lane-1"] = [ - buildStash("stash@{0}", "restore me"), + buildStash("stash@{0}", "restore me", "2026-03-31T12:00:00.000Z", "oid-restore"), buildStash("stash@{1}", "keep me", "2026-03-30T12:00:00.000Z"), ]; - (window.ade.git.stashPop as any).mockImplementationOnce(async ({ stashRef }: { stashRef: string }) => { + (window.ade.git.stashPop as any).mockImplementationOnce(async ( + { stashRef, stashOid }: { stashRef: string; stashOid?: string }, + ) => { expect(stashRef).toBe("stash@{0}"); + expect(stashOid).toBe("oid-restore"); mockStashesByLaneId["lane-1"] = [buildStash("stash@{0}", "keep me", "2026-03-30T12:00:00.000Z")]; return { operationId: "stash-pop", preHeadSha: "abc", postHeadSha: "abc" }; }); @@ -555,7 +571,11 @@ describe("LaneGitActionsPane rescue action", () => { await user.click(screen.getAllByRole("button", { name: "RESTORE" })[0]); await waitFor(() => { - expect(window.ade.git.stashPop).toHaveBeenCalledWith({ laneId: "lane-1", stashRef: "stash@{0}" }); + expect(window.ade.git.stashPop).toHaveBeenCalledWith({ + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-restore", + }); }); await waitFor(() => { expect(screen.getByText("1 saved")).toBeTruthy(); @@ -564,6 +584,26 @@ describe("LaneGitActionsPane rescue action", () => { expect(screen.getByText("keep me")).toBeTruthy(); }); + it("sends stash oid when copying a stash to the worktree", async () => { + const user = userEvent.setup(); + mockStashesByLaneId["lane-1"] = [ + buildStash("stash@{0}", "copy me", "2026-03-31T12:00:00.000Z", "oid-copy"), + ]; + + renderPane(); + + await screen.findByText("1 saved"); + await user.click(screen.getByRole("button", { name: "COPY TO WORKTREE" })); + + await waitFor(() => { + expect(window.ade.git.stashApply).toHaveBeenCalledWith({ + laneId: "lane-1", + stashRef: "stash@{0}", + stashOid: "oid-copy", + }); + }); + }); + it("updates the stash section even if the broader refresh fails afterward", async () => { const user = userEvent.setup(); mockStashesByLaneId["lane-1"] = [ diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index eb39d6090..e67ee5e6e 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -2528,7 +2528,7 @@ export function LaneGitActionsPane({ >
- STASHES + BRANCH STASHES {stashes.length === 0 ? "None saved" : `${stashes.length} saved`} @@ -2536,10 +2536,10 @@ export function LaneGitActionsPane({
{stashes.length > 0 && ( ", + effect: `Delete ${stashes.length} branch stash${stashes.length === 1 ? "" : "es"}`, warning: "This cannot be undone", }}> ) : null} - {/* Primary: house icon; Non-primary: conflict status dot */} + {/* Primary: house icon; non-primary: conflict status dot */} {isPrimary ? ( ) : ( diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index c654601c6..4c3f0a07d 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -130,6 +130,7 @@ function PRsPageInner() { const consumedCreateRouteKeyRef = React.useRef(null); const [lastWorkflowTab, setLastWorkflowTab] = React.useState(() => readLastWorkflowTab(projectRoot)); const [integrationRefreshNonce, setIntegrationRefreshNonce] = React.useState(0); + const lastRouteLocationKeyRef = React.useRef(null); const [selectedDetailTab, setSelectedDetailTab] = React.useState(() => { try { return parsePrsRouteState({ search: window.location.search, hash: window.location.hash }).detailTab; @@ -186,6 +187,9 @@ function PRsPageInner() { React.useEffect(() => { const syncFromLocation = () => { try { + const locationKey = `${location.pathname}${location.search}${window.location.hash}`; + const locationChanged = lastRouteLocationKeyRef.current !== locationKey; + lastRouteLocationKeyRef.current = locationKey; const routeState = parsePrsRouteState({ search: location.search, hash: window.location.hash, @@ -203,11 +207,16 @@ function PRsPageInner() { setActiveTab(resolved.activeTab); if (!resolved.isWorkflowRoute) { + const hasExplicitPrSelection = Boolean(routeState.prId) || routeState.prNumber != null; const prNumberMatch = routeState.prNumber == null ? null : prs.find((pr) => pr.githubPrNumber === routeState.prNumber)?.id ?? null; - setSelectedPrId(routeState.prId ?? prNumberMatch); - setSelectedDetailTab(routeState.detailTab); + if (hasExplicitPrSelection || locationChanged) { + setSelectedPrId(routeState.prId ?? prNumberMatch); + } + if (hasExplicitPrSelection || locationChanged || routeState.detailTab) { + setSelectedDetailTab(routeState.detailTab); + } } if (resolved.effectiveWorkflow === "queue") { setSelectedQueueGroupId(routeState.queueGroupId ?? 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 284255242..c7cdb11f4 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 @@ -308,10 +308,13 @@ function renderPane(args: { getDetail?: ReturnType; getFiles?: ReturnType; getCommits?: ReturnType; + getActivity?: ReturnType; + addComment?: ReturnType; getActionRuns?: ReturnType; snapshotHydration?: PrSnapshotHydration | null; snapshotHydrationOwnedByContext?: boolean; liveDetailReady?: boolean; + prsTimelineRailsEnabled?: boolean; }) { const laneList = args.lanes ?? [makeLane({ status: { @@ -452,6 +455,16 @@ function renderPane(args: { setResolverModel: vi.fn(), setResolverReasoningLevel: vi.fn(), setResolverPermissionMode: vi.fn(), + prsTimelineRailsEnabled: args.prsTimelineRailsEnabled ?? false, + dismissedAiSummaries: {}, + timelineFiltersByPrId: {}, + detailAiSummary: null, + detailDeployments: [], + detailLiveDataPrId: null, + viewerLogin: "octocat", + setTimelineFilters: vi.fn(), + setAiSummaryDismissed: vi.fn(), + regeneratePrAiSummary: vi.fn(), }); Object.assign(window, { ade: { @@ -471,7 +484,8 @@ function renderPane(args: { getFiles: args.getFiles ?? vi.fn().mockResolvedValue([]), getCommits: args.getCommits, getActionRuns, - getActivity: vi.fn().mockResolvedValue(args.activity ?? []), + getActivity: args.getActivity ?? vi.fn().mockResolvedValue(args.activity ?? []), + addComment: args.addComment ?? vi.fn().mockResolvedValue(undefined), getReviewThreads, issueInventorySync, issueInventoryReset, @@ -563,6 +577,8 @@ function renderPane(args: { aiResolutionStop, getReviewThreads, getActionRuns, + getActivity: window.ade.prs.getActivity, + addComment: window.ade.prs.addComment, issueInventorySync, issueInventoryReset, getChecks, @@ -624,6 +640,7 @@ describe("PrDetailPane issue resolver CTA", () => { afterEach(() => { cleanup(); vi.useRealTimers(); + window.history.replaceState(null, "", "/"); }); it.each(visibilityCases)("$name — Path to Merge tab is always visible", async ({ checks, reviewThreads, statusOverrides }) => { @@ -750,7 +767,7 @@ describe("PrDetailPane issue resolver CTA", () => { }); }); - it("hydrates checks and activity data from a cached snapshot", async () => { + it("hydrates checks and timeline data from a cached snapshot", async () => { const user = userEvent.setup(); const listSnapshots = vi.fn().mockResolvedValue([{ prId: "pr-80", @@ -779,15 +796,59 @@ describe("PrDetailPane issue resolver CTA", () => { expect(screen.getByText("Cached snapshot check")).toBeTruthy(); }); - await user.click(screen.getByRole("button", { name: /activity/i })); + await user.click(screen.getByRole("button", { name: /overview/i })); await waitFor(() => { expect(screen.getByText("Cached comment body")).toBeTruthy(); expect(screen.getByText("Cached review body")).toBeTruthy(); }); }); + it("does not start live detail work from a stale snapshot prefill request", async () => { + let resolveSnapshots!: (snapshots: PrSnapshotHydration[]) => void; + const listSnapshots = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveSnapshots = resolve; + })); + const getDetail = vi.fn().mockResolvedValue({ + prId: "pr-80", + body: "Live detail should not load", + labels: [], + assignees: [], + requestedReviewers: [], + author: { login: "octocat", avatarUrl: null }, + isDraft: false, + milestone: null, + linkedIssues: [], + }); + const getFiles = vi.fn().mockResolvedValue([]); + const getCommits = vi.fn().mockResolvedValue([]); + const getActionRuns = vi.fn().mockResolvedValue([]); + const { unmount } = renderPane({ + checks: [], + reviewThreads: [], + listSnapshots, + getDetail, + getFiles, + getCommits, + getActionRuns, + }); + + await waitFor(() => { + expect(listSnapshots).toHaveBeenCalledWith({ prId: "pr-80" }); + }); + + unmount(); + await act(async () => { + resolveSnapshots([]); + await Promise.resolve(); + }); + + expect(getDetail).not.toHaveBeenCalled(); + expect(getFiles).not.toHaveBeenCalled(); + expect(getCommits).not.toHaveBeenCalled(); + expect(getActionRuns).not.toHaveBeenCalled(); + }); + it("updates synthesized activity when selected PR detail inputs refresh", async () => { - const user = userEvent.setup(); const { rerenderPane } = renderPane({ checks: [makeCheck({ name: "Old CI", conclusion: "success" })], reviewThreads: [], @@ -797,7 +858,6 @@ describe("PrDetailPane issue resolver CTA", () => { }, }); - await user.click(screen.getByRole("button", { name: /activity/i })); await waitFor(() => { expect(screen.getByText("Old CI: success")).toBeTruthy(); }); @@ -816,10 +876,41 @@ describe("PrDetailPane issue resolver CTA", () => { }); }); + it("refetches activity after posting a PR comment", async () => { + const user = userEvent.setup(); + const addComment = vi.fn().mockResolvedValue(undefined); + const getActivity = vi.fn().mockResolvedValue([ + { + id: "comment-new", + type: "comment", + author: "octocat", + avatarUrl: null, + body: "Freshly posted comment", + timestamp: "2026-03-23T12:05:00.000Z", + metadata: {}, + } satisfies PrActivityEvent, + ]); + renderPane({ + checks: [], + comments: [makeComment({ id: "comment-old", body: "Old cached comment" })], + reviewThreads: [], + getActivity, + addComment, + }); + + await user.type(screen.getByPlaceholderText(/leave a comment/i), "Freshly posted comment"); + await user.click(screen.getByRole("button", { name: /^comment$/i })); + + await waitFor(() => { + expect(addComment).toHaveBeenCalledWith({ prId: "pr-80", body: "Freshly posted comment" }); + expect(getActivity).toHaveBeenCalled(); + expect(screen.getByText("Freshly posted comment")).toBeTruthy(); + }); + }); + it("synthesizes unique activity keys for duplicate review and check identities", async () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); try { - const user = userEvent.setup(); renderPane({ checks: [ makeCheck({ name: "CodeRabbit", detailsUrl: null, startedAt: null, completedAt: null }), @@ -832,8 +923,6 @@ describe("PrDetailPane issue resolver CTA", () => { reviewThreads: [], }); - await user.click(screen.getByRole("button", { name: /activity/i })); - await waitFor(() => { expect(screen.getAllByText("CodeRabbit: failure")).toHaveLength(2); expect(screen.getByText("First review")).toBeTruthy(); @@ -920,6 +1009,76 @@ describe("PrDetailPane issue resolver CTA", () => { expect(screen.queryByText("stale-label")).toBeNull(); }); + it("keeps live rich detail while a force-live refresh is pending", async () => { + const freshDetail = { + prId: "pr-80", + body: "Fresh live body", + labels: [{ name: "fresh-label", color: "22c55e", description: null }], + assignees: [], + requestedReviewers: [], + author: { login: "octocat", avatarUrl: null }, + isDraft: false, + milestone: null, + linkedIssues: [], + }; + const staleSnapshot: PrSnapshotHydration = { + prId: "pr-80", + detail: { + ...freshDetail, + body: "Stale cached body", + labels: [{ name: "stale-label", color: "ef4444", description: null }], + }, + status: makeStatus({ checksStatus: "passing", reviewStatus: "approved" }), + checks: [], + reviews: [], + comments: [], + files: [], + commits: [], + updatedAt: "2026-03-23T12:01:00.000Z", + }; + let resolveSecondDetail!: (value: typeof freshDetail) => void; + const secondDetail = new Promise((resolve) => { + resolveSecondDetail = resolve; + }); + const getDetail = vi.fn() + .mockResolvedValueOnce(freshDetail) + .mockReturnValueOnce(secondDetail); + const { rerenderPane } = renderPane({ + checks: [], + reviewThreads: [], + getDetail, + snapshotHydration: null, + snapshotHydrationOwnedByContext: true, + prOverrides: { + checksStatus: "none", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + }); + + await waitFor(() => { + expect(screen.getByText("fresh-label")).toBeTruthy(); + }); + + const refreshedPr = { + checksStatus: "pending" as const, + updatedAt: "2026-03-23T12:01:00.000Z", + }; + rerenderPane(refreshedPr); + + await waitFor(() => { + expect(getDetail).toHaveBeenCalledTimes(2); + }); + rerenderPane(refreshedPr, { snapshotHydration: staleSnapshot }); + + expect(screen.getByText("fresh-label")).toBeTruthy(); + expect(screen.queryByText("stale-label")).toBeNull(); + + await act(async () => { + resolveSecondDetail(freshDetail); + await secondDetail; + }); + }); + it("prefers authoritative empty live detail over cached snapshot data", async () => { const user = userEvent.setup(); const listSnapshots = vi.fn().mockResolvedValue([{ @@ -948,7 +1107,7 @@ describe("PrDetailPane issue resolver CTA", () => { await user.click(screen.getByRole("button", { name: /ci \/ checks/i })); expect(screen.queryByText("Cached snapshot check")).toBeNull(); - await user.click(screen.getByRole("button", { name: /activity/i })); + await user.click(screen.getByRole("button", { name: /overview/i })); expect(screen.queryByText("Cached comment body")).toBeNull(); expect(screen.queryByText("Cached review body")).toBeNull(); }); @@ -1243,6 +1402,72 @@ describe("PrDetailPane issue resolver CTA", () => { }); }); + it("keeps ADE PR actions available in the default timeline overview", async () => { + const user = userEvent.setup(); + const { land, onRefresh } = renderPane({ + checks: [makeCheck({ conclusion: "success" })], + reviewThreads: [], + prsTimelineRailsEnabled: true, + statusOverrides: { + checksStatus: "passing", + reviewStatus: "approved", + isMergeable: true, + mergeConflicts: false, + }, + prOverrides: { + checksStatus: "passing", + reviewStatus: "approved", + }, + }); + + expect(await screen.findByTestId("pr-detail-timeline-rails")).toBeTruthy(); + const actionSlot = screen.getByTestId("pr-detail-action-rail-slot"); + expect(actionSlot.style.overflowY).toBe("auto"); + expect(actionSlot.style.maxHeight).toBe("min(40%, 340px)"); + expect(screen.getByRole("button", { name: /ai review/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /submit review/i })).toBeTruthy(); + + const mergeButton = screen.getByRole("button", { name: /merge pull request/i }); + expect((mergeButton as HTMLButtonElement).disabled).toBe(false); + await user.click(mergeButton); + + await waitFor(() => { + expect(land).toHaveBeenCalledWith({ prId: "pr-80", method: "squash" }); + expect(onRefresh).toHaveBeenCalled(); + }); + }); + + it("enables resolved and outdated review thread filters for legacy activity deep links", async () => { + window.history.replaceState(null, "", "/prs?tab=normal&prId=pr-80&detailTab=activity&threadId=thread-1"); + + renderPane({ + checks: [makeCheck({ conclusion: "success" })], + reviewThreads: [ + makeThread({ + isResolved: true, + isOutdated: true, + comments: [ + { + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Resolved thread body", + url: null, + createdAt: null, + updatedAt: null, + }, + ], + }), + ], + prsTimelineRailsEnabled: true, + }); + + expect(await screen.findByTestId("pr-detail-timeline-rails")).toBeTruthy(); + expect(document.querySelector('[data-filter-key="all"]')?.getAttribute("aria-pressed")).toBe("true"); + expect(document.querySelector('[data-filter-key="unresolved"]')?.getAttribute("aria-pressed")).toBe("false"); + expect(document.querySelector('[data-filter-key="outdated"]')?.getAttribute("aria-pressed")).toBe("true"); + }); + it("launches the issue resolver chat and navigates to the work session", async () => { const user = userEvent.setup(); const onNavigate = vi.fn(); @@ -1782,20 +2007,22 @@ describe("PrDetailPane issue resolver CTA", () => { }); }); - it("loads review threads when opening the resolver", async () => { + it("loads review threads before and when opening the resolver", async () => { const user = userEvent.setup(); const { getReviewThreads } = renderPane({ checks: [makeCheck()], reviewThreads: [], }); - expect(getReviewThreads).not.toHaveBeenCalled(); + await waitFor(() => { + expect(getReviewThreads).toHaveBeenCalledTimes(1); + }); await user.click(screen.getByRole("button", { name: /ci \/ checks/i })); await user.click(await screen.findByRole("button", { name: /resolve issues with agent/i })); await waitFor(() => { - expect(getReviewThreads).toHaveBeenCalledTimes(1); + expect(getReviewThreads).toHaveBeenCalledTimes(2); }); }); @@ -1821,7 +2048,6 @@ describe("PrDetailPane issue resolver CTA", () => { }); it("renders review activity bodies as markdown instead of raw source text", async () => { - const user = userEvent.setup(); renderPane({ checks: [makeCheck()], reviewThreads: [], @@ -1838,7 +2064,6 @@ describe("PrDetailPane issue resolver CTA", () => { ], }); - await user.click(screen.getByRole("button", { name: /activity/i })); expect(await screen.findByText("Actionable comments posted: 3")).toBeTruthy(); expect(screen.queryByText(/\*\*Actionable comments posted: 3\*\*/)).toBeNull(); expect(screen.getByText("Prompt for AI Agents")).toBeTruthy(); diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index ac49a9196..efc3c0b52 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -5,7 +5,7 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize from "rehype-sanitize"; import { GitBranch, GitMerge, GitCommit, GithubLogo, CheckCircle, XCircle, Circle, - CircleNotch, Sparkle, ArrowRight, Eye, ChatText, Code, ClockCounterClockwise, + CircleNotch, Sparkle, ArrowRight, Eye, ChatText, Code, PencilSimple, X, Check, ArrowsClockwise, Warning, Play, Rocket, Tag, CaretDown, CaretRight, UserCircle, DotsThreeVertical, Robot, Stack as Layers, } from "@phosphor-icons/react"; @@ -19,6 +19,8 @@ import type { PrConvergenceState, PrConvergenceStatePatch, PrSnapshotHydration, + PrChecksStatus, + PrReviewStatus, } from "../../../../shared/types"; import { DEFAULT_PR_TIMELINE_FILTERS, type PrTimelineFilters } from "../shared/PrTimeline"; import type { PaletteKind } from "../shared/PrCommandPalettes"; @@ -42,9 +44,14 @@ import { modifierKeyLabel } from "../../../lib/platform"; // ---- Sub-tab type ---- type DetailTab = PrDetailRouteTab; const DETAIL_TAB_STORAGE_KEY = "ade:prs:detailTabs:v1"; +const DETAIL_BACKGROUND_ACTIVITY_DELAY_MS = 250; function isDetailTab(value: unknown): value is DetailTab { - return value === "overview" || value === "convergence" || value === "files" || value === "checks" || value === "activity"; + return value === "overview" || value === "convergence" || value === "files" || value === "checks"; +} + +function normalizeDetailTab(tab: DetailTab | "activity" | null | undefined): DetailTab { + return tab === "activity" || tab == null ? "overview" : tab; } function isPrsRouteRuntime(): boolean { @@ -62,7 +69,7 @@ function readStoredDetailTab(prId: string): DetailTab | null { if (!raw) return null; const parsed = JSON.parse(raw) as Record; const value = parsed?.[prId]; - return isDetailTab(value) ? value : null; + return isDetailTab(value) ? normalizeDetailTab(value) : null; } catch { return null; } @@ -276,7 +283,7 @@ function CheckIcon({ check }: { check: PrCheck }) { return ; } -// ---- Shared activity event helpers (used by OverviewTab and ActivityTab) ---- +// ---- Shared activity event helpers for the overview thread ---- function activityEventColor(ev: PrActivityEvent): string { if (ev.type === "comment") return ev.metadata?.source === "review" ? COLORS.warning : COLORS.info; if (ev.type === "review") return COLORS.accent; @@ -427,7 +434,9 @@ type ChecksSummary = { checksRunning: boolean; }; -function summarizeChecks(checks: PrCheck[]): ChecksSummary { +type CheckSummaryItem = Pick; + +function summarizeChecks(checks: CheckSummaryItem[]): ChecksSummary { const passing = checks.filter((check) => check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped").length; const failing = checks.filter((check) => check.conclusion === "failure" || check.conclusion === "cancelled").length; const pending = checks.filter((check) => check.status !== "completed" && !check.conclusion).length; @@ -509,6 +518,8 @@ type PrDetailPaneProps = { onOpenQueueView?: (groupId: string) => void; initialDetailTab?: DetailTab | null; onDetailTabChange?: (tab: DetailTab) => void; + onUnmap?: () => void; + unmapBusy?: boolean; }; export function PrDetailPane({ @@ -531,6 +542,8 @@ export function PrDetailPane({ onOpenQueueView, initialDetailTab, onDetailTabChange, + onUnmap, + unmapBusy = false, }: PrDetailPaneProps) { const { convergenceStatesByPrId, @@ -550,21 +563,23 @@ export function PrDetailPane({ detailAiSummary, detailReviewThreads: ctxReviewThreads, detailDeployments, + detailLiveDataPrId: ctxDetailPrId, viewerLogin, setTimelineFilters, setAiSummaryDismissed, regeneratePrAiSummary, } = usePrs(); + const initialSnapshotHydration = snapshotHydration?.prId === pr.id ? snapshotHydration : null; const [activeTab, setActiveTabState] = React.useState( - () => initialDetailTab ?? readStoredDetailTab(pr.id) ?? "overview", + () => normalizeDetailTab(initialDetailTab ?? readStoredDetailTab(pr.id)), ); - const [detail, setDetail] = React.useState(null); - const [files, setFiles] = React.useState([]); - const [commits, setCommits] = React.useState([]); - const [snapshotStatus, setSnapshotStatus] = React.useState(null); - const [snapshotChecks, setSnapshotChecks] = React.useState([]); - const [snapshotReviews, setSnapshotReviews] = React.useState([]); - const [snapshotComments, setSnapshotComments] = React.useState([]); + const [detail, setDetail] = React.useState(() => initialSnapshotHydration?.detail ?? null); + const [files, setFiles] = React.useState(() => initialSnapshotHydration?.files ?? []); + const [commits, setCommits] = React.useState(() => initialSnapshotHydration?.commits ?? []); + const [snapshotStatus, setSnapshotStatus] = React.useState(() => initialSnapshotHydration?.status ?? null); + const [snapshotChecks, setSnapshotChecks] = React.useState(() => initialSnapshotHydration?.checks ?? []); + const [snapshotReviews, setSnapshotReviews] = React.useState(() => initialSnapshotHydration?.reviews ?? []); + const [snapshotComments, setSnapshotComments] = React.useState(() => initialSnapshotHydration?.comments ?? []); const [actionRuns, setActionRuns] = React.useState([]); const [activity, setActivity] = React.useState([]); const [reviewThreads, setReviewThreads] = React.useState([]); @@ -586,22 +601,18 @@ export function PrDetailPane({ }, [onDetailTabChange, pr.id]); React.useEffect(() => { - const next = initialDetailTab ?? readStoredDetailTab(pr.id) ?? "overview"; + const next = normalizeDetailTab(initialDetailTab ?? readStoredDetailTab(pr.id)); setActiveTabState(next); - setSnapshotStatus(null); - setSnapshotChecks([]); - setSnapshotReviews([]); - setSnapshotComments([]); if (initialDetailTab) { - writeStoredDetailTab(pr.id, initialDetailTab); + writeStoredDetailTab(pr.id, normalizeDetailTab(initialDetailTab)); } }, [initialDetailTab, pr.id]); React.useEffect(() => { const onTourTab = (event: Event) => { - const tab = (event as CustomEvent).detail; + const tab = (event as CustomEvent).detail; if (tab === "overview" || tab === "convergence" || tab === "files" || tab === "checks" || tab === "activity") { - setActiveTab(tab); + setActiveTab(normalizeDetailTab(tab)); } }; window.addEventListener("ade:tour-pr-detail-tab", onTourTab); @@ -611,26 +622,42 @@ export function PrDetailPane({ const deepLinkState = React.useMemo(() => { try { const parsed = parsePrsRouteState({ search: window.location.search, hash: window.location.hash }); - return { eventId: parsed.eventId, threadId: parsed.threadId, commitSha: parsed.commitSha }; + const searchParams = new URLSearchParams(window.location.search.startsWith("?") ? window.location.search.slice(1) : window.location.search); + const hashQuery = window.location.hash.includes("?") ? window.location.hash.slice(window.location.hash.indexOf("?") + 1) : ""; + const hashParams = new URLSearchParams(hashQuery); + const legacyActivityTab = searchParams.get("detailTab") === "activity" || hashParams.get("detailTab") === "activity"; + return { eventId: parsed.eventId, threadId: parsed.threadId, commitSha: parsed.commitSha, legacyActivityTab }; } catch { - return { eventId: null, threadId: null, commitSha: null }; + return { eventId: null, threadId: null, commitSha: null, legacyActivityTab: false }; } }, [pr.id]); // eslint-disable-line react-hooks/exhaustive-deps + const timelineDeepLinkNeedsAllThreads = Boolean(deepLinkState.eventId || deepLinkState.threadId || deepLinkState.legacyActivityTab); const timelineFilters: PrTimelineFilters = React.useMemo( - () => timelineFiltersByPrId?.[pr.id] ?? DEFAULT_PR_TIMELINE_FILTERS, - [timelineFiltersByPrId, pr.id], + () => { + const filters = timelineFiltersByPrId?.[pr.id] ?? DEFAULT_PR_TIMELINE_FILTERS; + return timelineDeepLinkNeedsAllThreads + ? { ...filters, showResolved: true, showOutdated: true } + : filters; + }, + [timelineFiltersByPrId, pr.id, timelineDeepLinkNeedsAllThreads], ); const handleTimelineFiltersChange = React.useCallback( (next: PrTimelineFilters) => setTimelineFilters?.(pr.id, next), [pr.id, setTimelineFilters], ); const reviewThreadsForTimeline = React.useMemo( - () => ((ctxReviewThreads?.length ?? 0) > 0 ? ctxReviewThreads! : reviewThreads), - [ctxReviewThreads, reviewThreads], + () => (ctxDetailPrId === pr.id && (ctxReviewThreads?.length ?? 0) > 0 ? ctxReviewThreads! : reviewThreads), + [ctxDetailPrId, ctxReviewThreads, pr.id, reviewThreads], ); React.useEffect(() => { - if (ctxReviewThreads) setReviewThreads(ctxReviewThreads); - }, [ctxReviewThreads, pr.id]); + if (ctxDetailPrId === pr.id && (ctxReviewThreads?.length ?? 0) > 0) { + setReviewThreads(ctxReviewThreads); + } + }, [ctxDetailPrId, ctxReviewThreads, pr.id]); + const deploymentsForTimeline = React.useMemo( + () => (ctxDetailPrId === pr.id ? detailDeployments : []), + [ctxDetailPrId, detailDeployments, pr.id], + ); const aiSummaryDismissedForPr = Boolean(dismissedAiSummaries?.[pr.id]); const handleDismissAiSummary = React.useCallback(() => { setAiSummaryDismissed?.(pr.id, true); @@ -638,6 +665,10 @@ export function PrDetailPane({ const handleRegenerateAiSummary = React.useCallback(() => { void regeneratePrAiSummary?.(pr.id); }, [pr.id, regeneratePrAiSummary]); + const timelineAiSummary = React.useMemo( + () => (detailAiSummary?.prId === pr.id ? detailAiSummary : null), + [detailAiSummary, pr.id], + ); // Page-level keyboard shortcuts scoped to the Timeline+Rails overview. // Only attach listeners when the flag is on AND the overview tab is active. @@ -842,6 +873,9 @@ export function PrDetailPane({ const detailStatusRefreshKeyRef = React.useRef(null); const inventoryLoadSeqRef = React.useRef(0); const snapshotHydrationRef = React.useRef(snapshotHydration); + const snapshotPrefillPendingRef = React.useRef(false); + const visibleActivityCountRef = React.useRef(0); + const activityFetchKeyRef = React.useRef(null); const liveDetailLoadedForPrRef = React.useRef(null); const liveFilesLoadedForPrRef = React.useRef(null); const liveCommitsLoadedForPrRef = React.useRef(null); @@ -869,15 +903,16 @@ export function PrDetailPane({ } }, [applySnapshotHydration, pr.id, snapshotHydration]); - const loadDetail = React.useCallback(async (options: { hydrateSnapshot?: boolean } = {}) => { + const loadDetail = React.useCallback(async (options: { hydrateSnapshot?: boolean; forceLive?: boolean } = {}) => { const requestId = ++detailLoadSeqRef.current; try { - if (options.hydrateSnapshot) { + if (options.hydrateSnapshot && !options.forceLive) { const contextSnapshot = snapshotHydrationRef.current?.prId === pr.id ? snapshotHydrationRef.current : null; - const cachedSnapshot = contextSnapshot ?? (!snapshotHydrationOwnedByContext && typeof window.ade.prs.listSnapshots === "function" + const cachedSnapshot = contextSnapshot ?? (typeof window.ade.prs.listSnapshots === "function" ? (await window.ade.prs.listSnapshots({ prId: pr.id }).catch(() => []))[0] : null); - if (cachedSnapshot && requestId === detailLoadSeqRef.current) { + if (requestId !== detailLoadSeqRef.current) return; + if (cachedSnapshot) { applySnapshotHydration(cachedSnapshot); } } @@ -911,8 +946,12 @@ export function PrDetailPane({ await Promise.allSettled([detailPromise, filesPromise, commitsPromise, actionRunsPromise]); } catch { // silently fail - basic data still available from context + } finally { + if (requestId === detailLoadSeqRef.current) { + snapshotPrefillPendingRef.current = false; + } } - }, [applySnapshotHydration, pr.id, snapshotHydrationOwnedByContext]); + }, [applySnapshotHydration, pr.id]); const refreshReviewThreads = React.useCallback(async () => { const requestId = detailLoadSeqRef.current; @@ -954,17 +993,24 @@ export function PrDetailPane({ setShowReviewerEditor(false); setShowReviewModal(false); setActivity([]); + activityFetchKeyRef.current = null; liveDetailLoadedForPrRef.current = null; liveFilesLoadedForPrRef.current = null; liveCommitsLoadedForPrRef.current = null; - setDetail(null); - setFiles([]); - setCommits([]); + const contextSnapshot = snapshotHydrationRef.current?.prId === pr.id ? snapshotHydrationRef.current : null; + if (contextSnapshot) { + applySnapshotHydration(contextSnapshot); + } else { + setDetail(null); + setFiles([]); + setCommits([]); + setSnapshotStatus(null); + setSnapshotChecks([]); + setSnapshotReviews([]); + setSnapshotComments([]); + } setActionRuns([]); - setSnapshotStatus(null); - setSnapshotChecks([]); - setSnapshotReviews([]); - setSnapshotComments([]); + setReviewThreads([]); const requestId = ++convergenceLoadSeqRef.current; const cachedRuntime = cachedConvergenceRuntimeRef.current; @@ -981,13 +1027,15 @@ export function PrDetailPane({ } }); + snapshotPrefillPendingRef.current = true; void loadDetail({ hydrateSnapshot: true }); + void refreshReviewThreads(); return () => { detailLoadSeqRef.current += 1; inventoryLoadSeqRef.current += 1; convergenceLoadSeqRef.current += 1; }; - }, [applyConvergenceRuntime, loadConvergenceState, loadDetail, pr.id]); + }, [applyConvergenceRuntime, applySnapshotHydration, loadConvergenceState, loadDetail, pr.id, refreshReviewThreads]); React.useEffect(() => { const key = [ @@ -1003,7 +1051,7 @@ export function PrDetailPane({ } if (prev === key) return; detailStatusRefreshKeyRef.current = key; - void loadDetail(); + void loadDetail({ forceLive: true }); void refreshReviewThreads(); }, [loadDetail, pr.checksStatus, pr.id, pr.reviewStatus, pr.updatedAt, refreshReviewThreads]); @@ -1014,25 +1062,39 @@ export function PrDetailPane({ const visibleActivity = activity.length > 0 ? activity : derivedActivity; React.useEffect(() => { - const shouldLoadImmediately = activeTab === "activity" || Boolean(deepLinkState.eventId); + visibleActivityCountRef.current = visibleActivity.length; + }, [visibleActivity.length]); + + React.useEffect(() => { + const shouldLoadImmediately = activeTab === "overview" || Boolean(deepLinkState.eventId); if (!shouldLoadImmediately) return undefined; + const key = `${pr.id}|${activeTab}|${deepLinkState.eventId ?? ""}`; + if (activityFetchKeyRef.current === key) return undefined; + activityFetchKeyRef.current = key; let cancelled = false; - window.ade.prs.getActivity(pr.id).then((events) => { - if (!cancelled) setActivity(events); - }).catch(() => {}); + const hasLocalActivity = visibleActivityCountRef.current > 0; + const delay = !deepLinkState.eventId && (hasLocalActivity || snapshotPrefillPendingRef.current) + ? DETAIL_BACKGROUND_ACTIVITY_DELAY_MS + : 0; + const timeoutId = window.setTimeout(() => { + window.ade.prs.getActivity(pr.id).then((events) => { + if (!cancelled) setActivity(events); + }).catch(() => {}); + }, delay); return () => { cancelled = true; + window.clearTimeout(timeoutId); }; }, [activeTab, deepLinkState.eventId, pr.id]); // Poll checks + actionRuns + reviewThreads every 60s so the // Path to Merge readiness panel stays fresh without requiring a manual refresh. // Full activity fetches include comments/reviews/checks again, so only do that - // while the Activity tab is actually visible. + // while the Overview thread is actually visible. React.useEffect(() => { let cancelled = false; const id = window.setInterval(() => { - const activityPromise = activeTab === "activity" + const activityPromise = activeTab === "overview" ? window.ade.prs.getActivity(pr.id) : Promise.resolve(null); Promise.allSettled([ @@ -1090,8 +1152,16 @@ export function PrDetailPane({ return runAction(async () => { await window.ade.prs.addComment({ prId: pr.id, body: commentDraft }); setCommentDraft(""); - await onRefresh(); - await loadDetail(); + activityFetchKeyRef.current = null; + setActivity([]); + const activityPromise = window.ade.prs.getActivity(pr.id) + .then((events) => setActivity(events)) + .catch(() => undefined); + await Promise.all([ + onRefresh(), + loadDetail({ forceLive: true }), + activityPromise, + ]); }); }; @@ -1108,20 +1178,20 @@ export function PrDetailPane({ await window.ade.prs.updateBody({ prId: pr.id, body: bodyDraft }); setEditingBody(false); await onRefresh(); - await loadDetail(); + await loadDetail({ forceLive: true }); }); const handleSetLabels = (labels: string[]) => runAction(async () => { await window.ade.prs.setLabels({ prId: pr.id, labels }); setShowLabelEditor(false); - await loadDetail(); + await loadDetail({ forceLive: true }); }); const handleRequestReviewers = (reviewers: string[]) => runAction(async () => { await window.ade.prs.requestReviewers({ prId: pr.id, reviewers }); setShowReviewerEditor(false); await onRefresh(); - await loadDetail(); + await loadDetail({ forceLive: true }); }); const handleSubmitReview = () => runAction(async () => { @@ -1144,7 +1214,7 @@ export function PrDetailPane({ const handleRerunChecks = () => runAction(async () => { await window.ade.prs.rerunChecks({ prId: pr.id }); await onRefresh(); - await loadDetail(); + await loadDetail({ forceLive: true }); }); const handleAiSummary = async () => { @@ -1288,7 +1358,7 @@ export function PrDetailPane({ }, [checks, pr.id, pr.state]); const refreshDetailSurface = React.useCallback(async (options: { includeInventory?: boolean } = {}) => { - const tasks: Array> = [onRefresh({ prId: pr.id }), loadDetail(), refreshReviewThreads()]; + const tasks: Array> = [onRefresh({ prId: pr.id }), loadDetail({ forceLive: true }), refreshReviewThreads()]; if (options.includeInventory && pr.state !== "merged" && pr.state !== "closed") { tasks.push(syncInventory()); } @@ -2255,14 +2325,27 @@ export function PrDetailPane({ const localBehindCount = laneForPr?.status?.behind ?? 0; const sc = getPrStateBadge(pr.state); - const cc = getPrChecksBadge(pr.checksStatus); - const rc = getPrReviewsBadge(pr.reviewStatus); + const resolvedChecksStatus = React.useMemo(() => { + if (status?.checksStatus) return status.checksStatus; + if (checks.length === 0) return pr.checksStatus; + if (checks.some((check) => check.status === "queued" || check.status === "in_progress")) return "pending"; + if (checks.some((check) => check.conclusion === "failure" || check.conclusion === "cancelled")) return "failing"; + if (checks.some((check) => check.status === "completed")) return "passing"; + return pr.checksStatus; + }, [checks, pr.checksStatus, status?.checksStatus]); + const resolvedReviewStatus = React.useMemo(() => { + if (status?.reviewStatus) return status.reviewStatus; + if (reviews.some((review) => review.state === "changes_requested")) return "changes_requested"; + if (reviews.some((review) => review.state === "approved")) return "approved"; + return pr.reviewStatus; + }, [pr.reviewStatus, reviews, status?.reviewStatus]); + const cc = getPrChecksBadge(resolvedChecksStatus); + const rc = getPrReviewsBadge(resolvedReviewStatus); const TAB_ACTIVE_COLORS: Record = { overview: COLORS.accent, convergence: COLORS.accent, files: COLORS.info, checks: COLORS.success, - activity: COLORS.warning, }; const isTerminalPr = pr.state === "merged" || pr.state === "closed"; @@ -2289,11 +2372,12 @@ export function PrDetailPane({ { id: "convergence", label: "Path to Merge", icon: Sparkle, count: newIssueCount > 0 ? newIssueCount : undefined }, { id: "files", label: "Files", icon: Code, count: files.length }, { id: "checks", label: "CI / Checks", icon: Play, count: buildUnifiedChecks(checks, actionRuns).length }, - { id: "activity", label: "Activity", icon: ClockCounterClockwise, count: visibleActivity.length }, ]; + const overviewRailsActive = activeTab === "overview" && prsTimelineRailsEnabled; + return ( -
+
{/* ===== HEADER ===== */}
{/* Title row */} @@ -2401,6 +2485,22 @@ export function PrDetailPane({ + {onUnmap ? ( + + ) : null} {queueContext && onOpenQueueView ? (
void; + labelInput: string; + setLabelInput: (value: string) => void; + showReviewerEditor: boolean; + setShowReviewerEditor: (value: boolean) => void; + reviewerInput: string; + setReviewerInput: (value: string) => void; + showReviewModal: boolean; + setShowReviewModal: (value: boolean) => void; + reviewBody: string; + setReviewBody: (value: string) => void; + reviewEvent: PrReviewEvent; + setReviewEvent: (value: PrReviewEvent) => void; + onMerge: (method: MergeMethod) => void; + onAiSummary: () => void; + onSetLabels: (labels: string[]) => void; + onRequestReviewers: (reviewers: string[]) => void; + onSubmitReview: () => void; + onClose: () => void; + onReopen: () => void; +}; + +function PrOverviewActionPanel(props: PrOverviewActionPanelProps) { + const [localMergeMethod, setLocalMergeMethod] = React.useState(props.mergeMethod); + const [allowBlockedMerge, setAllowBlockedMerge] = React.useState(false); + const allChecks = React.useMemo(() => buildUnifiedChecks(props.checks, props.actionRuns), [props.checks, props.actionRuns]); + const checksSummary = summarizeChecks(allChecks); + const { someChecksFailing, checksRunning } = checksSummary; + const reviewStatus = props.pr.reviewStatus; + const canMerge = Boolean(props.status?.isMergeable) && !props.status?.mergeConflicts && props.pr.state === "open"; + const canAttemptBlockedMerge = Boolean(props.status) && !props.status?.isMergeable && !props.status?.mergeConflicts && props.pr.state === "open"; + const isBypassMerge = allowBlockedMerge && canAttemptBlockedMerge; + const mergeActionEnabled = canMerge || isBypassMerge; + const hasCheckWarnings = someChecksFailing || checksRunning; + const hasReviewWarnings = reviewStatus === "changes_requested" || reviewStatus === "requested"; + const hasMergeWarnings = canMerge && (hasCheckWarnings || hasReviewWarnings); + const mergeActionLabel = props.actionBusy + ? (isBypassMerge ? "Attempting merge..." : "Merging...") + : hasMergeWarnings + ? (someChecksFailing ? "Merge (checks failing)" : checksRunning ? "Merge (checks pending)" : "Merge (review pending)") + : (isBypassMerge ? "Attempt merge anyway" : "Merge pull request"); + const mergeAccentColor = canMerge + ? (hasMergeWarnings ? COLORS.warning : COLORS.success) + : isBypassMerge ? COLORS.warning : null; + const mergeActionBackground = mergeAccentColor + ? `linear-gradient(135deg, ${mergeAccentColor} 0%, ${canMerge && !hasMergeWarnings ? "#16a34a" : "#d97706"} 100%)` + : COLORS.recessedBg; + + React.useEffect(() => { + setLocalMergeMethod(props.mergeMethod); + }, [props.mergeMethod]); + + React.useEffect(() => { + if (!canAttemptBlockedMerge) setAllowBlockedMerge(false); + }, [canAttemptBlockedMerge]); + + const requestReviewers = () => { + const reviewers = props.reviewerInput.split(",").map((value) => value.trim()).filter(Boolean); + if (reviewers.length) props.onRequestReviewers(reviewers); + }; + const setLabels = () => { + const labels = props.labelInput.split(",").map((value) => value.trim()).filter(Boolean); + if (labels.length) props.onSetLabels(labels); + }; + + return ( +
+
+ PR actions + + + + {(props.pr.state === "open" || props.pr.state === "draft") ? ( + <> +
+ {(["squash", "merge", "rebase"] as const).map((method) => ( + + ))} +
+ {canAttemptBlockedMerge ? ( + + ) : null} + + {props.pr.state === "open" ? ( + + ) : null} + + ) : props.pr.state === "closed" ? ( + + ) : null} +
+ +
+ props.setShowReviewerEditor(!props.showReviewerEditor)}> + {props.detail?.requestedReviewers?.length ? ( + props.detail.requestedReviewers.map((reviewer) => ( +
+ + {reviewer.login} +
+ )) + ) : ( + None + )} + {props.showReviewerEditor ? ( +
+ props.setReviewerInput(event.target.value)} + placeholder="username1, username2" + onKeyDown={(event) => { + if (event.key === "Enter") requestReviewers(); + }} + style={{ width: "100%", height: 26, padding: "0 8px", fontFamily: MONO_FONT, fontSize: 11, color: COLORS.textPrimary, background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, outline: "none" }} + /> +
+ ) : null} +
+
+ +
+ props.setShowLabelEditor(!props.showLabelEditor)}> + {props.detail?.labels?.length ? ( +
+ {props.detail.labels.map((label) => ( + + + {label.name} + + ))} +
+ ) : ( + None + )} + {props.showLabelEditor ? ( +
+ props.setLabelInput(event.target.value)} + placeholder="bug, enhancement" + onKeyDown={(event) => { + if (event.key === "Enter") setLabels(); + }} + style={{ width: "100%", height: 26, padding: "0 8px", fontFamily: MONO_FONT, fontSize: 11, color: COLORS.textPrimary, background: COLORS.recessedBg, border: `1px solid ${COLORS.border}`, outline: "none" }} + /> +
+ ) : null} +
+
+ + props.setShowReviewModal(false)} + onSubmit={props.onSubmitReview} + /> +
+ ); +} + +function PrReviewSubmitModal({ + open, + actionBusy, + reviewBody, + setReviewBody, + reviewEvent, + setReviewEvent, + onCancel, + onSubmit, +}: { + open: boolean; + actionBusy: boolean; + reviewBody: string; + setReviewBody: (value: string) => void; + reviewEvent: PrReviewEvent; + setReviewEvent: (value: PrReviewEvent) => void; + onCancel: () => void; + onSubmit: () => void; +}) { + if (!open) return null; + return ( +
+
+
+ Submit Review + +
+
+ {(["APPROVE", "REQUEST_CHANGES", "COMMENT"] as const).map((event) => { + const isSelected = reviewEvent === event; + const eventColor = event === "APPROVE" ? COLORS.success : event === "REQUEST_CHANGES" ? COLORS.warning : COLORS.info; + return ( + + ); + })} +
+