From 56a7ce62d724bec9ac4bc66dc91572c70cd870c1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:04:23 -0400 Subject: [PATCH 1/3] fixing rebasing logic --- .ade/kv.sqlite | 0 apps/desktop/src/main/main.ts | 19 +- .../services/ai/tools/workflowTools.test.ts | 105 + .../main/services/ai/tools/workflowTools.ts | 186 +- .../conflicts/conflictService.test.ts | 147 + .../services/conflicts/conflictService.ts | 53 +- .../src/main/services/ipc/registerIpc.ts | 88 +- .../src/main/services/lanes/laneService.ts | 15 +- .../lanes/rebaseSuggestionService.test.ts | 434 +++ .../services/lanes/rebaseSuggestionService.ts | 144 +- .../missions/missionPreflightService.test.ts | 70 - .../missions/missionPreflightService.ts | 23 +- .../aiOrchestratorService.test.ts | 160 -- .../orchestrator/aiOrchestratorService.ts | 129 +- .../services/orchestrator/missionStateDoc.ts | 5 +- .../main/services/prs/prIssueResolver.test.ts | 366 +++ .../src/main/services/prs/prIssueResolver.ts | 455 ++++ .../prs/prService.reviewThreads.test.ts | 237 ++ .../src/main/services/prs/prService.ts | 488 +++- .../prs/queueRehearsalService.test.ts | 274 -- .../services/prs/queueRehearsalService.ts | 727 ----- .../src/main/services/shared/queueRebase.ts | 229 ++ apps/desktop/src/main/services/state/kvDb.ts | 31 - .../services/state/onConflictAudit.test.ts | 5 - apps/desktop/src/preload/global.d.ts | 81 +- apps/desktop/src/preload/preload.ts | 30 +- apps/desktop/src/renderer/browserMock.ts | 68 +- .../components/graph/WorkspaceGraphPage.tsx | 1 - .../components/lanes/LaneRebaseBanner.tsx | 2 +- .../renderer/components/lanes/LanesPage.tsx | 2 +- .../missions/CreateMissionDialog.tsx | 22 +- .../components/prs/CreatePrModal.test.tsx | 142 + .../renderer/components/prs/CreatePrModal.tsx | 497 ++-- .../src/renderer/components/prs/PRsPage.tsx | 68 +- .../PrDetailPane.issueResolver.test.tsx | 349 +++ .../components/prs/detail/PrDetailPane.tsx | 462 ++-- .../components/prs/prsRouteState.test.ts | 63 + .../renderer/components/prs/prsRouteState.ts | 72 + .../prs/shared/PrAiResolverPanel.test.tsx | 1 - .../prs/shared/PrIssueResolverModal.test.tsx | 131 + .../prs/shared/PrIssueResolverModal.tsx | 488 ++++ .../components/prs/state/PrsContext.tsx | 22 +- .../components/prs/tabs/GitHubTab.test.tsx | 140 +- .../components/prs/tabs/GitHubTab.tsx | 126 +- .../components/prs/tabs/IntegrationTab.tsx | 18 +- .../components/prs/tabs/NormalTab.tsx | 1 - .../renderer/components/prs/tabs/QueueTab.tsx | 2367 +++++++++-------- .../components/prs/tabs/WorkflowsTab.tsx | 134 +- .../prs/tabs/queueWorkflowModel.test.ts | 171 ++ .../components/prs/tabs/queueWorkflowModel.ts | 227 ++ apps/desktop/src/shared/ipc.ts | 10 +- apps/desktop/src/shared/prIssueResolution.ts | 38 + apps/desktop/src/shared/types/conflicts.ts | 5 +- apps/desktop/src/shared/types/lanes.ts | 2 + apps/desktop/src/shared/types/orchestrator.ts | 4 - apps/desktop/src/shared/types/prs.ts | 198 +- docs/architecture/AI_INTEGRATION.md | 2 +- docs/architecture/DESKTOP_APP.md | 2 +- docs/architecture/SYSTEM_OVERVIEW.md | 4 +- docs/features/AGENTS.md | 2 +- docs/features/CHAT.md | 6 + docs/features/CONFLICTS.md | 2 +- docs/features/PULL_REQUESTS.md | 57 +- 63 files changed, 6802 insertions(+), 3605 deletions(-) create mode 100644 .ade/kv.sqlite create mode 100644 apps/desktop/src/main/services/ai/tools/workflowTools.test.ts create mode 100644 apps/desktop/src/main/services/lanes/rebaseSuggestionService.test.ts create mode 100644 apps/desktop/src/main/services/prs/prIssueResolver.test.ts create mode 100644 apps/desktop/src/main/services/prs/prIssueResolver.ts create mode 100644 apps/desktop/src/main/services/prs/prService.reviewThreads.test.ts delete mode 100644 apps/desktop/src/main/services/prs/queueRehearsalService.test.ts delete mode 100644 apps/desktop/src/main/services/prs/queueRehearsalService.ts create mode 100644 apps/desktop/src/main/services/shared/queueRebase.ts create mode 100644 apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx create mode 100644 apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx create mode 100644 apps/desktop/src/renderer/components/prs/prsRouteState.test.ts create mode 100644 apps/desktop/src/renderer/components/prs/prsRouteState.ts create mode 100644 apps/desktop/src/renderer/components/prs/shared/PrIssueResolverModal.test.tsx create mode 100644 apps/desktop/src/renderer/components/prs/shared/PrIssueResolverModal.tsx create mode 100644 apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.test.ts create mode 100644 apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.ts create mode 100644 apps/desktop/src/shared/prIssueResolution.ts diff --git a/.ade/kv.sqlite b/.ade/kv.sqlite new file mode 100644 index 00000000..e69de29b diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 282789dc..5ae15251 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -33,7 +33,6 @@ import { createGithubService } from "./services/github/githubService"; import { createPrService } from "./services/prs/prService"; import { createPrPollingService } from "./services/prs/prPollingService"; import { createQueueLandingService } from "./services/prs/queueLandingService"; -import { createQueueRehearsalService } from "./services/prs/queueRehearsalService"; import { detectDefaultBaseRef, resolveRepoRoot, toProjectInfo, upsertProjectRow } from "./services/projects/projectService"; import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; @@ -728,6 +727,7 @@ app.whenReady().then(async () => { db, logger, projectId, + projectRoot, laneService, onEvent: (event) => emitProjectEvent(projectRoot, IPC.lanesRebaseSuggestionsEvent, event) }); @@ -796,6 +796,7 @@ app.whenReady().then(async () => { aiIntegrationService, projectConfigService, conflictService, + rebaseSuggestionService, openExternal: async (url) => { await shell.openExternal(url); } @@ -845,19 +846,6 @@ app.whenReady().then(async () => { } }); queueLandingService.init(); - const queueRehearsalService = createQueueRehearsalService({ - db, - logger, - projectId, - prService, - laneService, - conflictService, - emitEvent: (event) => emitProjectEvent(projectRoot, IPC.prsEvent, event), - onStateChanged: (state) => { - aiOrchestratorServiceRef?.onQueueRehearsalStateChanged?.(state); - } - }); - queueRehearsalService.init(); const fileService = createFileService({ laneService, @@ -1541,7 +1529,6 @@ app.whenReady().then(async () => { prService, conflictService, queueLandingService, - queueRehearsalService, projectRoot, missionBudgetService, humanWorkDigestService, @@ -2117,7 +2104,6 @@ app.whenReady().then(async () => { prPollingService, computerUseArtifactBrokerService, queueLandingService, - queueRehearsalService, jobEngine, automationService, automationPlannerService, @@ -2210,7 +2196,6 @@ app.whenReady().then(async () => { prService: null, prPollingService: null, queueLandingService: null, - queueRehearsalService: null, jobEngine: null, automationService: null, automationPlannerService: null, diff --git a/apps/desktop/src/main/services/ai/tools/workflowTools.test.ts b/apps/desktop/src/main/services/ai/tools/workflowTools.test.ts new file mode 100644 index 00000000..e1c46e2b --- /dev/null +++ b/apps/desktop/src/main/services/ai/tools/workflowTools.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import { createWorkflowTools } from "./workflowTools"; + +function makeTools(prServiceOverrides: Record = {}) { + const prService = { + getChecks: vi.fn(async () => []), + getActionRuns: vi.fn(async () => []), + getReviewThreads: vi.fn(async () => []), + getComments: vi.fn(async () => []), + rerunChecks: vi.fn(async () => undefined), + replyToReviewThread: vi.fn(async () => ({ id: "reply-1", author: "you", authorAvatarUrl: null, body: "Fixed.", url: null, createdAt: null, updatedAt: null })), + resolveReviewThread: vi.fn(async () => undefined), + ...prServiceOverrides, + } as any; + + const tools = createWorkflowTools({ + laneService: {} as any, + prService, + sessionId: "session-1", + laneId: "lane-1", + }); + + return { prService, tools }; +} + +describe("createWorkflowTools", () => { + it("refreshes PR issue inventory with actionable review threads and failing checks", async () => { + const { tools } = makeTools({ + getChecks: vi.fn(async () => [ + { name: "ci / unit", status: "completed", conclusion: "failure", detailsUrl: "https://example.com/check", startedAt: null, completedAt: null }, + ]), + getActionRuns: vi.fn(async () => [ + { + id: 17, + name: "CI", + status: "completed", + conclusion: "failure", + headSha: "abc123", + htmlUrl: "https://example.com/run/17", + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + jobs: [ + { + id: 28, + name: "test", + status: "completed", + conclusion: "failure", + startedAt: null, + completedAt: null, + steps: [ + { name: "vitest", status: "completed", conclusion: "failure", number: 1, startedAt: null, completedAt: null }, + ], + }, + ], + }, + ]), + getReviewThreads: vi.fn(async () => [ + { + id: "thread-1", + isResolved: false, + isOutdated: false, + path: "src/prs.ts", + line: 18, + originalLine: 18, + startLine: null, + originalStartLine: null, + diffSide: "RIGHT", + url: "https://example.com/thread/1", + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + comments: [ + { id: "comment-1", author: "reviewer", authorAvatarUrl: null, body: "Please tighten this logic.", url: null, createdAt: null, updatedAt: null }, + ], + }, + ]), + getComments: vi.fn(async () => [ + { id: "issue-1", author: "bot", authorAvatarUrl: null, body: "Heads up", source: "issue", url: null, path: null, line: null, createdAt: null, updatedAt: null }, + ]), + }); + + const result = await (tools.prRefreshIssueInventory as any).execute({ prId: "pr-80" }); + + expect(result.success).toBe(true); + expect(result.summary).toMatchObject({ + hasActionableChecks: true, + hasActionableComments: true, + failingCheckCount: 1, + actionableReviewThreadCount: 1, + }); + expect(result.reviewThreads).toHaveLength(1); + expect(result.failingWorkflowRuns[0]).toMatchObject({ name: "CI" }); + }); + + it("routes review-thread reply, resolve, and rerun actions through prService", async () => { + const { prService, tools } = makeTools(); + + await (tools.prRerunFailedChecks as any).execute({ prId: "pr-80" }); + await (tools.prReplyToReviewThread as any).execute({ prId: "pr-80", threadId: "thread-1", body: "Fixed." }); + await (tools.prResolveReviewThread as any).execute({ prId: "pr-80", threadId: "thread-1" }); + + expect(prService.rerunChecks).toHaveBeenCalledWith({ prId: "pr-80" }); + expect(prService.replyToReviewThread).toHaveBeenCalledWith({ prId: "pr-80", threadId: "thread-1", body: "Fixed." }); + expect(prService.resolveReviewThread).toHaveBeenCalledWith({ prId: "pr-80", threadId: "thread-1" }); + }); +}); diff --git a/apps/desktop/src/main/services/ai/tools/workflowTools.ts b/apps/desktop/src/main/services/ai/tools/workflowTools.ts index 2bfb45c2..34306062 100644 --- a/apps/desktop/src/main/services/ai/tools/workflowTools.ts +++ b/apps/desktop/src/main/services/ai/tools/workflowTools.ts @@ -13,6 +13,7 @@ import type { createLaneService } from "../../lanes/laneService"; import type { createPrService } from "../../prs/prService"; import type { ComputerUseArtifactBrokerService } from "../../computerUse/computerUseArtifactBrokerService"; import { nowIso } from "../../shared/utils"; +import { getPrIssueResolutionAvailability } from "../../../../shared/prIssueResolution"; import { createDefaultComputerUsePolicy, isComputerUseModeEnabled, @@ -22,6 +23,10 @@ import { const execFileAsync = promisify(execFile); +function formatToolError(prefix: string, err: unknown): { success: false; error: string } { + return { success: false, error: `${prefix}: ${err instanceof Error ? err.message : String(err)}` }; +} + export interface WorkflowToolDeps { laneService: ReturnType; prService?: ReturnType | null; @@ -87,10 +92,7 @@ export function createWorkflowTools( worktreePath: lane.worktreePath, }; } catch (err) { - return { - success: false, - error: `Failed to create lane: ${err instanceof Error ? err.message : String(err)}`, - }; + return formatToolError("Failed to create lane", err); } }, }); @@ -140,10 +142,7 @@ export function createWorkflowTools( state: pr.state, }; } catch (err) { - return { - success: false, - error: `Failed to create PR: ${err instanceof Error ? err.message : String(err)}`, - }; + return formatToolError("Failed to create PR", err); } }, }); @@ -222,10 +221,7 @@ export function createWorkflowTools( title: artifact?.title ?? title, }; } catch (err) { - return { - success: false, - error: `Screenshot failed: ${err instanceof Error ? err.message : String(err)}`, - }; + return formatToolError("Screenshot failed", err); } }, }); @@ -268,10 +264,10 @@ export function createWorkflowTools( totalComments: comments.length, actionableComments: actionableComments.length, reviewsRequiringChanges: pendingReviews.filter((r) => r.state === "changes_requested").length, - checksStatus: checks.every((c) => c.conclusion === "success") - ? "passing" - : checks.some((c) => c.conclusion === "failure") - ? "failing" + checksStatus: checks.some((c) => c.conclusion === "failure") + ? "failing" + : checks.every((c) => c.conclusion === "success") + ? "passing" : "pending", }, comments: actionableComments.map((c) => ({ @@ -292,10 +288,7 @@ export function createWorkflowTools( })), }; } catch (err) { - return { - success: false, - error: `Failed to get PR comments: ${err instanceof Error ? err.message : String(err)}`, - }; + return formatToolError("Failed to get PR comments", err); } }, }); @@ -330,10 +323,7 @@ export function createWorkflowTools( commentId: comment.id, }; } catch (err) { - return { - success: false, - error: `Failed to post reply: ${err instanceof Error ? err.message : String(err)}`, - }; + return formatToolError("Failed to post reply", err); } }, }); @@ -355,13 +345,10 @@ export function createWorkflowTools( const failing = checks.filter((c) => c.conclusion === "failure"); const pending = checks.filter((c) => c.status !== "completed"); + const overall = failing.length > 0 ? "failing" : pending.length > 0 ? "pending" : "passing"; return { success: true, - overall: failing.length > 0 - ? "failing" - : pending.length > 0 - ? "pending" - : "passing", + overall, total: checks.length, passing: passing.length, failing: failing.length, @@ -374,10 +361,147 @@ export function createWorkflowTools( })), }; } catch (err) { + return formatToolError("Failed to get checks", err); + } + }, + }); + + tools.prRefreshIssueInventory = tool({ + description: + "Refresh the current pull request issue inventory, including checks, failing workflow details, unresolved review threads, and advisory issue comments. " + + "Use this to understand what still needs to be fixed before the PR is ready.", + inputSchema: z.object({ + prId: z.string().describe("The ADE PR ID to inspect"), + }), + execute: async ({ prId }) => { + try { + const [checks, actionRuns, reviewThreads, comments] = await Promise.all([ + prService.getChecks(prId), + prService.getActionRuns(prId), + prService.getReviewThreads(prId), + prService.getComments(prId), + ]); + const availability = getPrIssueResolutionAvailability(checks, reviewThreads); + const failingRuns = actionRuns + .filter((run) => run.conclusion === "failure" || run.conclusion === "timed_out" || run.conclusion === "action_required") + .map((run) => ({ + id: run.id, + name: run.name, + status: run.status, + conclusion: run.conclusion, + url: run.htmlUrl, + failingJobs: run.jobs + .filter((job) => job.conclusion === "failure" || job.status === "in_progress") + .map((job) => ({ + id: job.id, + name: job.name, + status: job.status, + conclusion: job.conclusion, + failingSteps: job.steps + .filter((step) => step.conclusion === "failure" || step.status === "in_progress") + .map((step) => step.name), + })), + })); + return { - success: false, - error: `Failed to get checks: ${err instanceof Error ? err.message : String(err)}`, + success: true, + summary: availability, + checks: checks.map((check) => ({ + name: check.name, + status: check.status, + conclusion: check.conclusion, + url: check.detailsUrl, + })), + failingWorkflowRuns: failingRuns, + reviewThreads: reviewThreads + .filter((thread) => !thread.isResolved && !thread.isOutdated) + .map((thread) => ({ + id: thread.id, + path: thread.path, + line: thread.line, + url: thread.url, + comments: thread.comments.map((comment) => ({ + id: comment.id, + author: comment.author, + body: comment.body, + url: comment.url, + })), + })), + issueComments: comments + .filter((comment) => comment.source === "issue") + .map((comment) => ({ + id: comment.id, + author: comment.author, + body: comment.body, + url: comment.url, + })), + }; + } catch (err) { + return formatToolError("Failed to refresh PR issue inventory", err); + } + }, + }); + + tools.prRerunFailedChecks = tool({ + description: + "Rerun failed CI checks for a pull request through ADE's GitHub integration. " + + "Use this after pushing a fix or when the current failed runs should be retried.", + inputSchema: z.object({ + prId: z.string().describe("The ADE PR ID to rerun checks for"), + }), + execute: async ({ prId }) => { + try { + await prService.rerunChecks({ prId }); + return { + success: true, + prId, + }; + } catch (err) { + return formatToolError("Failed to rerun checks", err); + } + }, + }); + + tools.prReplyToReviewThread = tool({ + description: + "Reply to a GitHub pull request review thread. " + + "Use this when you need to explain a fix or justify why a review thread is not being changed.", + inputSchema: z.object({ + prId: z.string().describe("The ADE PR ID"), + threadId: z.string().describe("The GitHub review thread node ID"), + body: z.string().describe("The markdown reply to post"), + }), + execute: async ({ prId, threadId, body }) => { + try { + const comment = await prService.replyToReviewThread({ prId, threadId, body }); + return { + success: true, + comment, + }; + } catch (err) { + return formatToolError("Failed to reply to review thread", err); + } + }, + }); + + tools.prResolveReviewThread = tool({ + description: + "Resolve a GitHub pull request review thread through ADE's GitHub integration. " + + "Use this only after the underlying issue is actually fixed or the thread is clearly stale/invalid.", + inputSchema: z.object({ + prId: z.string().describe("The ADE PR ID"), + threadId: z.string().describe("The GitHub review thread node ID"), + }), + execute: async ({ prId, threadId }) => { + try { + await prService.resolveReviewThread({ prId, threadId }); + return { + success: true, + prId, + threadId, }; + } catch (err) { + return formatToolError("Failed to resolve review thread", err); } }, }); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.test.ts b/apps/desktop/src/main/services/conflicts/conflictService.test.ts index 47e3106f..8a606550 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.test.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.test.ts @@ -61,6 +61,28 @@ function seedRepoWithLaneWork(root: string): { laneHeadSha: string } { return { laneHeadSha: git(root, ["rev-parse", "HEAD"]) }; } +function seedQueueRebaseRepo(root: string): void { + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "src", "base.ts"), "export const base = 1;\n", "utf8"); + git(root, ["init", "-b", "main"]); + git(root, ["config", "user.email", "ade@test.local"]); + git(root, ["config", "user.name", "ADE Test"]); + git(root, ["add", "."]); + git(root, ["commit", "-m", "base"]); + + git(root, ["checkout", "-b", "feature/lane-2"]); + fs.writeFileSync(path.join(root, "src", "lane2.ts"), "export const lane2 = true;\n", "utf8"); + git(root, ["add", "src/lane2.ts"]); + git(root, ["commit", "-m", "lane 2 work"]); + + git(root, ["checkout", "main"]); + fs.writeFileSync(path.join(root, "src", "landed.ts"), "export const landed = true;\n", "utf8"); + git(root, ["add", "src/landed.ts"]); + git(root, ["commit", "-m", "land queue item 1"]); + + git(root, ["checkout", "feature/lane-2"]); +} + async function seedProjectAndLane(db: any, projectId: string, repoRoot: string) { const now = "2026-02-15T19:00:00.000Z"; db.run( @@ -805,4 +827,129 @@ describe("conflictService conflict context integrity", () => { expect(runRecord.sourceLaneIds).toEqual(["lane-1", "lane-2"]); expect(runRecord.resolverContextKey).toBeTruthy(); }); + + it("surfaces and executes queue-aware rebases against the queue target", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-queue-rebase-")); + const repoRoot = path.join(root, "repo"); + fs.mkdirSync(repoRoot, { recursive: true }); + seedQueueRebaseRepo(repoRoot); + const db = await openKvDb(path.join(root, "kv.sqlite"), createLogger()); + const projectId = "proj-queue-rebase"; + const now = "2026-03-23T12:00:00.000Z"; + + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "demo", "main", now, now] + ); + db.run( + ` + insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at) + values (?, ?, 'queue', ?, 0, 1, ?, ?) + `, + ["group-queue", projectId, "Queue A", "main", now] + ); + db.run( + ` + insert into pull_requests( + id, lane_id, project_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "pr-1", + "lane-1", + projectId, + "owner", + "repo", + 1, + "https://example.com/pr/1", + null, + "lane 1", + "merged", + "main", + "feature/lane-1", + "passing", + "approved", + 0, + 0, + now, + now, + now + ] + ); + db.run( + ` + insert into pull_requests( + id, lane_id, project_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "pr-2", + "lane-2", + projectId, + "owner", + "repo", + 2, + "https://example.com/pr/2", + null, + "lane 2", + "open", + "main", + "feature/lane-2", + "passing", + "approved", + 0, + 0, + now, + now, + now + ] + ); + db.run( + `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, 'source')`, + [randomUUID(), "group-queue", "pr-1", "lane-1", 0] + ); + db.run( + `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, 'source')`, + [randomUUID(), "group-queue", "pr-2", "lane-2", 1] + ); + + const lane = createLaneSummary(repoRoot, { + id: "lane-2", + name: "Lane 2", + branchRef: "feature/lane-2", + baseRef: "feature/lane-1", + parentLaneId: "lane-1" + }); + + const service = createConflictService({ + db, + logger: createLogger(), + projectId, + projectRoot: repoRoot, + laneService: { + list: async () => [lane], + getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, baseRef: "feature/lane-1", branchRef: "feature/lane-2" }) + } as any, + projectConfigService: { + get: () => ({ effective: { providerMode: "guest" }, local: {} }) + } as any, + }); + + const needs = await service.scanRebaseNeeds(); + expect(needs).toHaveLength(1); + expect(needs[0]).toMatchObject({ + laneId: "lane-2", + baseBranch: "main", + groupContext: "Queue A", + }); + expect((needs[0]?.behindBy ?? 0) > 0).toBe(true); + + const rebased = await service.rebaseLane({ laneId: "lane-2" }); + expect(rebased.success).toBe(true); + expect(git(repoRoot, ["rev-list", "--count", "HEAD..main"])).toBe("0"); + }); }); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index ea44b8ea..5c24384f 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -74,6 +74,7 @@ import { normalizeConflictType, runGit, runGitMergeTree, runGitOrThrow } from ". import { redactSecretsDeep } from "../../utils/redaction"; import { extractFirstJsonObject } from "../ai/utils"; import { safeSegment } from "../shared/packLegacyUtils"; +import { fetchQueueTargetTrackingBranches, resolveQueueRebaseOverride } from "../shared/queueRebase"; import { asString, isRecord, parseDiffNameOnly, safeJsonParse, uniqueSorted } from "../shared/utils"; type PredictionStatus = "clean" | "conflict" | "unknown"; @@ -4159,6 +4160,12 @@ export function createConflictService({ }; const scanRebaseNeeds = async (): Promise => { + await fetchQueueTargetTrackingBranches({ + db, + projectId, + projectRoot, + }); + const lanes = await listActiveLanes(); const needs: RebaseNeed[] = []; @@ -4166,7 +4173,15 @@ export function createConflictService({ const nonPrimaryLanes = lanes.filter((l) => l.laneType !== "primary"); for (const lane of nonPrimaryLanes) { try { - const baseHead = await readHeadSha(projectRoot, lane.baseRef); + const queueOverride = await resolveQueueRebaseOverride({ + db, + projectId, + projectRoot, + laneId: lane.id, + }); + const comparisonRef = queueOverride?.comparisonRef ?? lane.baseRef; + const displayBaseBranch = queueOverride?.displayBaseBranch ?? lane.baseRef; + const baseHead = await readHeadSha(projectRoot, comparisonRef); const laneHead = await readHeadSha(lane.worktreePath, "HEAD"); // Count how many commits the lane is behind base @@ -4193,12 +4208,12 @@ export function createConflictService({ needs.push({ laneId: lane.id, laneName: lane.name, - baseBranch: lane.baseRef, + baseBranch: displayBaseBranch, behindBy, conflictPredicted: conflictingFiles.length > 0, conflictingFiles, prId: null, - groupContext: null, + groupContext: queueOverride?.groupContext ?? null, dismissedAt: rebaseDismissed.get(lane.id) ?? null, deferredUntil: rebaseDeferred.get(lane.id) ?? null }); @@ -4216,12 +4231,26 @@ export function createConflictService({ }; const getRebaseNeed = async (laneId: string): Promise => { + await fetchQueueTargetTrackingBranches({ + db, + projectId, + projectRoot, + }); + const lanes = await listActiveLanes(); const lane = lanes.find((l) => l.id === laneId); if (!lane || lane.laneType === "primary") return null; try { - const baseHead = await readHeadSha(projectRoot, lane.baseRef); + const queueOverride = await resolveQueueRebaseOverride({ + db, + projectId, + projectRoot, + laneId: lane.id, + }); + const comparisonRef = queueOverride?.comparisonRef ?? lane.baseRef; + const displayBaseBranch = queueOverride?.displayBaseBranch ?? lane.baseRef; + const baseHead = await readHeadSha(projectRoot, comparisonRef); const laneHead = await readHeadSha(lane.worktreePath, "HEAD"); const behindRes = await runGit( @@ -4246,12 +4275,12 @@ export function createConflictService({ return { laneId: lane.id, laneName: lane.name, - baseBranch: lane.baseRef, + baseBranch: displayBaseBranch, behindBy, conflictPredicted: conflictingFiles.length > 0, conflictingFiles, prId: null, - groupContext: null, + groupContext: queueOverride?.groupContext ?? null, dismissedAt: rebaseDismissed.get(lane.id) ?? null, deferredUntil: rebaseDeferred.get(lane.id) ?? null }; @@ -4332,6 +4361,9 @@ export function createConflictService({ if (args.aiAssisted) { logger.info(`rebaseLane: AI-assisted rebase requested for lane ${args.laneId}`, { provider: args.provider ?? "codex", + modelId: args.modelId ?? null, + reasoningEffort: args.reasoningEffort ?? null, + permissionMode: args.permissionMode ?? null, autoApplyThreshold: args.autoApplyThreshold }); } @@ -4340,8 +4372,15 @@ export function createConflictService({ onEvent({ type: "rebase-started", laneId: args.laneId, timestamp: new Date().toISOString() }); } + const queueOverride = await resolveQueueRebaseOverride({ + db, + projectId, + projectRoot, + laneId: lane.id, + }); + const rebaseTarget = queueOverride?.comparisonRef ?? lane.baseRef; const rebaseRes = await runGit( - ["rebase", lane.baseRef], + ["rebase", rebaseTarget], { cwd: lane.worktreePath, timeoutMs: 120_000 } ); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 76cd5390..f6495380 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { IPC } from "../../../shared/ipc"; import { getModelById } from "../../../shared/modelRegistry"; import { buildPrAiResolutionContextKey } from "../../../shared/types"; +import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../prs/prIssueResolver"; import type { AdeCleanupResult, AdeProjectSnapshot } from "../../../shared/types"; import type { ApplyConflictProposalArgs, @@ -35,12 +36,12 @@ import type { AddMissionArtifactArgs, AddMissionInterventionArgs, ConflictProposal, - ConflictExternalResolverRunSummary, - ConflictProposalPreview, - ConflictOverlap, - ConflictStatus, - DraftPrDescriptionArgs, - CreateLaneArgs, + ConflictExternalResolverRunSummary, + ConflictProposalPreview, + ConflictOverlap, + ConflictStatus, + DraftPrDescriptionArgs, + CreateLaneArgs, CreateChildLaneArgs, DeleteLaneArgs, DockLayout, @@ -118,18 +119,25 @@ import type { PrAiResolutionSessionInfo, PrAiResolutionSessionStatus, AiPermissionMode, + PrIssueResolutionPromptPreviewArgs, + PrIssueResolutionPromptPreviewResult, + PrIssueResolutionStartArgs, + PrIssueResolutionStartResult, LinkPrToLaneArgs, LandResult, LandStackEnhancedArgs, LandQueueNextArgs, PrCheck, PrComment, + PrReviewThread, PrHealth, PrMergeContext, PrReview, PrStatus, PrSummary, QueueLandingState, + ReplyToPrReviewThreadArgs, + ResolvePrReviewThreadArgs, SimulateIntegrationArgs, UpdatePrDescriptionArgs, LandPrArgs, @@ -437,9 +445,6 @@ import type { BudgetCapScope, BudgetCapProvider, BudgetCapConfig, -} from "../../../shared/types"; -import type { LaneEnvInitConfig, LaneOverlayOverrides, LaneTemplate, PortLease } from "../../../shared/types"; -import type { ComputerUseArtifactListArgs, ComputerUseArtifactReviewArgs, ComputerUseArtifactRouteArgs, @@ -448,6 +453,10 @@ import type { ComputerUseOwnerSnapshot, ComputerUseOwnerSnapshotArgs, ComputerUseSettingsSnapshot, + LaneEnvInitConfig, + LaneOverlayOverrides, + LaneTemplate, + PortLease, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; @@ -478,7 +487,6 @@ import type { createGithubService } from "../github/githubService"; import type { createPrService } from "../prs/prService"; import type { createPrPollingService } from "../prs/prPollingService"; import type { createQueueLandingService } from "../prs/queueLandingService"; -import type { createQueueRehearsalService } from "../prs/queueRehearsalService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { @@ -571,7 +579,6 @@ export type AppContext = { prService: ReturnType; prPollingService: ReturnType; queueLandingService: ReturnType; - queueRehearsalService: ReturnType; jobEngine: ReturnType; automationService: ReturnType; automationPlannerService: ReturnType; @@ -4306,6 +4313,16 @@ export function registerIpc({ } }); + ipcMain.handle(IPC.prsGetReviewThreads, async (_event, arg: { prId: string }): Promise => { + const ctx = ensurePrPolling(); + try { + return await ctx.prService.getReviewThreads(arg.prId); + } catch (err) { + if (err instanceof Error && err.message.includes("PR not found")) return []; + throw err; + } + }); + ipcMain.handle(IPC.prsUpdateDescription, async (_event, arg: UpdatePrDescriptionArgs): Promise => { const ctx = getCtx(); await ctx.prService.updateDescription(arg); @@ -4465,18 +4482,10 @@ export function registerIpc({ ipcMain.handle(IPC.prsCancelQueueAutomation, async (_event, arg) => getCtx().queueLandingService.cancelQueue(arg.queueId)); - ipcMain.handle(IPC.prsStartQueueRehearsal, async (_event, arg) => { - const ctx = getCtx(); - const state = await ctx.queueRehearsalService.startQueueRehearsal(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_start_queue_rehearsal:${arg.groupId}`, - }); - return state; + ipcMain.handle(IPC.prsReorderQueue, async (_event, arg: import("../../../shared/types").ReorderQueuePrsArgs): Promise => { + await getCtx().prService.reorderQueuePrs(arg); }); - ipcMain.handle(IPC.prsCancelQueueRehearsal, async (_event, arg) => getCtx().queueRehearsalService.cancelQueueRehearsal(arg.rehearsalId)); - ipcMain.handle(IPC.prsGetHealth, async (_event, arg: { prId: string }): Promise => getCtx().prService.getPrHealth(arg.prId)); ipcMain.handle(IPC.prsGetQueueState, async (_event, arg: { groupId: string }): Promise => @@ -4485,12 +4494,6 @@ export function registerIpc({ ipcMain.handle(IPC.prsListQueueStates, async (_event, arg = {}) => getCtx().queueLandingService.listQueueStates(arg)); - ipcMain.handle(IPC.prsGetQueueRehearsalState, async (_event, arg: { groupId: string }) => - getCtx().queueRehearsalService.getQueueRehearsalStateByGroup(arg.groupId) - ); - - ipcMain.handle(IPC.prsListQueueRehearsals, async (_event, arg = {}) => getCtx().queueRehearsalService.listQueueRehearsals(arg)); - ipcMain.handle(IPC.prsCreateIntegrationLaneForProposal, async (_event, arg: CreateIntegrationLaneForProposalArgs): Promise => getCtx().prService.createIntegrationLaneForProposal(arg)); @@ -4773,11 +4776,42 @@ export function registerIpc({ }); }); + ipcMain.handle(IPC.prsIssueResolutionStart, async (_event, arg: PrIssueResolutionStartArgs): Promise => { + const ctx = getCtx(); + return await launchPrIssueResolutionChat( + { + prService: ctx.prService, + laneService: ctx.laneService, + agentChatService: ctx.agentChatService, + sessionService: ctx.sessionService, + }, + arg, + ); + }); + + ipcMain.handle(IPC.prsIssueResolutionPreviewPrompt, async ( + _event, + arg: PrIssueResolutionPromptPreviewArgs, + ): Promise => { + const ctx = getCtx(); + return await previewPrIssueResolutionPrompt( + { + prService: ctx.prService, + laneService: ctx.laneService, + agentChatService: ctx.agentChatService, + sessionService: ctx.sessionService, + }, + arg, + ); + }); + 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.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.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)); ipcMain.handle(IPC.prsUpdateTitle, (_e, args) => getCtx().prService.updateTitle(args)); ipcMain.handle(IPC.prsUpdateBody, (_e, args) => getCtx().prService.updateBody(args)); ipcMain.handle(IPC.prsSetLabels, (_e, args) => getCtx().prService.setLabels(args)); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index dceb9019..9ec4910c 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import type { AdeDb } from "../state/kvDb"; import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; import { isWithinDir } from "../shared/utils"; +import { resolveQueueRebaseOverride } from "../shared/queueRebase"; import { detectConflictKind } from "../git/gitConflictState"; import type { createOperationService } from "../history/operationService"; import type { @@ -564,11 +565,17 @@ export function createLaneService({ const row = rowsById.get(laneId); if (!row) return DEFAULT_LANE_STATUS; const parent = row.parent_lane_id ? rowsById.get(row.parent_lane_id) : null; - let baseRef = parent?.branch_ref ?? row.base_ref; + const queueOverride = await resolveQueueRebaseOverride({ + db, + projectId, + projectRoot, + laneId: row.id, + }); + let baseRef = queueOverride?.comparisonRef ?? parent?.branch_ref ?? row.base_ref; // For primary lanes with no parent, compare against the upstream tracking ref // instead of base_ref (which equals branchRef, giving 0 behind). - if (!parent && row.lane_type === "primary") { + if (!queueOverride && !parent && row.lane_type === "primary") { const upstreamRes = await runGit( ["rev-parse", "--verify", `${row.branch_ref}@{upstream}`], { cwd: row.worktree_path, timeoutMs: 5_000 } @@ -1741,6 +1748,10 @@ export function createLaneService({ invalidateLaneListCache(); }, + invalidateCache(): void { + invalidateLaneListCache(); + }, + getFilesWorkspaces(): Array<{ id: string; kind: LaneType; diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.test.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.test.ts new file mode 100644 index 00000000..a32e663f --- /dev/null +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.test.ts @@ -0,0 +1,434 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { openKvDb } from "../state/kvDb"; +import { createRebaseSuggestionService } from "./rebaseSuggestionService"; + +function git(cwd: string, args: string[]): string { + const res = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (res.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${res.stderr || res.stdout}`); + } + return (res.stdout ?? "").trim(); +} + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; +} + +function seedRepo(root: string): void { + fs.writeFileSync(path.join(root, "README.md"), "base\n", "utf8"); + git(root, ["init", "-b", "main"]); + git(root, ["config", "user.email", "ade@test.local"]); + git(root, ["config", "user.name", "ADE Test"]); + git(root, ["add", "README.md"]); + git(root, ["commit", "-m", "base"]); +} + +function createBranchWorktree(root: string, branchName: string, prefix: string): string { + git(root, ["branch", "--force", branchName, "HEAD"]); + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + git(root, ["worktree", "add", worktreePath, branchName]); + return worktreePath; +} + +function commitMainUpdate(root: string, fileName: string, content: string, message: string): void { + fs.writeFileSync(path.join(root, fileName), content, "utf8"); + git(root, ["add", fileName]); + git(root, ["commit", "-m", message]); +} + +describe("rebaseSuggestionService", () => { + it("suggests rebasing the next queued lane even when it has no parent lane", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-rebase-suggestions-")); + seedRepo(repoRoot); + const lane2Worktree = createBranchWorktree(repoRoot, "feature/lane-2", "ade-rebase-suggestions-lane2-"); + commitMainUpdate(repoRoot, "main-update.txt", "main drift\n", "main drift"); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const projectId = "proj-rebase-suggestions"; + const now = "2026-03-23T12:00:00.000Z"; + + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "demo", "main", now, now] + ); + db.run( + ` + insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at) + values (?, ?, 'queue', ?, 0, 1, ?, ?) + `, + ["group-queue", projectId, "Queue A", "main", now] + ); + db.run( + ` + insert into pull_requests( + id, lane_id, project_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "pr-1", + "lane-1", + projectId, + "owner", + "repo", + 1, + "https://example.com/pr/1", + null, + "lane 1", + "merged", + "main", + "feature/lane-1", + "passing", + "approved", + 0, + 0, + now, + now, + now + ] + ); + db.run( + ` + insert into pull_requests( + id, lane_id, project_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "pr-2", + "lane-2", + projectId, + "owner", + "repo", + 2, + "https://example.com/pr/2", + null, + "lane 2", + "open", + "main", + "feature/lane-2", + "passing", + "approved", + 0, + 0, + now, + now, + now + ] + ); + db.run( + `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, 'source')`, + [randomUUID(), "group-queue", "pr-1", "lane-1", 0] + ); + db.run( + `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, 'source')`, + [randomUUID(), "group-queue", "pr-2", "lane-2", 1] + ); + + const service = createRebaseSuggestionService({ + db, + logger: createLogger(), + projectId, + projectRoot: repoRoot, + laneService: { + list: async () => [ + { + id: "lane-2", + name: "Lane 2", + description: null, + laneType: "worktree", + branchRef: "feature/lane-2", + baseRef: "main", + worktreePath: lane2Worktree, + attachedRootPath: null, + isEditProtected: false, + parentLaneId: null, + color: null, + icon: null, + tags: [], + status: { dirty: false, ahead: 0, behind: 0, conflict: "unknown", tests: "unknown", pr: "none" }, + stackDepth: 0, + createdAt: now, + archivedAt: null, + childCount: 0, + parentStatus: null, + }, + ], + } as any, + }); + + const suggestions = await service.listSuggestions(); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + laneId: "lane-2", + baseLabel: "queue target main", + groupContext: "Queue A", + hasPr: true, + }); + }); + + it("keeps suggesting a queue rebase after landed members are removed from group membership", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-rebase-suggestions-pruned-")); + seedRepo(repoRoot); + const lane2Worktree = createBranchWorktree(repoRoot, "feature/lane-2", "ade-rebase-suggestions-pruned-lane2-"); + commitMainUpdate(repoRoot, "main-update.txt", "main drift\n", "main drift"); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const projectId = "proj-rebase-suggestions-pruned"; + const now = "2026-03-23T12:00:00.000Z"; + + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "demo", "main", now, now] + ); + db.run( + ` + insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at) + values (?, ?, 'queue', ?, 0, 1, ?, ?) + `, + ["group-queue", projectId, "Queue A", "main", now] + ); + db.run( + ` + insert into pull_requests( + id, lane_id, project_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "pr-1", + "lane-1", + projectId, + "owner", + "repo", + 1, + "https://example.com/pr/1", + null, + "lane 1", + "merged", + "main", + "feature/lane-1", + "passing", + "approved", + 0, + 0, + now, + now, + now + ] + ); + db.run( + ` + insert into pull_requests( + id, lane_id, project_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "pr-2", + "lane-2", + projectId, + "owner", + "repo", + 2, + "https://example.com/pr/2", + null, + "lane 2", + "open", + "main", + "feature/lane-2", + "passing", + "approved", + 0, + 0, + now, + now, + now + ] + ); + db.run( + `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, 'source')`, + [randomUUID(), "group-queue", "pr-2", "lane-2", 1] + ); + + const service = createRebaseSuggestionService({ + db, + logger: createLogger(), + projectId, + projectRoot: repoRoot, + laneService: { + list: async () => [ + { + id: "lane-2", + name: "Lane 2", + description: null, + laneType: "worktree", + branchRef: "feature/lane-2", + baseRef: "main", + worktreePath: lane2Worktree, + attachedRootPath: null, + isEditProtected: false, + parentLaneId: null, + color: null, + icon: null, + tags: [], + status: { dirty: false, ahead: 0, behind: 0, conflict: "unknown", tests: "unknown", pr: "none" }, + stackDepth: 0, + createdAt: now, + archivedAt: null, + childCount: 0, + parentStatus: null, + }, + ], + } as any, + }); + + const suggestions = await service.listSuggestions(); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + laneId: "lane-2", + baseLabel: "queue target main", + groupContext: "Queue A", + hasPr: true, + }); + }); + + it("suggests rebasing every unfinished queue member after the landed prefix", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-rebase-suggestions-remaining-")); + seedRepo(repoRoot); + const lane2Worktree = createBranchWorktree(repoRoot, "feature/lane-2", "ade-rebase-suggestions-remaining-lane2-"); + const lane3Worktree = createBranchWorktree(repoRoot, "feature/lane-3", "ade-rebase-suggestions-remaining-lane3-"); + commitMainUpdate(repoRoot, "main-update.txt", "main drift\n", "main drift"); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const projectId = "proj-rebase-suggestions-remaining"; + const now = "2026-03-23T12:00:00.000Z"; + + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "demo", "main", now, now] + ); + db.run( + ` + insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at) + values (?, ?, 'queue', ?, 0, 1, ?, ?) + `, + ["group-queue", projectId, "Queue A", "main", now] + ); + + const insertPr = (id: string, laneId: string, prNumber: number, title: string, state: string) => { + db.run( + ` + insert into pull_requests( + id, lane_id, project_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + id, + laneId, + projectId, + "owner", + "repo", + prNumber, + `https://example.com/pr/${prNumber}`, + null, + title, + state, + "main", + `feature/${laneId}`, + "passing", + "approved", + 0, + 0, + now, + now, + now, + ] + ); + }; + + insertPr("pr-1", "lane-1", 1, "lane 1", "merged"); + insertPr("pr-2", "lane-2", 2, "lane 2", "open"); + insertPr("pr-3", "lane-3", 3, "lane 3", "open"); + + db.run( + `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, 'source')`, + [randomUUID(), "group-queue", "pr-2", "lane-2", 1] + ); + db.run( + `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, 'source')`, + [randomUUID(), "group-queue", "pr-3", "lane-3", 2] + ); + + const service = createRebaseSuggestionService({ + db, + logger: createLogger(), + projectId, + projectRoot: repoRoot, + laneService: { + list: async () => [ + { + id: "lane-2", + name: "Lane 2", + description: null, + laneType: "worktree", + branchRef: "feature/lane-2", + baseRef: "main", + worktreePath: lane2Worktree, + attachedRootPath: null, + isEditProtected: false, + parentLaneId: null, + color: null, + icon: null, + tags: [], + status: { dirty: false, ahead: 0, behind: 0, conflict: "unknown", tests: "unknown", pr: "none" }, + stackDepth: 0, + createdAt: now, + archivedAt: null, + childCount: 0, + parentStatus: null, + }, + { + id: "lane-3", + name: "Lane 3", + description: null, + laneType: "worktree", + branchRef: "feature/lane-3", + baseRef: "main", + worktreePath: lane3Worktree, + attachedRootPath: null, + isEditProtected: false, + parentLaneId: null, + color: null, + icon: null, + tags: [], + status: { dirty: false, ahead: 0, behind: 0, conflict: "unknown", tests: "unknown", pr: "none" }, + stackDepth: 0, + createdAt: now, + archivedAt: null, + childCount: 0, + parentStatus: null, + }, + ], + } as any, + }); + + const suggestions = await service.listSuggestions(); + expect(suggestions).toHaveLength(2); + expect(suggestions.map((suggestion) => suggestion.laneId)).toEqual(["lane-2", "lane-3"]); + expect(suggestions.every((suggestion) => suggestion.baseLabel === "queue target main")).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts index 9e9f1045..d0fc1410 100644 --- a/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts +++ b/apps/desktop/src/main/services/lanes/rebaseSuggestionService.ts @@ -1,8 +1,9 @@ -import { getHeadSha } from "../git/git"; +import { getHeadSha, runGit } from "../git/git"; import type { AdeDb } from "../state/kvDb"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "./laneService"; -import type { RebaseSuggestion, RebaseSuggestionsEventPayload } from "../../../shared/types"; +import type { LaneSummary, RebaseSuggestion, RebaseSuggestionsEventPayload } from "../../../shared/types"; +import { fetchQueueTargetTrackingBranches, resolveQueueRebaseOverride } from "../shared/queueRebase"; import { isRecord, nowIso } from "../shared/utils"; type StoredSuggestionState = { @@ -57,10 +58,11 @@ export function createRebaseSuggestionService(args: { db: AdeDb; logger: Logger; projectId: string; + projectRoot: string; laneService: ReturnType; onEvent?: (event: RebaseSuggestionsEventPayload) => void; }) { - const { db, logger, projectId, laneService, onEvent } = args; + const { db, logger, projectId, projectRoot, laneService, onEvent } = args; const getPrLaneIds = (): Set => { const rows = db.all<{ lane_id: string }>( @@ -80,40 +82,87 @@ export function createRebaseSuggestionService(args: { db.setJson(keyForLane(state.laneId), state); }; + const readRefHeadSha = async (ref: string): Promise => { + const result = await runGit(["rev-parse", "--verify", ref], { cwd: projectRoot, timeoutMs: 10_000 }); + return result.exitCode === 0 && result.stdout.trim() ? result.stdout.trim() : null; + }; + + const readBehindCount = async (args: { laneWorktreePath: string; baseHeadSha: string }): Promise => { + const laneHeadSha = await getHeadSha(args.laneWorktreePath); + if (!laneHeadSha) return 0; + const result = await runGit( + ["rev-list", "--count", `${laneHeadSha}..${args.baseHeadSha}`], + { cwd: projectRoot, timeoutMs: 10_000 } + ); + return result.exitCode === 0 ? Math.max(0, Number(result.stdout.trim()) || 0) : 0; + }; + + const resolveSuggestionBase = async ( + lane: LaneSummary, + laneById: Map, + ): Promise<{ parentLaneId: string; parentHeadSha: string; baseLabel: string | null; groupContext: string | null } | null> => { + const queueOverride = await resolveQueueRebaseOverride({ + db, + projectId, + projectRoot, + laneId: lane.id, + }); + if (queueOverride) { + const parentHeadSha = await readRefHeadSha(queueOverride.comparisonRef); + if (!parentHeadSha) return null; + return { + parentLaneId: lane.parentLaneId ?? `queue:${queueOverride.queueGroupId}`, + parentHeadSha, + baseLabel: queueOverride.baseLabel, + groupContext: queueOverride.groupContext, + }; + } + + if (!lane.parentLaneId) return null; + const parent = laneById.get(lane.parentLaneId); + if (!parent) return null; + const parentHeadSha = await getHeadSha(parent.worktreePath); + if (!parentHeadSha) return null; + return { + parentLaneId: lane.parentLaneId, + parentHeadSha, + baseLabel: parent.name ?? null, + groupContext: null, + }; + }; + const listSuggestions = async (): Promise => { + await fetchQueueTargetTrackingBranches({ + db, + projectId, + projectRoot, + }); + const lanes = await laneService.list({ includeArchived: false }); const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const prLaneIds = getPrLaneIds(); - const parentHeadShaById = new Map(); const out: RebaseSuggestion[] = []; const nowMs = Date.now(); for (const lane of lanes) { - const parentLaneId = lane.parentLaneId; - if (!parentLaneId) continue; - if (lane.status.behind <= 0) continue; - - const parent = laneById.get(parentLaneId); - if (!parent) continue; - - let parentHeadSha = parentHeadShaById.get(parentLaneId); - if (parentHeadSha === undefined) { - parentHeadSha = await getHeadSha(parent.worktreePath); - parentHeadShaById.set(parentLaneId, parentHeadSha); - } - if (!parentHeadSha) continue; + const base = await resolveSuggestionBase(lane, laneById); + if (!base) continue; + const behindCount = await readBehindCount({ + laneWorktreePath: lane.worktreePath, + baseHeadSha: base.parentHeadSha, + }); + if (behindCount <= 0) continue; const existing = loadState(lane.id); - const behindCount = Math.max(0, Math.floor(lane.status.behind)); - const nextState: StoredSuggestionState = existing && existing.parentLaneId === parentLaneId + const nextState: StoredSuggestionState = existing && existing.parentLaneId === base.parentLaneId ? (() => { - if (existing.parentHeadSha !== parentHeadSha) { + if (existing.parentHeadSha !== base.parentHeadSha) { return { laneId: lane.id, - parentLaneId, - parentHeadSha, + parentLaneId: base.parentLaneId, + parentHeadSha: base.parentHeadSha, behindCount, lastSuggestedAt: nowIso(), deferredUntil: existing.deferredUntil ?? null, @@ -125,8 +174,8 @@ export function createRebaseSuggestionService(args: { })() : { laneId: lane.id, - parentLaneId, - parentHeadSha, + parentLaneId: base.parentLaneId, + parentHeadSha: base.parentHeadSha, behindCount, lastSuggestedAt: nowIso(), deferredUntil: existing?.deferredUntil ?? null, @@ -137,13 +186,15 @@ export function createRebaseSuggestionService(args: { saveState(nextState); } - if (isSuppressed({ nowMs, state: nextState, currentParentHeadSha: parentHeadSha })) continue; + if (isSuppressed({ nowMs, state: nextState, currentParentHeadSha: base.parentHeadSha })) continue; out.push({ laneId: lane.id, - parentLaneId, - parentHeadSha, + parentLaneId: base.parentLaneId, + parentHeadSha: base.parentHeadSha, behindCount, + baseLabel: base.baseLabel, + groupContext: base.groupContext, lastSuggestedAt: nextState.lastSuggestedAt, deferredUntil: nextState.deferredUntil, dismissedAt: nextState.dismissedAt, @@ -179,19 +230,20 @@ export function createRebaseSuggestionService(args: { const lanes = await laneService.list({ includeArchived: false }); const lane = lanes.find((l) => l.id === laneId); if (!lane) throw new Error(`Lane not found: ${laneId}`); - if (!lane.parentLaneId) throw new Error("Lane has no parent; nothing to dismiss."); - - const parent = lanes.find((l) => l.id === lane.parentLaneId); - if (!parent) throw new Error("Parent lane not found."); - const parentHeadSha = await getHeadSha(parent.worktreePath); - if (!parentHeadSha) throw new Error("Unable to resolve parent HEAD."); + const laneById = new Map(lanes.map((entry) => [entry.id, entry] as const)); + const base = await resolveSuggestionBase(lane, laneById); + if (!base) throw new Error("Lane has no rebase suggestion to dismiss."); const existing = loadState(laneId); + const behindCount = await readBehindCount({ + laneWorktreePath: lane.worktreePath, + baseHeadSha: base.parentHeadSha, + }); const next: StoredSuggestionState = { laneId, - parentLaneId: lane.parentLaneId, - parentHeadSha, - behindCount: Math.max(0, Math.floor(lane.status.behind)), + parentLaneId: base.parentLaneId, + parentHeadSha: base.parentHeadSha, + behindCount, lastSuggestedAt: existing?.lastSuggestedAt ?? nowIso(), deferredUntil: existing?.deferredUntil ?? null, dismissedAt: nowIso() @@ -210,19 +262,20 @@ export function createRebaseSuggestionService(args: { const lanes = await laneService.list({ includeArchived: false }); const lane = lanes.find((l) => l.id === laneId); if (!lane) throw new Error(`Lane not found: ${laneId}`); - if (!lane.parentLaneId) throw new Error("Lane has no parent; nothing to defer."); - - const parent = lanes.find((l) => l.id === lane.parentLaneId); - if (!parent) throw new Error("Parent lane not found."); - const parentHeadSha = await getHeadSha(parent.worktreePath); - if (!parentHeadSha) throw new Error("Unable to resolve parent HEAD."); + const laneById = new Map(lanes.map((entry) => [entry.id, entry] as const)); + const base = await resolveSuggestionBase(lane, laneById); + if (!base) throw new Error("Lane has no rebase suggestion to defer."); const existing = loadState(laneId); + const behindCount = await readBehindCount({ + laneWorktreePath: lane.worktreePath, + baseHeadSha: base.parentHeadSha, + }); const next: StoredSuggestionState = { laneId, - parentLaneId: lane.parentLaneId, - parentHeadSha, - behindCount: Math.max(0, Math.floor(lane.status.behind)), + parentLaneId: base.parentLaneId, + parentHeadSha: base.parentHeadSha, + behindCount, lastSuggestedAt: existing?.lastSuggestedAt ?? nowIso(), deferredUntil: until, dismissedAt: null @@ -268,6 +321,7 @@ export function createRebaseSuggestionService(args: { return { listSuggestions, + refresh: emit, dismiss, defer, onParentHeadChanged diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts b/apps/desktop/src/main/services/missions/missionPreflightService.test.ts index 9dc8a26d..7d9d2137 100644 --- a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts +++ b/apps/desktop/src/main/services/missions/missionPreflightService.test.ts @@ -416,76 +416,6 @@ describe("missionPreflightService", () => { expect(result.checklist.find((item) => item.id === "capabilities")?.severity).toBe("fail"); }); - it("blocks launch when queue rehearsal is selected without a local target lane", async () => { - const profiles = createProfiles(); - const service = createMissionPreflightService({ - logger: createLogger(), - projectRoot: "/tmp/ade-preflight", - missionService: { - listPhaseProfiles: () => profiles, - } as any, - laneService: { - list: async () => [{ id: "lane-1", branchRef: "feature/lane-1", baseRef: "main", archivedAt: null }], - } as any, - aiIntegrationService: { - getAvailabilityAsync: async () => ({ - availableModels: [ - { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, - ], - }), - executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }), - } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - permissions: { - cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, - inProcess: { mode: "full-auto" }, - }, - }, - }, - }), - } as any, - missionBudgetService: { - estimateLaunchBudget: async () => ({ - estimate: createBudgetEstimate("subscription"), - hardLimitExceeded: false, - windowUsageCostUsd: 0.1, - remainingWindowCostUsd: 10.9, - budgetLimitCostUsd: 11, - }), - } as any, - }); - - const result = await service.runPreflight({ - launch: { - prompt: "Rehearse the queue before review.", - phaseProfileId: profiles[0]!.id, - phaseOverride: profiles[0]!.phases, - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "anthropic/claude-sonnet-4-6", - }, - }, - executionPolicy: { - prStrategy: { - kind: "queue", - targetBranch: "release/main", - rehearseQueue: true, - autoResolveConflicts: false, - mergeMethod: "squash", - }, - }, - } as any, - }); - - expect(result.canLaunch).toBe(false); - expect(result.checklist.find((item) => item.id === "capabilities")?.severity).toBe("fail"); - expect(result.checklist.find((item) => item.id === "capabilities")?.details.some((detail) => detail.includes("local lane"))).toBe(true); - }); - it("surfaces computer-use readiness when an external backend satisfies required proof", async () => { const profiles = createProfiles(); const phases = profiles[0]!.phases.map((phase, index) => index === 0 diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.ts b/apps/desktop/src/main/services/missions/missionPreflightService.ts index e368b8e7..341bff27 100644 --- a/apps/desktop/src/main/services/missions/missionPreflightService.ts +++ b/apps/desktop/src/main/services/missions/missionPreflightService.ts @@ -382,24 +382,9 @@ export function createMissionPreflightService(args: { ) || ( selectedPrStrategy?.kind === "queue" - && (selectedPrStrategy.autoLand === true || selectedPrStrategy.rehearseQueue === true) + && selectedPrStrategy.autoLand === true && selectedPrStrategy.autoResolveConflicts === true ); - if (selectedPrStrategy?.kind === "queue" && selectedPrStrategy.autoLand === true && selectedPrStrategy.rehearseQueue === true) { - capabilityIssues.push("Queue finalization cannot auto-land and dry-run the queue at the same time. Pick either auto-land or rehearse-only."); - } - if (selectedPrStrategy?.kind === "queue" && selectedPrStrategy.rehearseQueue === true) { - const targetBranch = toOptionalString(selectedPrStrategy.targetBranch) ?? "main"; - const stripRefPrefix = (ref: string) => ref.trim().replace(/^refs\/heads\//, "").replace(/^origin\//, ""); - const targetExists = activeLanes.some((lane) => { - const branchRef = stripRefPrefix(String((lane as { branchRef?: string }).branchRef ?? "")); - const baseRef = stripRefPrefix(String((lane as { baseRef?: string }).baseRef ?? "")); - return lane.id === targetBranch || branchRef === targetBranch || baseRef === targetBranch; - }); - if (!targetExists) { - capabilityIssues.push(`Queue rehearsal requires a local lane for target branch "${targetBranch}", but ADE could not find one.`); - } - } if (needsCliConflictResolver) { const hasCliResolverModel = [ ...selected.phases.map((phase) => phase.model.modelId), @@ -411,9 +396,7 @@ export function createMissionPreflightService(args: { if (!hasCliResolverModel) { capabilityIssues.push( selectedPrStrategy?.kind === "queue" - ? selectedPrStrategy.rehearseQueue - ? "Queue rehearsal is configured to resolve conflicts automatically, but no compatible Claude/Codex CLI resolver model is configured on the mission." - : "Queue auto-land is configured to resolve conflicts automatically, but no compatible Claude/Codex CLI resolver model is configured on the mission." + ? "Queue auto-land is configured to resolve conflicts automatically, but no compatible Claude/Codex CLI resolver model is configured on the mission." : "Integration finalization is configured to resolve conflicts automatically, but no compatible Claude/Codex CLI resolver model is configured on the mission." ); } @@ -790,7 +773,7 @@ export function createMissionPreflightService(args: { ? "Parallel fan-out may be reduced because available worktrees are below the ideal concurrency target." : "ADE can provision or reuse enough active lanes for the expected worker fan-out.", selectedPrStrategy?.kind === "queue" - ? "Queue finalization will respect the configured rehearse/auto-land settings and surface conflicts if automation is blocked." + ? "Queue finalization will respect the configured auto-land settings and surface blocked queue steps for operator follow-up." : selectedPrStrategy?.kind === "integration" ? "Integration finalization will open or land PRs using the configured PR depth and conflict policy." : "Mission will complete without a special PR/queue finalization contract.", diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 6cc44ba4..b95fb713 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -7211,166 +7211,6 @@ describe("aiOrchestratorService", () => { } }); - it("keeps mission incomplete while queue rehearsal finalization is still running", async () => { - const prServiceMock = { - createQueuePrs: vi.fn().mockResolvedValue({ - groupId: "queue-group-2", - prs: [ - { id: "pr-11", githubUrl: "https://github.com/test/repo/pull/111" }, - ], - errors: [], - }), - } as any; - const queueRehearsalServiceMock = { - startQueueRehearsal: vi.fn().mockResolvedValue({ - rehearsalId: "queue-rehearsal-1", - groupId: "queue-group-2", - groupName: "Queue Rehearsal", - targetBranch: "main", - state: "running", - entries: [], - currentPosition: 0, - scratchLaneId: "lane-scratch", - activePrId: "pr-11", - activeResolverRunId: null, - lastError: null, - waitReason: null, - config: { - method: "squash", - autoResolve: true, - resolverProvider: "claude", - resolverModel: "anthropic/claude-sonnet-4-6", - reasoningEffort: "medium", - permissionMode: "guarded_edit", - preserveScratchLane: true, - originSurface: "mission", - originMissionId: null, - originRunId: null, - originLabel: null, - }, - startedAt: new Date().toISOString(), - completedAt: null, - updatedAt: new Date().toISOString(), - }), - } as any; - - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Queue rehearsal mission.", - laneId: fixture.laneId, - plannedSteps: [ - { index: 0, title: "Worker task", detail: "Task", kind: "implementation", metadata: { stepType: "implementation" } }, - ], - }); - - const existingMeta = JSON.parse( - fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? limit 1`, - [mission.id], - )?.metadata_json ?? "{}", - ); - existingMeta.executionPolicy = { - ...existingMeta.executionPolicy, - prStrategy: { - kind: "queue", - targetBranch: "main", - rehearseQueue: true, - autoResolveConflicts: true, - mergeMethod: "squash", - }, - }; - existingMeta.missionLevelSettings = { - ...(existingMeta.missionLevelSettings ?? {}), - prStrategy: { - kind: "queue", - targetBranch: "main", - rehearseQueue: true, - autoResolveConflicts: true, - mergeMethod: "squash", - }, - }; - fixture.db.run( - `update missions set metadata_json = ? where id = ?`, - [JSON.stringify(existingMeta), mission.id], - ); - - const service = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - laneService: fixture.laneService, - projectConfigService: fixture.projectConfigService, - aiIntegrationService: fixture.aiIntegrationService, - prService: prServiceMock, - queueRehearsalService: queueRehearsalServiceMock, - projectRoot: fixture.projectRoot, - }); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "worker-task", - title: "Worker task", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation", instructions: "Do the work" }, - }, - ], - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - const runId = started.run.id; - - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((entry) => entry.status === "ready") ?? graph.steps[0]; - if (!readyStep) throw new Error("Expected mission step"); - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual", - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Done", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - - fixture.orchestratorService.tick({ runId }); - service.finalizeRun({ runId }); - await service.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - - expect(prServiceMock.createQueuePrs).toHaveBeenCalled(); - expect(queueRehearsalServiceMock.startQueueRehearsal).toHaveBeenCalledWith(expect.objectContaining({ - groupId: "queue-group-2", - autoResolve: true, - originSurface: "mission", - originMissionId: mission.id, - originRunId: runId, - })); - expect(fixture.missionService.get(mission.id)?.status).not.toBe("completed"); - - service.dispose(); - } finally { - fixture.dispose(); - } - }); - it("watchdog detects stalled attempt with no session output and emits warning event", async () => { const fixture = await createFixture({ aiIntegrationService: createStagnationRecoveryAiIntegrationService() diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index edbc92fa..b229364e 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -116,7 +116,6 @@ import type { createAgentChatService } from "../chat/agentChatService"; import type { createPrService } from "../prs/prService"; import type { createConflictService } from "../conflicts/conflictService"; import type { createQueueLandingService } from "../prs/queueLandingService"; -import type { createQueueRehearsalService } from "../prs/queueRehearsalService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { buildComputerUseOwnerSnapshot, @@ -765,7 +764,6 @@ export function createAiOrchestratorService(args: { prService?: ReturnType | null; conflictService?: ReturnType | null; queueLandingService?: ReturnType | null; - queueRehearsalService?: ReturnType | null; missionBudgetService?: import("./missionBudgetService").MissionBudgetService | null; humanWorkDigestService?: import("../memory/humanWorkDigestService").HumanWorkDigestService | null; missionMemoryLifecycleService?: import("../memory/missionMemoryLifecycleService").MissionMemoryLifecycleService | null; @@ -787,7 +785,6 @@ export function createAiOrchestratorService(args: { prService, conflictService, queueLandingService, - queueRehearsalService, missionBudgetService, humanWorkDigestService, missionMemoryLifecycleService, @@ -2517,7 +2514,6 @@ Check all worker statuses and continue managing the mission from here. Read work autoRebase: null, ciGating: null, autoLand: null, - rehearseQueue: null, autoResolveConflicts: null, archiveLaneOnLand: null, mergeMethod: null, @@ -2554,7 +2550,6 @@ Check all worker statuses and continue managing the mission from here. Read work }; } const autoLand = strategy.autoLand ?? false; - const rehearseQueue = strategy.rehearseQueue ?? false; const autoResolveConflicts = strategy.autoResolveConflicts ?? false; return { kind: "queue", @@ -2564,17 +2559,12 @@ Check all worker statuses and continue managing the mission from here. Read work autoRebase: strategy.autoRebase ?? true, ciGating: strategy.ciGating ?? false, autoLand, - rehearseQueue, autoResolveConflicts, archiveLaneOnLand: strategy.archiveLaneOnLand ?? false, mergeMethod: strategy.mergeMethod ?? "squash", conflictResolverModel: strategy.conflictResolverModel ?? null, reasoningEffort: strategy.reasoningEffort ?? null, - description: rehearseQueue - ? autoResolveConflicts - ? "Create queue PRs, rehearse the entire queue on an isolated scratch lane, and use the shared AI resolver before the mission is considered complete." - : "Create queue PRs and rehearse the entire queue on an isolated scratch lane before the mission is considered complete." - : autoLand + description: autoLand ? autoResolveConflicts ? "Create queue PRs, auto-resolve merge conflicts, and land the queue before the mission is considered complete." : "Create queue PRs and land the queue before the mission is considered complete." @@ -2836,8 +2826,6 @@ Check all worker statuses and continue managing the mission from here. Read work integrationLaneId: state.integrationLaneId ?? previous?.integrationLaneId ?? null, queueGroupId: state.queueGroupId ?? previous?.queueGroupId ?? null, queueId: state.queueId ?? previous?.queueId ?? null, - queueRehearsalId: state.queueRehearsalId ?? previous?.queueRehearsalId ?? null, - scratchLaneId: state.scratchLaneId ?? previous?.scratchLaneId ?? null, activePrId: state.activePrId ?? previous?.activePrId ?? null, waitReason: state.waitReason ?? previous?.waitReason ?? null, proposalUrl: state.proposalUrl ?? previous?.proposalUrl ?? null, @@ -3040,91 +3028,6 @@ Check all worker statuses and continue managing the mission from here. Read work } }; - const onQueueRehearsalStateChanged = async (rehearsalState: import("../../../shared/types").QueueRehearsalState): Promise => { - const runId = rehearsalState.config.originRunId ?? null; - const missionId = rehearsalState.config.originMissionId ?? (runId ? getMissionIdForRun(runId) : null); - if (!runId || !missionId) return; - - const mission = missionService.get(missionId); - if (!mission) return; - - let graph: OrchestratorRunGraph; - try { - graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - } catch { - return; - } - - const prUrls = rehearsalState.entries - .map((entry) => entry.githubUrl ?? null) - .filter((value): value is string => Boolean(value)); - - let status: MissionFinalizationState["status"] = "rehearsing_queue"; - let blocked = false; - let blockedReason: string | null = null; - let mergeReadiness: string | null = null; - let contractSatisfied = false; - let summary = "Queue rehearsal is still running."; - let detail = rehearsalState.lastError ?? null; - - if (rehearsalState.state === "completed") { - status = "completed"; - contractSatisfied = true; - mergeReadiness = "queue_rehearsed"; - const resolvedCount = rehearsalState.entries.filter((entry) => entry.state === "resolved").length; - summary = resolvedCount > 0 - ? "Execution finished and the full queue rehearsal completed with AI-assisted conflict fixes." - : "Execution finished and the full queue rehearsal completed cleanly."; - detail = `Rehearsed ${rehearsalState.entries.length} queue PR(s) on scratch lane ${rehearsalState.scratchLaneId ?? "unknown"}.`; - } else if (rehearsalState.state === "running") { - status = rehearsalState.activeResolverRunId ? "resolving_queue_conflicts" : "rehearsing_queue"; - summary = rehearsalState.activeResolverRunId - ? "Queue rehearsal is resolving simulated conflicts before it can continue." - : "Queue rehearsal is simulating queue landing on an isolated scratch lane."; - } else if (rehearsalState.state === "paused") { - status = "finalizing"; - blocked = true; - blockedReason = rehearsalState.lastError ?? "Queue rehearsal paused and needs operator intervention."; - summary = "Queue rehearsal is paused pending operator intervention."; - } else if (rehearsalState.state === "cancelled" || rehearsalState.state === "failed") { - status = "finalization_failed"; - blocked = true; - blockedReason = rehearsalState.lastError ?? "Queue rehearsal failed."; - summary = rehearsalState.state === "cancelled" - ? "Queue rehearsal was cancelled." - : "Queue rehearsal failed."; - } - - const finalization = await updateMissionFinalizationState(runId, { - policy: resolveMissionFinalizationPolicy((resolveActivePhaseSettings(missionId).settings.prStrategy ?? { kind: "manual" }) as PrStrategy), - status, - executionComplete: true, - contractSatisfied, - blocked, - blockedReason, - summary, - detail, - resolverJobId: rehearsalState.activeResolverRunId, - queueGroupId: rehearsalState.groupId, - queueRehearsalId: rehearsalState.rehearsalId, - scratchLaneId: rehearsalState.scratchLaneId, - activePrId: rehearsalState.activePrId, - prUrls, - mergeReadiness, - completedAt: contractSatisfied ? rehearsalState.completedAt : null, - warnings: [], - }, { graph }); - - if (finalization) { - await updateMissionCompletionFromStateDoc({ - runId, - graph, - mission, - finalization, - }); - } - }; - const resolveMissionStateStepPhase = (step: OrchestratorStep): string => { const stepMeta = isRecord(step.metadata) ? step.metadata : {}; const phaseName = typeof stepMeta.phaseName === "string" ? stepMeta.phaseName.trim() : ""; @@ -7038,7 +6941,6 @@ Check all worker statuses and continue managing the mission from here. Read work const laneIdArray = laneIdArrayBase; const targetBranch = prStrategy.targetBranch ?? missionBaseBranch ?? "main"; const autoLandQueue = prStrategy.autoLand ?? false; - const rehearseQueue = prStrategy.rehearseQueue ?? false; const autoResolveQueueConflicts = prStrategy.autoResolveConflicts ?? false; const queueMergeMethod = prStrategy.mergeMethod ?? "squash"; const resolverModelId = prStrategy.conflictResolverModel ?? integrationPrPolicy.conflictResolverModel ?? null; @@ -7086,34 +6988,6 @@ Check all worker statuses and continue managing the mission from here. Read work detail: queueResult.errors.map((entry) => `${entry.laneId}: ${entry.error}`).join("\n"), warnings: [], }, { graph }); - } else if (rehearseQueue && queueRehearsalService) { - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "rehearsing_queue", - executionComplete: true, - contractSatisfied: false, - blocked: false, - queueGroupId: queueResult.groupId, - prUrls: queueResult.prs.map((entry) => entry.githubUrl), - mergeReadiness: "queue_rehearsing", - summary: "Execution finished. Queue rehearsal has started and must complete before the mission can close out.", - detail: `Queue group ${queueResult.groupId} created with ${queueResult.prs.length} PR(s).`, - warnings: [], - }, { graph }); - await queueRehearsalService.startQueueRehearsal({ - groupId: queueResult.groupId, - method: queueMergeMethod, - autoResolve: autoResolveQueueConflicts, - resolverProvider, - resolverModel: resolverModelId, - reasoningEffort: prStrategy.reasoningEffort ?? null, - permissionMode: prStrategy.permissionMode ?? "guarded_edit", - preserveScratchLane: true, - originSurface: "mission", - originMissionId: mission.id, - originRunId: runId, - originLabel: mission.title, - }); } else if (autoLandQueue && queueLandingService) { await updateMissionFinalizationState(runId, { policy: finalizationPolicy, @@ -10138,7 +10012,6 @@ Check all worker statuses and continue managing the mission from here. Read work cancelRunGracefully, cleanupTeamResources, onQueueLandingStateChanged, - onQueueRehearsalStateChanged, onOrchestratorRuntimeEvent(event: OrchestratorRuntimeEvent) { if (disposed) return; diff --git a/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts b/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts index f9298763..8af7371c 100644 --- a/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts +++ b/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts @@ -215,7 +215,6 @@ function normalizeFinalizationPolicy(value: unknown): MissionFinalizationPolicy autoRebase: nullableBool(raw.autoRebase), ciGating: nullableBool(raw.ciGating), autoLand: nullableBool(raw.autoLand), - rehearseQueue: nullableBool(raw.rehearseQueue), autoResolveConflicts: nullableBool(raw.autoResolveConflicts), archiveLaneOnLand: nullableBool(raw.archiveLaneOnLand), mergeMethod: @@ -234,7 +233,7 @@ function normalizeFinalizationState(value: unknown): MissionFinalizationState | const policy = normalizeFinalizationPolicy(raw.policy); if (!policy) return null; const VALID_FINALIZATION_STATUSES = new Set([ - "idle", "finalizing", "creating_pr", "rehearsing_queue", "landing_queue", + "idle", "finalizing", "creating_pr", "landing_queue", "resolving_integration_conflicts", "resolving_queue_conflicts", "waiting_for_green", "awaiting_operator_review", "posting_review_comment", "finalization_failed", "completed", ]); @@ -258,8 +257,6 @@ function normalizeFinalizationState(value: unknown): MissionFinalizationState | integrationLaneId: nullableString(raw.integrationLaneId), queueGroupId: nullableString(raw.queueGroupId), queueId: nullableString(raw.queueId), - queueRehearsalId: nullableString(raw.queueRehearsalId), - scratchLaneId: nullableString(raw.scratchLaneId), activePrId: nullableString(raw.activePrId), waitReason, proposalUrl: nullableString(raw.proposalUrl), diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts new file mode 100644 index 00000000..0d735213 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts @@ -0,0 +1,366 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { LaneSummary, PrActionRun, PrCheck, PrDetail, PrFile, PrReviewThread, PrSummary } from "../../../shared/types"; +import { buildPrIssueResolutionPrompt, launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "./prIssueResolver"; + +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "feature/pr-80", + description: "Tighten the PR workflow lane.", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/pr-80", + worktreePath: overrides.worktreePath ?? fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-issue-lane-")), + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-23T12:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +function makePr(overrides: Partial = {}): PrSummary { + return { + id: "pr-80", + laneId: "lane-1", + projectId: "project-1", + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 80, + githubUrl: "https://github.com/ade-dev/ade/pull/80", + githubNodeId: "PR_kwDOExample", + title: "Stabilize GitHub PR flows", + state: "open", + baseBranch: "main", + headBranch: "feature/pr-80", + checksStatus: "failing", + reviewStatus: "changes_requested", + additions: 25, + deletions: 8, + lastSyncedAt: "2026-03-23T12:00:00.000Z", + createdAt: "2026-03-23T11:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + ...overrides, + }; +} + +function makeDetail(overrides: Partial = {}): PrDetail { + return { + prId: "pr-80", + body: "This PR makes the GitHub PR detail view more reliable.", + labels: [], + assignees: [], + requestedReviewers: [], + author: { login: "octocat", avatarUrl: null }, + isDraft: false, + milestone: null, + linkedIssues: [], + ...overrides, + }; +} + +describe("buildPrIssueResolutionPrompt", () => { + it("includes scope, issue inventory, extra instructions, and regression guidance", () => { + const prompt = buildPrIssueResolutionPrompt({ + pr: makePr(), + lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), + detail: makeDetail(), + files: [ + { filename: "src/prs.ts", status: "modified", additions: 10, deletions: 2, patch: null, previousFilename: null }, + ], + checks: [ + { name: "ci / unit", status: "completed", conclusion: "failure", detailsUrl: "https://example.com/check", startedAt: null, completedAt: null }, + ], + actionRuns: [ + { + id: 71, + name: "CI", + status: "completed", + conclusion: "failure", + headSha: "abc123", + htmlUrl: "https://example.com/run/71", + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:10:00.000Z", + jobs: [ + { + id: 81, + name: "test", + status: "completed", + conclusion: "failure", + startedAt: null, + completedAt: null, + steps: [ + { name: "vitest", status: "completed", conclusion: "failure", number: 1, startedAt: null, completedAt: null }, + ], + }, + ], + } satisfies PrActionRun, + ], + reviewThreads: [ + { + id: "thread-1", + isResolved: false, + isOutdated: false, + path: "src/prs.ts", + line: 42, + originalLine: 42, + startLine: null, + originalStartLine: null, + diffSide: "RIGHT", + url: "https://example.com/thread/1", + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:05:00.000Z", + comments: [ + { + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Please handle the loading state here.", + url: "https://example.com/comment/1", + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + ], + } satisfies PrReviewThread, + ], + issueComments: [ + { + id: "issue-comment-1", + author: "coderabbitai[bot]", + authorAvatarUrl: null, + body: "Consider simplifying this branch.", + source: "issue", + url: "https://example.com/issue-comment/1", + path: null, + line: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + ], + scope: "both", + additionalInstructions: "Please keep the PR description accurate if behavior changes.", + recentCommits: [{ sha: "abcdef123456", subject: "Refine PR detail header" }], + }); + + expect(prompt).toContain("Selected scope: checks and review comments"); + expect(prompt).toContain("ADE PR id (for ADE tools): pr-80"); + expect(prompt).toContain("Please keep the PR description accurate if behavior changes."); + expect(prompt).toContain("Watch carefully for regressions caused by your fixes."); + expect(prompt).toContain("update the test"); + expect(prompt).toContain("rerun the complete failing test files or suites locally"); + expect(prompt).toContain("prRefreshIssueInventory"); + expect(prompt).toContain("thread-1"); + expect(prompt).toContain("ci / unit"); + }); + + it("compresses review-thread bodies into references and filters noisy advisory comments", () => { + const prompt = buildPrIssueResolutionPrompt({ + pr: makePr({ title: "fix codex chat" }), + lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), + detail: makeDetail({ + body: "\n## Summary by CodeRabbit\nHuge autogenerated summary", + }), + files: [], + checks: [], + actionRuns: [], + reviewThreads: [ + { + id: "thread-1", + isResolved: false, + isOutdated: false, + path: "apps/desktop/src/renderer/components/chat/AgentChatPane.tsx", + line: 551, + originalLine: 551, + startLine: null, + originalStartLine: null, + diffSide: "RIGHT", + url: "https://example.com/thread/1", + createdAt: null, + updatedAt: null, + comments: [ + { + id: "comment-1", + author: "coderabbitai", + authorAvatarUrl: null, + body: "_⚠️ Potential issue_ | _🟡 Minor_\n\n**Derive `assistantLabel` from the effective provider.**\n\nThis can drift from the model that will actually run.\n\n
PromptVery long autogenerated block
", + url: "https://example.com/comment/1", + createdAt: null, + updatedAt: null, + }, + ], + } satisfies PrReviewThread, + ], + issueComments: [ + { + id: "issue-comment-1", + author: "coderabbitai[bot]", + authorAvatarUrl: null, + body: " giant summary", + source: "issue", + url: "https://example.com/issue-comment/1", + path: null, + line: null, + createdAt: null, + updatedAt: null, + }, + { + id: "issue-comment-2", + author: "vercel[bot]", + authorAvatarUrl: null, + body: "[vc]: deployment details", + source: "issue", + url: "https://example.com/issue-comment/2", + path: null, + line: null, + createdAt: null, + updatedAt: null, + }, + ], + scope: "comments", + additionalInstructions: null, + recentCommits: [], + }); + + expect(prompt).toContain("Changed test files / likely hotspots"); + expect(prompt).toContain("No changed test files detected in this PR."); + expect(prompt).toContain("Current unresolved review threads (summaries + references)"); + expect(prompt).toContain("Summary: Derive assistantLabel from the effective provider."); + expect(prompt).toContain("Reference: https://example.com/thread/1"); + expect(prompt).not.toContain("Very long autogenerated block"); + expect(prompt).not.toContain("giant summary"); + expect(prompt).not.toContain("deployment details"); + expect(prompt).not.toContain("Huge autogenerated summary"); + expect(prompt).toContain("If you are running outside ADE, use the linked GitHub thread/check URLs"); + }); + + it("highlights changed test files as likely hotspots", () => { + const prompt = buildPrIssueResolutionPrompt({ + pr: makePr(), + lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), + detail: makeDetail(), + files: [ + { filename: "apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx", status: "modified", additions: 525, deletions: 8, patch: null, previousFilename: null }, + { filename: "apps/desktop/src/main/services/chat/chatTextBatching.test.ts", status: "added", additions: 113, deletions: 0, patch: null, previousFilename: null }, + { filename: "apps/desktop/src/renderer/components/chat/AgentChatPane.tsx", status: "modified", additions: 10, deletions: 2, patch: null, previousFilename: null }, + ], + checks: [], + actionRuns: [], + reviewThreads: [], + issueComments: [], + scope: "checks", + additionalInstructions: null, + recentCommits: [], + }); + + expect(prompt).toContain("Changed test files / likely hotspots"); + expect(prompt).toContain("heavily modified test file: apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx (+525/-8)"); + expect(prompt).toContain("new test file: apps/desktop/src/main/services/chat/chatTextBatching.test.ts (+113/-0)"); + expect(prompt).toContain("Treat newly added or heavily modified test files as likely regression hotspots"); + }); +}); + +describe("launchPrIssueResolutionChat", () => { + const failingCheck: PrCheck = { name: "ci / unit", status: "completed", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null }; + + function makeDeps(overrides: { checks?: PrCheck[] } = {}) { + const lane = makeLane(); + const pr = makePr(); + const createSession = vi.fn(async () => ({ id: "session-1" })); + const sendMessage = vi.fn(async () => undefined); + const updateMeta = vi.fn(); + + const deps = { + prService: { + listAll: () => [pr], + getDetail: vi.fn(async () => makeDetail()), + getFiles: vi.fn(async () => [] as PrFile[]), + getChecks: vi.fn(async () => overrides.checks ?? [failingCheck]), + getActionRuns: vi.fn(async () => [] as PrActionRun[]), + getReviewThreads: vi.fn(async () => [] as PrReviewThread[]), + getComments: vi.fn(async () => []), + } as any, + laneService: { + list: vi.fn(async () => [lane]), + getLaneBaseAndBranch: vi.fn(() => ({ baseRef: "main", branchRef: "feature/pr-80", worktreePath: lane.worktreePath, laneType: "worktree" })), + }, + agentChatService: { createSession, sendMessage }, + sessionService: { updateMeta }, + }; + + return { lane, pr, deps, createSession, sendMessage, updateMeta }; + } + + it("previews the exact first prompt without creating a chat session", async () => { + const { deps, createSession, sendMessage, pr } = makeDeps(); + + const result = await previewPrIssueResolutionPrompt(deps as any, { + prId: pr.id, + scope: "checks", + modelId: "openai/gpt-5.4-codex", + reasoning: "high", + permissionMode: "guarded_edit", + additionalInstructions: "Keep commits tight and rerun focused tests first.", + }); + + expect(result.title).toBe("Resolve PR #80 issues"); + expect(result.prompt).toContain("Keep commits tight and rerun focused tests first."); + expect(createSession).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("creates a normal work chat session and sends the composed prompt", async () => { + const { lane, pr, deps, createSession, sendMessage, updateMeta } = makeDeps(); + + const result = await launchPrIssueResolutionChat(deps as any, { + prId: pr.id, + scope: "checks", + modelId: "openai/gpt-5.4-codex", + reasoning: "high", + permissionMode: "guarded_edit", + additionalInstructions: "Run focused tests before full CI.", + }); + + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + laneId: lane.id, + provider: "unified", + modelId: "openai/gpt-5.4-codex", + surface: "work", + sessionProfile: "workflow", + permissionMode: "edit", + })); + expect(updateMeta).toHaveBeenCalledWith({ sessionId: "session-1", title: "Resolve PR #80 issues" }); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "session-1", + displayText: "Resolve PR #80 issues", + text: expect.stringContaining("Run focused tests before full CI."), + })); + expect(result).toEqual({ + sessionId: "session-1", + laneId: lane.id, + href: `/work?laneId=${encodeURIComponent(lane.id)}&sessionId=session-1`, + }); + }); + + it("rejects checks scope while checks are still running", async () => { + const runningCheck: PrCheck = { name: "ci / unit", status: "in_progress", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null }; + const { pr, deps } = makeDeps({ checks: [runningCheck] }); + + await expect(launchPrIssueResolutionChat(deps as any, { + prId: pr.id, + scope: "checks", + modelId: "openai/gpt-5.4-codex", + })).rejects.toThrow("Failing checks are not currently actionable"); + }); +}); diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.ts b/apps/desktop/src/main/services/prs/prIssueResolver.ts new file mode 100644 index 00000000..4565350f --- /dev/null +++ b/apps/desktop/src/main/services/prs/prIssueResolver.ts @@ -0,0 +1,455 @@ +import fs from "node:fs"; +import { getModelById } from "../../../shared/modelRegistry"; +import type { + AgentChatPermissionMode, + LaneSummary, + PrActionRun, + PrCheck, + PrComment, + PrDetail, + PrFile, + PrIssueResolutionPromptPreviewArgs, + PrIssueResolutionPromptPreviewResult, + PrIssueResolutionScope, + PrIssueResolutionStartArgs, + PrIssueResolutionStartResult, + PrReviewThread, + PrSummary, +} from "../../../shared/types"; +import { getPrIssueResolutionAvailability } from "../../../shared/prIssueResolution"; +import { runGit } from "../git/git"; +import type { createLaneService } from "../lanes/laneService"; +import type { createPrService } from "./prService"; +import type { createAgentChatService } from "../chat/agentChatService"; +import type { createSessionService } from "../sessions/sessionService"; + +type IssueResolutionPromptArgs = { + pr: PrSummary; + lane: LaneSummary; + detail: PrDetail | null; + files: PrFile[]; + checks: PrCheck[]; + actionRuns: PrActionRun[]; + reviewThreads: PrReviewThread[]; + issueComments: PrComment[]; + scope: PrIssueResolutionScope; + additionalInstructions: string | null; + recentCommits: Array<{ sha: string; subject: string }>; +}; + +export type PrIssueResolutionLaunchDeps = { + prService: ReturnType; + laneService: Pick, "list" | "getLaneBaseAndBranch">; + agentChatService: Pick, "createSession" | "sendMessage">; + sessionService: Pick, "updateMeta">; +}; + +type PreparedIssueResolutionPrompt = { + pr: PrSummary; + lane: LaneSummary; + prompt: string; + title: string; +}; + +function mapIssueResolverPermissionMode(mode: PrIssueResolutionStartArgs["permissionMode"]): AgentChatPermissionMode { + if (mode === "full_edit") return "full-auto"; + if (mode === "read_only") return "plan"; + return "edit"; +} + +function truncateText(value: string, max: number): string { + const normalized = value.trim(); + if (normalized.length <= max) return normalized; + return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`; +} + +function stripMarkupNoise(value: string): string { + return value + .replace(//g, " ") + .replace(/
[\s\S]*?<\/details>/gi, " ") + .replace(/[\s\S]*?<\/summary>/gi, " ") + .replace(/```[\s\S]*?```/g, " ") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/[_*#>|]/g, " ") + .replace(/\|/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function extractSeverity(value: string): string | null { + const match = value.match(/\b(Critical|Major|Minor)\b/i); + return match?.[1] ? match[1][0].toUpperCase() + match[1].slice(1).toLowerCase() : null; +} + +function extractHighlightedTitle(value: string): string | null { + const match = value.match(/\*\*([^*]+)\*\*/); + return match?.[1] ? stripMarkupNoise(match[1]) : null; +} + +function summarizeThreadCommentBody(value: string | null | undefined): { severity: string | null; summary: string } { + const raw = (value ?? "").trim(); + if (!raw) { + return { + severity: null, + summary: "Review the linked thread for the full comment context.", + }; + } + + const severity = extractSeverity(raw); + const title = extractHighlightedTitle(raw); + let summary = stripMarkupNoise(raw) + .replace(/\bPotential issue\b/gi, " ") + .replace(/\b(Critical|Major|Minor)\b/gi, " ") + .replace(/\bAlso applies to:.*$/i, " ") + .replace(/\bVerify each finding against.*$/i, " ") + .replace(/\bThis is an auto-generated comment by CodeRabbit\b/gi, " ") + .trim(); + + if (title) { + summary = summary.replace(title, " ").trim(); + } + summary = truncateText(summary, 180); + + const compact = title && summary.length > 0 + ? `${title} — ${summary}` + : title || summary; + + return { + severity, + summary: compact || "Review the linked thread for the full comment context.", + }; +} + +function summarizePrBody(value: string | null | undefined): string | null { + const raw = (value ?? "").trim(); + if (!raw) return null; + const withoutBotSummary = raw + .replace(/## Summary by CodeRabbit[\s\S]*$/i, " ") + .replace(/summary by coderabbit[\s\S]*$/i, " "); + const plain = stripMarkupNoise(withoutBotSummary); + if (!plain) return null; + return truncateText(plain, 420); +} + +const NOISY_BOT_AUTHORS = new Set(["vercel", "vercel[bot]", "mintlify", "mintlify[bot]"]); + +const NOISY_BODY_PATTERNS = [ + /\[vc\]:/i, + /mintlify-preview-comment/i, + /this is an auto-generated comment: summarize by coderabbit/i, + /this is an auto-generated comment: release notes by coderabbit/i, + /pre-merge checks/i, + /thanks for using \[coderabbit\]/i, + //i, +]; + +function isNoisyIssueComment(comment: PrComment): boolean { + const author = comment.author.trim().toLowerCase(); + const body = (comment.body ?? "").trim(); + if (!body) return true; + if (NOISY_BOT_AUTHORS.has(author)) return true; + return NOISY_BODY_PATTERNS.some((pattern) => pattern.test(body)); +} + +function formatChecksSummary(checks: PrCheck[], actionRuns: PrActionRun[]): string { + const failingChecks = checks.filter((check) => check.conclusion === "failure"); + if (failingChecks.length === 0) return "- No actionable failing checks."; + + const lines: string[] = []; + for (const check of failingChecks) { + lines.push(`- ${check.name} (${check.conclusion ?? check.status})${check.detailsUrl ? ` — ${check.detailsUrl}` : ""}`); + } + + const failingRuns = actionRuns.filter((run) => + run.conclusion === "failure" || run.conclusion === "timed_out" || run.conclusion === "action_required", + ); + for (const run of failingRuns.slice(0, 8)) { + const failingJobs = run.jobs.filter((job) => job.conclusion === "failure" || job.status === "in_progress"); + const jobBits = failingJobs.map((job) => { + const failingSteps = job.steps + .filter((step) => step.conclusion === "failure" || step.status === "in_progress") + .map((step) => step.name); + if (failingSteps.length > 0) { + return `${job.name} [steps: ${failingSteps.join(", ")}]`; + } + return job.name; + }); + lines.push( + `- Workflow ${run.name} run #${run.id}: ${run.conclusion ?? run.status}${jobBits.length > 0 ? ` — jobs: ${jobBits.join("; ")}` : ""}${ + run.htmlUrl ? ` — ${run.htmlUrl}` : "" + }`, + ); + } + + return lines.join("\n"); +} + +function formatReviewThreadsSummary(reviewThreads: PrReviewThread[]): string { + const actionableThreads = reviewThreads.filter((thread) => !thread.isResolved && !thread.isOutdated); + if (actionableThreads.length === 0) return "- No actionable unresolved review threads."; + + return actionableThreads.map((thread, index) => { + const primaryComment = thread.comments[0] ?? null; + const commentSummary = summarizeThreadCommentBody(primaryComment?.body); + const location = thread.path + ? `${thread.path}${thread.line != null ? `:${thread.line}` : ""}` + : "unknown location"; + const severityPrefix = commentSummary.severity ? `[${commentSummary.severity}] ` : ""; + const authorSuffix = primaryComment?.author ? ` | author: ${primaryComment.author}` : ""; + return `${index + 1}. ${severityPrefix}Thread ${thread.id} at ${location}${authorSuffix}\n Summary: ${commentSummary.summary}\n Reference: ${thread.url ?? primaryComment?.url ?? "(no URL available)"}`; + }).join("\n"); +} + +function formatIssueCommentsSummary(issueComments: PrComment[]): string { + const advisory = issueComments + .filter((comment) => comment.source === "issue") + .filter((comment) => !isNoisyIssueComment(comment)) + .slice(0, 5); + if (advisory.length === 0) return "- No advisory top-level issue comments."; + return advisory + .map((comment, index) => { + const body = truncateText(stripMarkupNoise(comment.body ?? ""), 220); + return `${index + 1}. ${comment.author}${comment.url ? ` — ${comment.url}` : ""}\n ${body}`; + }) + .join("\n"); +} + +function formatChangedFilesSummary(files: PrFile[]): string { + if (files.length === 0) return "- No changed files reported."; + return files + .slice(0, 40) + .map((file) => `- ${file.status} ${file.filename} (+${file.additions}/-${file.deletions})`) + .join("\n"); +} + +function isTestFilePath(filename: string): boolean { + return /(^|\/)__tests__(\/|$)/.test(filename) + || /(^|\/)__mocks__(\/|$)/.test(filename) + || /(^|\/)[^/]+\.(test|spec)\.[^/]+$/.test(filename); +} + +function formatChangedTestFilesSummary(files: PrFile[]): string { + const changedTests = files + .filter((file) => isTestFilePath(file.filename)) + .map((file) => { + const totalChanges = file.additions + file.deletions; + let emphasis = "changed test file"; + if (file.status === "added") { + emphasis = "new test file"; + } else if (totalChanges >= 80 || file.additions >= 40) { + emphasis = "heavily modified test file"; + } + return `- ${emphasis}: ${file.filename} (+${file.additions}/-${file.deletions})`; + }); + + if (changedTests.length === 0) return "- No changed test files detected in this PR."; + return changedTests.slice(0, 16).join("\n"); +} + +function formatRecentCommitsSummary(recentCommits: Array<{ sha: string; subject: string }>): string { + if (recentCommits.length === 0) return "- No recent commits found in the lane worktree."; + return recentCommits.map((commit) => `- ${commit.sha.slice(0, 7)} ${commit.subject}`).join("\n"); +} + +function buildSelectedScopeDescription(scope: PrIssueResolutionScope): string { + if (scope === "both") return "checks and review comments"; + if (scope === "comments") return "review comments"; + return "checks"; +} + +export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): string { + const actionableThreads = args.reviewThreads.filter((thread) => !thread.isResolved && !thread.isOutdated); + const availability = getPrIssueResolutionAvailability(args.checks, args.reviewThreads); + const prBodySummary = summarizePrBody(args.detail?.body); + const purposeBits = [ + args.pr.title.trim(), + prBodySummary ? `PR body summary:\n${prBodySummary}` : null, + args.lane.description?.trim() ? `Lane description:\n${args.lane.description.trim()}` : null, + ].filter(Boolean); + + const scopeLabel = buildSelectedScopeDescription(args.scope); + const promptSections = [ + "You are resolving issues on an existing GitHub pull request inside ADE.", + "Address every valid issue in the selected scope without asking the user to enumerate them again.", + "The issue references below are intentionally compact summaries. Use the linked GitHub thread/check URLs or refresh the issue inventory when you need full detail.", + "", + "PR context", + `- ADE PR id (for ADE tools): ${args.pr.id}`, + `- GitHub PR: #${args.pr.githubPrNumber} — ${args.pr.githubUrl}`, + `- Title: ${args.pr.title}`, + `- Base -> head: ${args.pr.baseBranch} -> ${args.pr.headBranch}`, + `- Lane: ${args.lane.name}`, + `- Worktree: ${args.lane.worktreePath}`, + `- Selected scope: ${scopeLabel}`, + `- Actionable failing checks: ${availability.hasActionableChecks ? availability.failingCheckCount : 0}`, + `- Actionable unresolved review threads: ${actionableThreads.length}`, + "", + "Purpose / intent", + purposeBits.length > 0 ? purposeBits.join("\n\n") : "- No additional PR purpose text was available.", + "", + "Recent commits", + formatRecentCommitsSummary(args.recentCommits), + "", + "Changed files", + formatChangedFilesSummary(args.files), + "", + "Changed test files / likely hotspots", + formatChangedTestFilesSummary(args.files), + "", + "Current failing checks", + formatChecksSummary(args.checks, args.actionRuns), + "", + "Current unresolved review threads (summaries + references)", + formatReviewThreadsSummary(args.reviewThreads), + "", + "Advisory top-level issue comments (filtered)", + formatIssueCommentsSummary(args.issueComments), + "", + "Goal", + `Get the selected PR issue scope (${scopeLabel}) into a good state. The overall goal is to get all CI checks passing and all valid selected review issues handled.`, + "", + "Requirements", + "- Fix all valid issues in the selected scope, not just the first one.", + "- Start by refreshing the PR issue inventory if ADE tools are available, especially if CI or review state may have changed.", + "- Verify review comments before changing code. Some comments may be stale, incorrect, or already addressed.", + "- If you work on review comments, reply on the review thread when useful and resolve the thread only after the fix is truly in place or the thread is clearly outdated/invalid.", + "- If you are running inside ADE, use ADE-backed PR tools instead of assuming gh auth. The relevant tools include prRefreshIssueInventory, prRerunFailedChecks, prReplyToReviewThread, and prResolveReviewThread.", + "- If you are running outside ADE, use the linked GitHub thread/check URLs together with your local git and CI tooling.", + "- Use parallel agents when they will materially speed up independent fixes.", + "- After each set of changes, run the smallest relevant local validation first.", + "- Before you push, rerun the complete failing test files or suites locally, not just the specific failing test names. Test runners and sharded CI can hide additional failures behind the first error in a file.", + "- Treat newly added or heavily modified test files as likely regression hotspots, even if CI only surfaced a different failure first.", + "- Watch carefully for regressions caused by your fixes. If a change breaks an existing test because the expected behavior legitimately changed, update the test. Do not change tests just to mask a bug.", + "- Continue iterating until the selected issue set is cleared and CI is green, or stop only with a concrete blocker and explain it clearly.", + ]; + + const trimmedAdditionalInstructions = args.additionalInstructions?.trim() ?? ""; + if (trimmedAdditionalInstructions.length > 0) { + promptSections.push("", "Additional user instructions", trimmedAdditionalInstructions); + } + + return promptSections.join("\n"); +} + +async function readRecentCommits(worktreePath: string): Promise> { + const result = await runGit(["log", "--format=%H%x09%s", "-n", "8"], { cwd: worktreePath, timeoutMs: 10_000 }); + if (result.exitCode !== 0) return []; + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sha, ...subjectParts] = line.split("\t"); + return { + sha: (sha ?? "").trim(), + subject: subjectParts.join("\t").trim(), + }; + }) + .filter((entry) => entry.sha.length > 0 && entry.subject.length > 0); +} + +async function preparePrIssueResolutionPrompt( + deps: PrIssueResolutionLaunchDeps, + args: PrIssueResolutionStartArgs | PrIssueResolutionPromptPreviewArgs, +): Promise { + const pr = deps.prService.listAll().find((entry) => entry.id === args.prId) ?? null; + if (!pr) throw new Error(`PR not found: ${args.prId}`); + + const lanes = await deps.laneService.list({ includeArchived: false }); + const lane = lanes.find((entry) => entry.id === pr.laneId) ?? null; + if (!lane || lane.archivedAt) { + throw new Error("Resolve issues with agent is only available for linked PRs with a live local lane."); + } + if (!fs.existsSync(lane.worktreePath)) { + throw new Error(`Lane worktree is missing on disk: ${lane.worktreePath}`); + } + + const [detail, files, checks, actionRuns, reviewThreads, comments] = await Promise.all([ + deps.prService.getDetail(pr.id).catch(() => null), + deps.prService.getFiles(pr.id).catch(() => [] as PrFile[]), + deps.prService.getChecks(pr.id), + deps.prService.getActionRuns(pr.id).catch(() => [] as PrActionRun[]), + deps.prService.getReviewThreads(pr.id), + deps.prService.getComments(pr.id).catch(() => [] as PrComment[]), + ]); + + const availability = getPrIssueResolutionAvailability(checks, reviewThreads); + if (args.scope === "checks" && !availability.hasActionableChecks) { + throw new Error("Failing checks are not currently actionable. Checks must be finished running and still failing."); + } + if (args.scope === "comments" && !availability.hasActionableComments) { + throw new Error("There are no actionable unresolved review threads right now."); + } + if (args.scope === "both" && (!availability.hasActionableChecks || !availability.hasActionableComments)) { + throw new Error("Checks and comments are no longer both actionable. Refresh the PR and choose the currently available scope."); + } + + return { + pr, + lane, + prompt: buildPrIssueResolutionPrompt({ + pr, + lane, + detail, + files, + checks, + actionRuns, + reviewThreads, + issueComments: comments, + scope: args.scope, + additionalInstructions: args.additionalInstructions?.trim() || null, + recentCommits: await readRecentCommits(lane.worktreePath), + }), + title: `Resolve PR #${pr.githubPrNumber} issues`, + }; +} + +export async function previewPrIssueResolutionPrompt( + deps: PrIssueResolutionLaunchDeps, + args: PrIssueResolutionPromptPreviewArgs, +): Promise { + const prepared = await preparePrIssueResolutionPrompt(deps, args); + return { + title: prepared.title, + prompt: prepared.prompt, + }; +} + +export async function launchPrIssueResolutionChat( + deps: PrIssueResolutionLaunchDeps, + args: PrIssueResolutionStartArgs, +): Promise { + const descriptor = getModelById(args.modelId); + if (!descriptor) { + throw new Error(`Unknown model '${args.modelId}'.`); + } + const prepared = await preparePrIssueResolutionPrompt(deps, args); + const reasoningEffort = args.reasoning?.trim() || undefined; + + const session = await deps.agentChatService.createSession({ + laneId: prepared.lane.id, + provider: "unified", + model: descriptor.id, + modelId: descriptor.id, + ...(reasoningEffort ? { reasoningEffort } : {}), + permissionMode: mapIssueResolverPermissionMode(args.permissionMode), + surface: "work", + sessionProfile: "workflow", + }); + + deps.sessionService.updateMeta({ sessionId: session.id, title: prepared.title }); + + await deps.agentChatService.sendMessage({ + sessionId: session.id, + text: prepared.prompt, + displayText: prepared.title, + ...(reasoningEffort ? { reasoningEffort } : {}), + }); + + return { + sessionId: session.id, + laneId: prepared.lane.id, + href: `/work?laneId=${encodeURIComponent(prepared.lane.id)}&sessionId=${encodeURIComponent(session.id)}`, + }; +} diff --git a/apps/desktop/src/main/services/prs/prService.reviewThreads.test.ts b/apps/desktop/src/main/services/prs/prService.reviewThreads.test.ts new file mode 100644 index 00000000..03b70880 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prService.reviewThreads.test.ts @@ -0,0 +1,237 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { LaneSummary } from "../../../shared/types"; +import { openKvDb } from "../state/kvDb"; +import { createPrService } from "./prService"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as const; +} + +function makeLane(id: string, name: string, branchRef: string, overrides: Partial = {}): LaneSummary { + return { + id, + name, + description: null, + laneType: "worktree", + baseRef: "refs/heads/main", + branchRef, + worktreePath: `/tmp/${id}`, + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-23T00:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +async function seedProject(db: any, projectId: string, repoRoot: string) { + const now = "2026-03-23T00:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "ADE", "main", now, now], + ); +} + +async function seedLane(db: any, projectId: string, lane: LaneSummary) { + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + lane.id, + projectId, + lane.name, + lane.description, + lane.laneType, + lane.baseRef, + lane.branchRef, + lane.worktreePath, + lane.attachedRootPath, + lane.isEditProtected ? 1 : 0, + lane.parentLaneId, + lane.color, + lane.icon, + JSON.stringify(lane.tags), + "active", + lane.createdAt, + lane.archivedAt, + ], + ); +} + +async function seedPr(db: any, args: { + prId: string; + projectId: string; + laneId: string; + baseBranch: string; + headBranch: string; + title: string; +}) { + const now = "2026-03-23T00:00:00.000Z"; + db.run( + ` + insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + args.prId, + args.projectId, + args.laneId, + "arul28", + "ADE", + 80, + "https://github.com/arul28/ADE/pull/80", + null, + args.title, + "open", + args.baseBranch, + args.headBranch, + "failing", + "changes_requested", + 0, + 0, + now, + now, + now, + ], + ); +} + +describe("prService.getReviewThreads", () => { + it("fetches review threads without querying unsupported thread timestamps", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-review-threads-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + const projectId = "proj-review-threads"; + const lane = makeLane("lane-80", "feature/pr-80", "refs/heads/feature/pr-80", { worktreePath: root }); + + await seedProject(db, projectId, root); + await seedLane(db, projectId, lane); + await seedPr(db, { + prId: "pr-80", + projectId, + laneId: lane.id, + baseBranch: "main", + headBranch: "feature/pr-80", + title: "Fix PR review thread loading", + }); + + const apiRequest = vi.fn(async ({ path: requestPath, body }: { path: string; body?: { query?: string } }) => { + if (requestPath !== "/graphql") return { data: {} }; + const query = body?.query ?? ""; + expect(query.match(/\bcreatedAt\b/g)?.length ?? 0).toBe(1); + expect(query.match(/\bupdatedAt\b/g)?.length ?? 0).toBe(1); + return { + data: { + data: { + repository: { + pullRequest: { + reviewThreads: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: [ + { + id: "thread-1", + isResolved: false, + isOutdated: false, + path: "apps/desktop/src/main/services/prs/prService.ts", + line: 1097, + originalLine: 1097, + startLine: null, + originalStartLine: null, + diffSide: "RIGHT", + comments: { + nodes: [ + { + id: "comment-1", + body: "Please load CodeRabbit review threads correctly.", + url: "https://github.com/arul28/ADE/pull/80#discussion_r1", + createdAt: "2026-03-23T01:00:00.000Z", + updatedAt: "2026-03-23T01:05:00.000Z", + author: { + login: "coderabbitai", + avatarUrl: "https://example.com/avatar.png", + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + }, + }; + }); + + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { + list: async () => [lane], + } as any, + operationService: {} as any, + githubService: { apiRequest } as any, + aiIntegrationService: undefined, + projectConfigService: {} as any, + conflictService: undefined, + openExternal: async () => {}, + }); + + await expect(service.getReviewThreads("pr-80")).resolves.toEqual([ + { + id: "thread-1", + isResolved: false, + isOutdated: false, + path: "apps/desktop/src/main/services/prs/prService.ts", + line: 1097, + originalLine: 1097, + startLine: 0, + originalStartLine: 0, + diffSide: "RIGHT", + url: "https://github.com/arul28/ADE/pull/80#discussion_r1", + createdAt: "2026-03-23T01:00:00.000Z", + updatedAt: "2026-03-23T01:05:00.000Z", + comments: [ + { + id: "comment-1", + author: "coderabbitai", + authorAvatarUrl: "https://example.com/avatar.png", + body: "Please load CodeRabbit review threads correctly.", + url: "https://github.com/arul28/ADE/pull/80#discussion_r1", + createdAt: "2026-03-23T01:00:00.000Z", + updatedAt: "2026-03-23T01:05:00.000Z", + }, + ], + }, + ]); + expect(apiRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 5d4ab674..f7c26c07 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -52,6 +52,7 @@ import type { PrSummary, PrWithConflicts, QueueLandingState, + ReorderQueuePrsArgs, RecheckIntegrationStepArgs, RecheckIntegrationStepResult, SimulateIntegrationArgs, @@ -79,11 +80,16 @@ import type { PrActionStep, PrActivityEvent, PrLabel, - PrUser + PrUser, + PrReviewThread, + PrReviewThreadComment, + ReplyToPrReviewThreadArgs, + ResolvePrReviewThreadArgs, } from "../../../shared/types"; import type { AdeDb } from "../state/kvDb"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; +import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; import type { createOperationService } from "../history/operationService"; import type { createGithubService } from "../github/githubService"; import type { createProjectConfigService } from "../config/projectConfigService"; @@ -94,6 +100,7 @@ import { runGit, runGitMergeTree, runGitOrThrow } from "../git/git"; import { extractFirstJsonObject } from "../ai/utils"; import { buildIntegrationPreflight } from "./integrationPlanning"; import { hasMergeConflictMarkers, parseGitStatusPorcelain } from "./integrationValidation"; +import { fetchRemoteTrackingBranch } from "../shared/queueRebase"; import { asNumber, asString, normalizeBranchName, nowIso } from "../shared/utils"; type PullRequestRow = { @@ -216,61 +223,57 @@ async function readIntegrationLaneSnapshot(worktreePath: string): Promise>>>>>>[^\n]*)/g; + const markers: string[] = []; + const oursLines: string[] = []; + const theirsLines: string[] = []; + let match: RegExpExecArray | null; + while ((match = markerRegex.exec(content)) !== null) { + markers.push(match[0]); + oursLines.push(match[2]!.trim()); + theirsLines.push(match[4]!.trim()); + } + return { + conflictType: "content", + conflictMarkers: markers.join("\n---\n").slice(0, 2000), + oursExcerpt: oursLines.join("\n---\n").slice(0, 500) || null, + theirsExcerpt: theirsLines.join("\n---\n").slice(0, 500) || null, + diffHunk: markers.map((entry) => entry.split("\n").slice(0, 12).join("\n")).join("\n...\n").slice(0, 500) || null, + }; +} + +const EMPTY_CONFLICT_EXCERPTS: ConflictExcerpts = { + conflictType: null, + conflictMarkers: "", + oursExcerpt: null, + theirsExcerpt: null, + diffHunk: null, +}; + function readConflictFilePreviewFromWorktree(worktreePath: string, filePath: string): IntegrationProposalStep["conflictingFiles"][number] { const root = path.resolve(worktreePath); const absPath = path.resolve(root, filePath); if (absPath !== root && !absPath.startsWith(`${root}${path.sep}`)) { - return { - path: filePath, - conflictType: null, - conflictMarkers: "", - oursExcerpt: null, - theirsExcerpt: null, - diffHunk: null, - }; + return { path: filePath, ...EMPTY_CONFLICT_EXCERPTS }; } try { const content = fs.readFileSync(absPath, "utf8"); if (!hasMergeConflictMarkers(content)) { - return { - path: filePath, - conflictType: null, - conflictMarkers: "", - oursExcerpt: null, - theirsExcerpt: null, - diffHunk: null, - }; - } - - const markerRegex = /(<<<<<<<[^\n]*\n)([\s\S]*?)(=======\n)([\s\S]*?)(>>>>>>>[^\n]*)/g; - const markers: string[] = []; - const oursLines: string[] = []; - const theirsLines: string[] = []; - let match: RegExpExecArray | null; - while ((match = markerRegex.exec(content)) !== null) { - markers.push(match[0]); - oursLines.push(match[2]!.trim()); - theirsLines.push(match[4]!.trim()); + return { path: filePath, ...EMPTY_CONFLICT_EXCERPTS }; } - - return { - path: filePath, - conflictType: "content", - conflictMarkers: markers.join("\n---\n").slice(0, 2000), - oursExcerpt: oursLines.join("\n---\n").slice(0, 500) || null, - theirsExcerpt: theirsLines.join("\n---\n").slice(0, 500) || null, - diffHunk: markers.map((entry) => entry.split("\n").slice(0, 12).join("\n")).join("\n...\n").slice(0, 500) || null, - }; + return { path: filePath, ...parseConflictMarkers(content) }; } catch { - return { - path: filePath, - conflictType: null, - conflictMarkers: "", - oursExcerpt: null, - theirsExcerpt: null, - diffHunk: null, - }; + return { path: filePath, ...EMPTY_CONFLICT_EXCERPTS }; } } @@ -568,6 +571,7 @@ export function createPrService({ aiIntegrationService, projectConfigService, conflictService, + rebaseSuggestionService, openExternal }: { db: AdeDb; @@ -580,6 +584,7 @@ export function createPrService({ aiIntegrationService?: ReturnType; projectConfigService: ReturnType; conflictService?: ReturnType; + rebaseSuggestionService?: ReturnType | null; openExternal: (url: string) => Promise; }) { const PR_COLUMNS = `id, lane_id, project_id, repo_owner, repo_name, github_pr_number, @@ -834,6 +839,30 @@ export function createPrService({ return data; }; + const graphqlRequest = async (query: string, variables: Record): Promise => { + const { data: payload } = await githubService.apiRequest<{ + data?: T; + errors?: Array<{ message?: unknown }>; + }>({ + method: "POST", + path: "/graphql", + body: { query, variables }, + }); + + const errors = Array.isArray(payload?.errors) + ? payload.errors + .map((entry) => asString(entry?.message).trim()) + .filter(Boolean) + : []; + if (errors.length > 0) { + throw new Error(errors.join("; ")); + } + if (!payload || payload.data == null) { + throw new Error("GitHub GraphQL request returned no data."); + } + return payload.data; + }; + const fetchAllPages = async (args: { path: string; query?: Record; @@ -1043,6 +1072,116 @@ export function createPrService({ })); }; + const fetchReviewThreads = async (repo: GitHubRepoRef, prNumber: number): Promise => { + const threads: PrReviewThread[] = []; + let after: string | null = null; + + const query = ` + query AdePullRequestReviewThreads($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + isOutdated + path + line + originalLine + startLine + originalStartLine + diffSide + comments(first: 50) { + nodes { + id + body + url + createdAt + updatedAt + author { + login + avatarUrl + } + } + } + } + } + } + } + } + `; + + for (let page = 0; page < 10; page += 1) { + const data: { + repository?: { + pullRequest?: { + reviewThreads?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null; + nodes?: any[]; + } | null; + } | null; + } | null; + } = await graphqlRequest(query, { + owner: repo.owner, + name: repo.name, + number: prNumber, + after, + }); + + const reviewThreads: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null; + nodes?: any[]; + } | null | undefined = data.repository?.pullRequest?.reviewThreads; + const nodes = Array.isArray(reviewThreads?.nodes) ? reviewThreads.nodes : []; + for (const node of nodes) { + const comments: PrReviewThreadComment[] = Array.isArray(node?.comments?.nodes) + ? node.comments.nodes.map((entry: any) => ({ + id: asString(entry?.id) || String(randomUUID()), + author: asString(entry?.author?.login) || "unknown", + authorAvatarUrl: asString(entry?.author?.avatarUrl) || null, + body: asString(entry?.body) || null, + url: asString(entry?.url) || null, + createdAt: asString(entry?.createdAt) || null, + updatedAt: asString(entry?.updatedAt) || null, + })) + : []; + const latestComment = comments[comments.length - 1] ?? comments[0] ?? null; + const diffSideRaw = asString(node?.diffSide).trim().toUpperCase(); + threads.push({ + id: asString(node?.id) || String(randomUUID()), + isResolved: Boolean(node?.isResolved), + isOutdated: Boolean(node?.isOutdated), + path: asString(node?.path) || null, + line: Number.isFinite(Number(node?.line)) ? Number(node?.line) : null, + originalLine: Number.isFinite(Number(node?.originalLine)) ? Number(node?.originalLine) : null, + startLine: Number.isFinite(Number(node?.startLine)) ? Number(node?.startLine) : null, + originalStartLine: Number.isFinite(Number(node?.originalStartLine)) ? Number(node?.originalStartLine) : null, + diffSide: diffSideRaw === "LEFT" || diffSideRaw === "RIGHT" ? diffSideRaw : null, + url: latestComment?.url ?? null, + createdAt: asString(node?.createdAt) || latestComment?.createdAt || null, + updatedAt: asString(node?.updatedAt) || latestComment?.updatedAt || null, + comments, + }); + } + + const hasNextPage = Boolean(reviewThreads?.pageInfo?.hasNextPage); + const endCursor: string | null = asString(reviewThreads?.pageInfo?.endCursor) || null; + if (!hasNextPage || !endCursor) break; + after = endCursor; + } + + return threads.sort((a, b) => { + const aTs = a.createdAt ? Date.parse(a.createdAt) : Number.NaN; + const bTs = b.createdAt ? Date.parse(b.createdAt) : Number.NaN; + if (!Number.isNaN(aTs) && !Number.isNaN(bTs) && aTs !== bTs) return aTs - bTs; + return a.id.localeCompare(b.id); + }); + }; + const fetchCombinedStatus = async (repo: GitHubRepoRef, sha: string): Promise<{ state: string; statuses: Array<{ context: string; state: string; description: string | null; target_url: string | null; created_at: string | null; updated_at: string | null }>; @@ -1063,8 +1202,7 @@ export function createPrService({ path: `/repos/${repo.owner}/${repo.name}/commits/${sha}/check-runs`, query: { per_page: 100 } }); - const runs = Array.isArray(data?.check_runs) ? data.check_runs : []; - return runs; + return Array.isArray(data?.check_runs) ? data.check_runs : []; }; const fetchCompare = async (repo: GitHubRepoRef, baseSha: string, headSha: string): Promise<{ behindBy: number }> => { @@ -1211,20 +1349,14 @@ export function createPrService({ const name = asString(run?.name) || "check"; if (seen.has(name)) continue; seen.add(name); - const statusRaw = asString(run?.status).toLowerCase(); - const status: PrCheck["status"] = - statusRaw === "queued" ? "queued" : statusRaw === "in_progress" ? "in_progress" : "completed"; const conclusionRaw = asString(run?.conclusion).toLowerCase(); - let conclusion: PrCheck["conclusion"]; - if (conclusionRaw === "success") conclusion = "success"; - else if (conclusionRaw === "failure" || conclusionRaw === "timed_out" || conclusionRaw === "action_required") conclusion = "failure"; - else if (conclusionRaw === "neutral") conclusion = "neutral"; - else if (conclusionRaw === "skipped") conclusion = "skipped"; - else if (conclusionRaw === "cancelled") conclusion = "cancelled"; - else conclusion = null; + const conclusion: PrCheck["conclusion"] = + conclusionRaw === "failure" || conclusionRaw === "timed_out" || conclusionRaw === "action_required" + ? "failure" + : toJobConclusion(run?.conclusion); out.push({ name, - status, + status: toJobStatus(run?.status), conclusion, detailsUrl: asString(run?.details_url) || asString(run?.html_url) || null, startedAt: asString(run?.started_at) || null, @@ -1696,6 +1828,18 @@ export function createPrService({ } } + await fetchRemoteTrackingBranch({ + projectRoot, + targetBranch: row.base_branch, + }).catch((error) => { + logger.warn("prs.fetch_base_branch_failed", { + prId: row.id, + baseBranch: row.base_branch, + error: error instanceof Error ? error.message : String(error), + }); + }); + laneService.invalidateCache?.(); + operationService.finish({ operationId: op.operationId, status: "succeeded", @@ -1703,6 +1847,18 @@ export function createPrService({ }); await refreshOne(row.id).catch(() => {}); + await conflictService?.scanRebaseNeeds().catch((error) => { + logger.warn("prs.refresh_rebase_needs_failed", { + prId: row.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + await rebaseSuggestionService?.refresh().catch((error) => { + logger.warn("prs.refresh_rebase_suggestions_failed", { + prId: row.id, + error: error instanceof Error ? error.message : String(error), + }); + }); return { prId: row.id, @@ -2202,7 +2358,7 @@ export function createPrService({ treeOid: string, filePath: string, cwd: string - ): Promise<{ conflictType: "content" | null; conflictMarkers: string; oursExcerpt: string; theirsExcerpt: string; diffHunk: string }> => { + ): Promise => { try { const result = await runGit( ["show", `${treeOid}:${filePath}`], @@ -2210,31 +2366,11 @@ export function createPrService({ ); const content = result.stdout; if (!content.includes("<<<<<<<")) { - return { conflictType: null, conflictMarkers: "", oursExcerpt: "", theirsExcerpt: "", diffHunk: "" }; - } - - // Extract conflict markers and excerpts - const markerRegex = /(<<<<<<<[^\n]*\n)([\s\S]*?)(=======\n)([\s\S]*?)(>>>>>>>[^\n]*)/g; - const markers: string[] = []; - const oursLines: string[] = []; - const theirsLines: string[] = []; - let match: RegExpExecArray | null; - while ((match = markerRegex.exec(content)) !== null) { - markers.push(match[0]); - oursLines.push(match[2]!.trim()); - theirsLines.push(match[4]!.trim()); + return EMPTY_CONFLICT_EXCERPTS; } - - const conflictMarkers = markers.join("\n---\n").slice(0, 2000); - const oursExcerpt = oursLines.join("\n---\n").slice(0, 500); - const theirsExcerpt = theirsLines.join("\n---\n").slice(0, 500); - - // Build a simple diff hunk preview - const diffHunk = markers.map((m) => m.split("\n").slice(0, 12).join("\n")).join("\n...\n").slice(0, 500); - - return { conflictType: "content", conflictMarkers, oursExcerpt, theirsExcerpt, diffHunk }; + return parseConflictMarkers(content); } catch { - return { conflictType: null, conflictMarkers: "", oursExcerpt: "", theirsExcerpt: "", diffHunk: "" }; + return EMPTY_CONFLICT_EXCERPTS; } }; @@ -2590,13 +2726,12 @@ export function createPrService({ const conflictsWith = Array.from(conflictingPeersByLaneId.get(laneId) ?? []); conflictsWith.sort((a, b) => (laneOrder.get(a) ?? 0) - (laneOrder.get(b) ?? 0)); - const outcome: IntegrationLaneSummary["outcome"] = !laneSummary?.headSha - ? "blocked" - : blockedLaneIds.has(laneId) || sequentialBlockedLaneIds.has(laneId) - ? "blocked" - : conflictsWith.length > 0 || sequentialConflictLaneIds.has(laneId) - ? "conflict" - : "clean"; + let outcome: IntegrationLaneSummary["outcome"] = "clean"; + if (!laneSummary?.headSha || blockedLaneIds.has(laneId) || sequentialBlockedLaneIds.has(laneId)) { + outcome = "blocked"; + } else if (conflictsWith.length > 0 || sequentialConflictLaneIds.has(laneId)) { + outcome = "conflict"; + } return { laneId, @@ -2619,11 +2754,12 @@ export function createPrService({ diffStat: laneSummary.diffStat })); - const overallOutcome = laneSummaries.some((lane) => lane.outcome === "blocked") - ? "blocked" - : laneSummaries.some((lane) => lane.outcome === "conflict") - ? "conflict" - : "clean"; + let overallOutcome: IntegrationProposal["overallOutcome"] = "clean"; + if (laneSummaries.some((lane) => lane.outcome === "blocked")) { + overallOutcome = "blocked"; + } else if (laneSummaries.some((lane) => lane.outcome === "conflict")) { + overallOutcome = "conflict"; + } const proposal: IntegrationProposal = { proposalId, @@ -2871,6 +3007,18 @@ 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; + }; + const toGitHubState = (rawPr: any): PrState => { if (Boolean(rawPr?.draft)) return "draft"; if (rawPr?.merged_at) return "merged"; @@ -2909,15 +3057,7 @@ export function createPrService({ 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: workflowRow - ? "integration" - : groupRow?.group_type === "queue" - ? "queue" - : groupRow?.group_type === "integration" - ? "integration" - : linkedPrRow - ? "single" - : null, + adeKind: deriveAdeKind(workflowRow, groupRow, linkedPrRow), workflowDisplayState: workflowRow ? parseWorkflowDisplayState(workflowRow.workflow_display_state) : null, cleanupState: workflowRow ? parseCleanupState(workflowRow.cleanup_state) : null, }; @@ -3096,6 +3236,66 @@ export function createPrService({ .map(rowToSummary); }; + const reorderQueuePrs = async (args: ReorderQueuePrsArgs): Promise => { + const groupRow = db.get<{ id: string; group_type: string }>( + `select id, group_type + from pr_groups + where id = ? and project_id = ?`, + [args.groupId, projectId], + ); + if (!groupRow || groupRow.group_type !== "queue") { + throw new Error("Queue group not found."); + } + + const queueState = db.get<{ state: string }>( + `select state + from queue_landing_state + where group_id = ? and project_id = ? + order by started_at desc + limit 1`, + [args.groupId, projectId], + ); + if (queueState && (queueState.state === "landing" || queueState.state === "paused")) { + throw new Error("Queue order cannot change while landing is active or paused."); + } + + const members = db.all<{ pr_id: string; position: number }>( + `select pr_id, position + from pr_group_members + where group_id = ? and role = 'source' + order by position asc`, + [args.groupId], + ); + if (members.length < 2) return; + + const requestedPrIds = args.prIds.map((value) => value.trim()).filter(Boolean); + const existingPrIds = members.map((member) => String(member.pr_id)); + if ( + requestedPrIds.length !== existingPrIds.length + || new Set(requestedPrIds).size !== requestedPrIds.length + || requestedPrIds.some((prId) => !existingPrIds.includes(prId)) + ) { + throw new Error("Queue reorder request does not match the current queue members."); + } + + const basePosition = Math.min(...members.map((member) => Number(member.position) || 0)); + db.run("BEGIN"); + try { + requestedPrIds.forEach((prId, index) => { + db.run( + `update pr_group_members + set position = ? + where group_id = ? and pr_id = ? and role = 'source'`, + [basePosition + index, args.groupId, prId], + ); + }); + db.run("COMMIT"); + } catch (error) { + db.run("ROLLBACK"); + throw error; + } + }; + const listWithConflicts = async (): Promise => { const rows = listRows(); const results: PrWithConflicts[] = []; @@ -3659,6 +3859,12 @@ export function createPrService({ return reviews; }, + async getReviewThreads(prId: string): Promise { + const row = requireRow(prId); + const repo = repoFromRow(row); + return await fetchReviewThreads(repo, Number(row.github_pr_number)); + }, + async updateDescription(args: UpdatePrDescriptionArgs): Promise { return await updateDescription(args); }, @@ -3709,6 +3915,10 @@ export function createPrService({ return await landQueueNext(args); }, + async reorderQueuePrs(args: ReorderQueuePrsArgs): Promise { + return await reorderQueuePrs(args); + }, + async getPrHealth(prId: string): Promise { return await getPrHealth(prId); }, @@ -4073,6 +4283,78 @@ export function createPrService({ return comment; }, + async replyToReviewThread(args: ReplyToPrReviewThreadArgs): Promise { + requireRow(args.prId); + const data = await graphqlRequest<{ + addPullRequestReviewThreadReply?: { + comment?: { + id?: unknown; + body?: unknown; + url?: unknown; + createdAt?: unknown; + updatedAt?: unknown; + author?: { + login?: unknown; + avatarUrl?: unknown; + } | null; + } | null; + } | null; + }>( + ` + mutation AdeReplyToReviewThread($threadId: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { + comment { + id + body + url + createdAt + updatedAt + author { + login + avatarUrl + } + } + } + } + `, + { + threadId: args.threadId, + body: args.body, + }, + ); + + const comment = data.addPullRequestReviewThreadReply?.comment; + if (!comment) { + throw new Error("GitHub did not return the review-thread reply."); + } + return { + id: asString(comment.id) || String(randomUUID()), + author: asString(comment.author?.login) || "unknown", + authorAvatarUrl: asString(comment.author?.avatarUrl) || null, + body: asString(comment.body) || null, + url: asString(comment.url) || null, + createdAt: asString(comment.createdAt) || null, + updatedAt: asString(comment.updatedAt) || null, + }; + }, + + async resolveReviewThread(args: ResolvePrReviewThreadArgs): Promise { + requireRow(args.prId); + await graphqlRequest( + ` + mutation AdeResolveReviewThread($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { + thread { + id + isResolved + } + } + } + `, + { threadId: args.threadId }, + ); + }, + async updateTitle(args: UpdatePrTitleArgs): Promise { const row = requireRow(args.prId); const repo = repoFromRow(row); diff --git a/apps/desktop/src/main/services/prs/queueRehearsalService.test.ts b/apps/desktop/src/main/services/prs/queueRehearsalService.test.ts deleted file mode 100644 index 6f9d2f5b..00000000 --- a/apps/desktop/src/main/services/prs/queueRehearsalService.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { describe, expect, it, vi } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createQueueRehearsalService } from "./queueRehearsalService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -function git(cwd: string, args: string[]): string { - const res = spawnSync("git", args, { cwd, encoding: "utf8" }); - if (res.status !== 0) { - throw new Error(`git ${args.join(" ")} failed: ${res.stderr || res.stdout}`); - } - return (res.stdout ?? "").trim(); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitFor(fn: () => T | null | undefined, predicate: (value: T) => boolean): Promise { - const deadline = Date.now() + 10_000; - for (;;) { - const value = fn(); - if (value && predicate(value)) return value; - if (Date.now() >= deadline) throw new Error("Timed out waiting for queue rehearsal state"); - await sleep(50); - } -} - -async function seedProject(db: any, projectId: string, repoRoot: string, laneId = "lane-1") { - const now = "2026-03-09T00:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, repoRoot, "ADE", "main", now, now], - ); - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [laneId, projectId, laneId, null, "worktree", "main", `feature/${laneId}`, repoRoot, null, 0, null, null, null, null, "active", now, null], - ); - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ["lane-main", projectId, "main", null, "primary", "main", "main", repoRoot, null, 0, null, null, null, null, "active", now, null], - ); -} - -describe("queueRehearsalService", () => { - it("runs queue rehearsal on a scratch lane and uses the shared resolver path for conflicts", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-queue-rehearsal-resolve-")); - const repoRoot = path.join(root, "repo"); - const scratchRoot = path.join(root, "scratch"); - fs.mkdirSync(repoRoot, { recursive: true }); - git(root, ["init", "--initial-branch=main", repoRoot]); - git(repoRoot, ["config", "user.email", "ade@test.local"]); - git(repoRoot, ["config", "user.name", "ADE Test"]); - fs.writeFileSync(path.join(repoRoot, "src.txt"), "base\n", "utf8"); - git(repoRoot, ["add", "src.txt"]); - git(repoRoot, ["commit", "-m", "base"]); - git(repoRoot, ["checkout", "-b", "feature/lane-1"]); - fs.writeFileSync(path.join(repoRoot, "src.txt"), "feature change\n", "utf8"); - git(repoRoot, ["commit", "-am", "feature change"]); - git(repoRoot, ["checkout", "main"]); - fs.writeFileSync(path.join(repoRoot, "src.txt"), "main change\n", "utf8"); - git(repoRoot, ["commit", "-am", "main change"]); - git(repoRoot, ["checkout", "feature/lane-1"]); - - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-queue-rehearsal"; - await seedProject(db, projectId, repoRoot); - db.run( - `insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at) - values (?, ?, 'queue', ?, 1, 1, ?, ?)`, - ["group-1", projectId, "Queue Rehearsal", "main", "2026-03-09T00:00:00.000Z"], - ); - - const lanePaths = new Map([ - ["lane-1", repoRoot], - ["lane-main", repoRoot], - ]); - - const runExternalResolver = vi.fn().mockImplementation(async (args: { targetLaneId: string; cwdLaneId?: string }) => { - const scratchPath = lanePaths.get(args.cwdLaneId ?? ""); - if (!scratchPath) throw new Error("Expected scratch worktree"); - fs.writeFileSync(path.join(scratchPath, "src.txt"), "resolved rehearsal\n", "utf8"); - return { - runId: "resolver-run-1", - provider: "claude", - status: "completed", - startedAt: "2026-03-09T00:00:00.000Z", - completedAt: "2026-03-09T00:01:00.000Z", - targetLaneId: args.targetLaneId, - sourceLaneIds: ["lane-1"], - cwdLaneId: args.cwdLaneId ?? args.targetLaneId, - integrationLaneId: null, - scenario: "single-merge", - model: "anthropic/claude-sonnet-4-6", - reasoningEffort: "medium", - permissionMode: "guarded_edit", - command: [], - changedFiles: ["src.txt"], - summary: "Resolved rehearsal conflict", - patchPath: null, - logPath: null, - insufficientContext: false, - contextGaps: [], - warnings: [], - originSurface: "queue", - originMissionId: null, - originRunId: null, - originLabel: "Queue Rehearsal", - ptyId: null, - sessionId: null, - committedAt: null, - commitSha: null, - commitMessage: null, - postActions: null, - error: null, - }; - }); - - const service = createQueueRehearsalService({ - db, - logger: createLogger(), - projectId, - prService: { - listGroupPrs: async () => [ - { id: "pr-1", laneId: "lane-1", title: "Needs rehearsal resolve", headBranch: "feature/lane-1", baseBranch: "main", githubPrNumber: 101, githubUrl: "https://example.com/pr/101", state: "open", createdAt: "2026-03-09T10:00:00.000Z" }, - ] as any, - }, - laneService: { - list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], - getLaneBaseAndBranch: (laneId: string) => { - const worktreePath = lanePaths.get(laneId); - if (!worktreePath) throw new Error(`Unknown lane: ${laneId}`); - return { worktreePath, branchRef: laneId === "lane-1" ? "feature/lane-1" : laneId === "lane-main" ? "main" : `scratch/${laneId}`, baseRef: "main", laneType: "worktree" }; - }, - createChild: async () => { - fs.mkdirSync(scratchRoot, { recursive: true }); - git(repoRoot, ["worktree", "add", "-b", "scratch/queue-rehearsal", scratchRoot, "main"]); - lanePaths.set("lane-scratch", scratchRoot); - return { id: "lane-scratch", name: "lane-scratch" }; - }, - archive: () => {}, - } as any, - conflictService: { runExternalResolver } as any, - emitEvent: () => {}, - }); - - await service.startQueueRehearsal({ - groupId: "group-1", - method: "squash", - autoResolve: true, - resolverModel: "anthropic/claude-sonnet-4-6", - reasoningEffort: "medium", - originSurface: "queue", - originLabel: "Queue Rehearsal", - }); - - const completed = await waitFor( - () => service.getQueueRehearsalStateByGroup("group-1"), - (state) => state.state === "completed", - ); - - expect(completed.scratchLaneId).toBe("lane-scratch"); - expect(runExternalResolver).toHaveBeenCalledTimes(1); - expect(runExternalResolver.mock.calls[0]?.[0]).toMatchObject({ - targetLaneId: "lane-scratch", - cwdLaneId: "lane-scratch", - originSurface: "queue", - }); - expect(completed.entries[0]?.state).toBe("resolved"); - expect(completed.entries[0]?.resolvedByAi).toBe(true); - }); - - it("rehearses queue rebase mode by replaying commits onto the scratch lane", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-queue-rehearsal-rebase-")); - const repoRoot = path.join(root, "repo"); - const scratchRoot = path.join(root, "scratch"); - fs.mkdirSync(repoRoot, { recursive: true }); - git(root, ["init", "--initial-branch=main", repoRoot]); - git(repoRoot, ["config", "user.email", "ade@test.local"]); - git(repoRoot, ["config", "user.name", "ADE Test"]); - fs.writeFileSync(path.join(repoRoot, "base.txt"), "base\n", "utf8"); - git(repoRoot, ["add", "base.txt"]); - git(repoRoot, ["commit", "-m", "base"]); - git(repoRoot, ["checkout", "-b", "feature/lane-1"]); - fs.writeFileSync(path.join(repoRoot, "feature.txt"), "feature work\n", "utf8"); - git(repoRoot, ["add", "feature.txt"]); - git(repoRoot, ["commit", "-m", "feature work"]); - git(repoRoot, ["checkout", "main"]); - fs.writeFileSync(path.join(repoRoot, "main.txt"), "main work\n", "utf8"); - git(repoRoot, ["add", "main.txt"]); - git(repoRoot, ["commit", "-m", "main work"]); - git(repoRoot, ["checkout", "feature/lane-1"]); - - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-queue-rehearsal-rebase"; - await seedProject(db, projectId, repoRoot); - db.run( - `insert into pr_groups(id, project_id, group_type, name, auto_rebase, ci_gating, target_branch, created_at) - values (?, ?, 'queue', ?, 1, 1, ?, ?)`, - ["group-2", projectId, "Queue Rehearsal Rebase", "main", "2026-03-09T00:00:00.000Z"], - ); - - const lanePaths = new Map([ - ["lane-1", repoRoot], - ["lane-main", repoRoot], - ]); - - const service = createQueueRehearsalService({ - db, - logger: createLogger(), - projectId, - prService: { - listGroupPrs: async () => [ - { id: "pr-2", laneId: "lane-1", title: "Needs rehearsal rebase", headBranch: "feature/lane-1", baseBranch: "main", githubPrNumber: 102, githubUrl: "https://example.com/pr/102", state: "open", createdAt: "2026-03-09T10:00:00.000Z" }, - ] as any, - }, - laneService: { - list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], - getLaneBaseAndBranch: (laneId: string) => { - const worktreePath = lanePaths.get(laneId); - if (!worktreePath) throw new Error(`Unknown lane: ${laneId}`); - return { worktreePath, branchRef: laneId === "lane-1" ? "feature/lane-1" : laneId === "lane-main" ? "main" : `scratch/${laneId}`, baseRef: "main", laneType: "worktree" }; - }, - createChild: async () => { - fs.mkdirSync(scratchRoot, { recursive: true }); - git(repoRoot, ["worktree", "add", "-b", "scratch/queue-rehearsal-rebase", scratchRoot, "main"]); - lanePaths.set("lane-scratch", scratchRoot); - return { id: "lane-scratch", name: "lane-scratch" }; - }, - archive: () => {}, - } as any, - conflictService: { runExternalResolver: vi.fn() } as any, - emitEvent: () => {}, - }); - - await service.startQueueRehearsal({ - groupId: "group-2", - method: "rebase", - autoResolve: false, - originSurface: "queue", - originLabel: "Queue Rehearsal Rebase", - }); - - const completed = await waitFor( - () => service.getQueueRehearsalStateByGroup("group-2"), - (state) => state.state === "completed", - ); - - expect(completed.entries[0]?.state).toBe("ready"); - expect(completed.entries[0]?.changedFiles).toContain("feature.txt"); - expect(completed.scratchLaneId).toBe("lane-scratch"); - }); -}); diff --git a/apps/desktop/src/main/services/prs/queueRehearsalService.ts b/apps/desktop/src/main/services/prs/queueRehearsalService.ts deleted file mode 100644 index 8fa26eae..00000000 --- a/apps/desktop/src/main/services/prs/queueRehearsalService.ts +++ /dev/null @@ -1,727 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { - ConflictExternalResolverRunSummary, - MergeMethod, - PrEventPayload, - PrSummary, - QueueRehearsalConfig, - QueueRehearsalEntry, - QueueRehearsalState, - QueueRehearsalStateStatus, - QueueRehearsalWaitReason, - StartQueueRehearsalArgs, -} from "../../../shared/types"; -import type { AdeDb } from "../state/kvDb"; -import type { Logger } from "../logging/logger"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { createLaneService } from "../lanes/laneService"; -import { runGit, runGitOrThrow } from "../git/git"; -import { getErrorMessage, normalizeBranchName, nowIso } from "../shared/utils"; - -type QueueRehearsalRow = { - id: string; - group_id: string; - project_id: string; - state: string; - entries_json: string; - config_json: string | null; - current_position: number; - scratch_lane_id: string | null; - active_pr_id: string | null; - active_resolver_run_id: string | null; - last_error: string | null; - wait_reason: string | null; - started_at: string; - completed_at: string | null; - updated_at: string | null; - group_name: string | null; - target_branch: string | null; -}; - -type QueueGroupRow = { - id: string; - name: string | null; - target_branch: string | null; -}; - -const DEFAULT_REHEARSAL_CONFIG: QueueRehearsalConfig = { - method: "squash", - autoResolve: false, - resolverProvider: null, - resolverModel: null, - reasoningEffort: null, - permissionMode: "guarded_edit", - preserveScratchLane: true, - originSurface: "manual", - originMissionId: null, - originRunId: null, - originLabel: null, -}; - -function parseEntries(raw: string): QueueRehearsalEntry[] { - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function parseConfig(raw: string | null | undefined): Partial { - if (!raw) return {}; - try { - const parsed = JSON.parse(raw) as Partial; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -function rowToState(row: QueueRehearsalRow): QueueRehearsalState { - return { - rehearsalId: row.id, - groupId: row.group_id, - groupName: row.group_name ?? null, - targetBranch: row.target_branch ?? null, - state: row.state as QueueRehearsalStateStatus, - entries: parseEntries(row.entries_json), - currentPosition: Number(row.current_position), - scratchLaneId: row.scratch_lane_id ?? null, - activePrId: row.active_pr_id ?? null, - activeResolverRunId: row.active_resolver_run_id ?? null, - lastError: row.last_error ?? null, - waitReason: (row.wait_reason as QueueRehearsalWaitReason | null) ?? null, - config: { - ...DEFAULT_REHEARSAL_CONFIG, - ...parseConfig(row.config_json), - }, - startedAt: row.started_at, - completedAt: row.completed_at ?? null, - updatedAt: row.updated_at ?? row.completed_at ?? row.started_at, - }; -} - -export function createQueueRehearsalService({ - db, - logger, - projectId, - prService, - laneService, - conflictService, - emitEvent, - onStateChanged, -}: { - db: AdeDb; - logger: Logger; - projectId: string; - prService: { - listGroupPrs: (groupId: string) => Promise; - }; - laneService: Pick, "list" | "getLaneBaseAndBranch" | "createChild" | "archive">; - conflictService?: Pick, "runExternalResolver"> | null; - emitEvent: (event: PrEventPayload) => void; - onStateChanged?: (state: QueueRehearsalState) => void | Promise; -}) { - const activeRehearsalLoops = new Map>(); - - const Q_COLS = [ - "qrs.id", - "qrs.group_id", - "qrs.project_id", - "qrs.state", - "qrs.entries_json", - "qrs.config_json", - "qrs.current_position", - "qrs.scratch_lane_id", - "qrs.active_pr_id", - "qrs.active_resolver_run_id", - "qrs.last_error", - "qrs.wait_reason", - "qrs.started_at", - "qrs.completed_at", - "qrs.updated_at", - "pg.name as group_name", - "pg.target_branch as target_branch", - ].join(", "); - - const getRow = (rehearsalId: string): QueueRehearsalRow | null => - db.get( - `select ${Q_COLS} - from queue_rehearsal_state qrs - left join pr_groups pg on pg.id = qrs.group_id - where qrs.id = ? and qrs.project_id = ? - limit 1`, - [rehearsalId, projectId], - ); - - const getRowByGroup = (groupId: string, includeCompleted = false): QueueRehearsalRow | null => - db.get( - `select ${Q_COLS} - from queue_rehearsal_state qrs - left join pr_groups pg on pg.id = qrs.group_id - where qrs.group_id = ? and qrs.project_id = ? - ${includeCompleted ? "" : "and qrs.state in ('running', 'paused')"} - order by qrs.started_at desc - limit 1`, - [groupId, projectId], - ); - - const listRows = (includeCompleted = true, limit = 50): QueueRehearsalRow[] => - db.all( - `select ${Q_COLS} - from queue_rehearsal_state qrs - left join pr_groups pg on pg.id = qrs.group_id - where qrs.project_id = ? - ${includeCompleted ? "" : "and qrs.state in ('running', 'paused')"} - order by qrs.started_at desc - limit ?`, - [projectId, limit], - ); - - const getGroup = (groupId: string): QueueGroupRow | null => - db.get( - `select id, name, target_branch - from pr_groups - where id = ? and project_id = ? and group_type = 'queue' - limit 1`, - [groupId, projectId], - ); - - const notifyStateChanged = (state: QueueRehearsalState): void => { - if (!onStateChanged) return; - void Promise.resolve(onStateChanged(state)).catch((error) => { - logger.debug("queue_rehearsal.state_change_callback_failed", { - rehearsalId: state.rehearsalId, - groupId: state.groupId, - error: getErrorMessage(error), - }); - }); - }; - - const saveState = (state: QueueRehearsalState): void => { - state.updatedAt = nowIso(); - db.run( - `insert into queue_rehearsal_state( - id, group_id, project_id, state, entries_json, config_json, current_position, - scratch_lane_id, active_pr_id, active_resolver_run_id, last_error, wait_reason, - started_at, completed_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - on conflict(id) do update set - state = excluded.state, - entries_json = excluded.entries_json, - config_json = excluded.config_json, - current_position = excluded.current_position, - scratch_lane_id = excluded.scratch_lane_id, - active_pr_id = excluded.active_pr_id, - active_resolver_run_id = excluded.active_resolver_run_id, - last_error = excluded.last_error, - wait_reason = excluded.wait_reason, - completed_at = excluded.completed_at, - updated_at = excluded.updated_at`, - [ - state.rehearsalId, - state.groupId, - projectId, - state.state, - JSON.stringify(state.entries), - JSON.stringify(state.config), - state.currentPosition, - state.scratchLaneId, - state.activePrId, - state.activeResolverRunId, - state.lastError, - state.waitReason, - state.startedAt, - state.completedAt, - state.updatedAt, - ], - ); - notifyStateChanged(state); - }; - - const emitStep = (groupId: string, rehearsalId: string, prId: string, entryState: QueueRehearsalEntry["state"], position: number): void => { - emitEvent({ - type: "queue-rehearsal-step", - groupId, - rehearsalId, - prId, - entryState, - position, - timestamp: nowIso(), - }); - }; - - const emitState = (groupId: string, rehearsalId: string, state: QueueRehearsalStateStatus, currentPosition: number): void => { - emitEvent({ - type: "queue-rehearsal-state", - groupId, - rehearsalId, - state, - currentPosition, - timestamp: nowIso(), - }); - }; - - const persistAndEmitState = (state: QueueRehearsalState): void => { - saveState(state); - emitState(state.groupId, state.rehearsalId, state.state, state.currentPosition); - }; - - const resolveConfig = (args: StartQueueRehearsalArgs): QueueRehearsalConfig => ({ - ...DEFAULT_REHEARSAL_CONFIG, - method: args.method ?? "squash", - autoResolve: args.autoResolve ?? false, - resolverProvider: args.resolverProvider ?? null, - resolverModel: args.resolverModel ?? null, - reasoningEffort: args.reasoningEffort ?? null, - permissionMode: args.permissionMode ?? "guarded_edit", - preserveScratchLane: args.preserveScratchLane ?? true, - originSurface: args.originSurface ?? "manual", - originMissionId: args.originMissionId ?? null, - originRunId: args.originRunId ?? null, - originLabel: args.originLabel ?? null, - }); - - const resolveTargetLaneId = async (targetBranch: string | null): Promise => { - const normalizedTarget = normalizeBranchName(targetBranch ?? ""); - if (!normalizedTarget) return null; - const lanes = await laneService.list({ includeArchived: false }); - const match = lanes.find((lane) => { - const branch = normalizeBranchName(lane.branchRef); - const base = normalizeBranchName(lane.baseRef); - return lane.id === normalizedTarget || branch === normalizedTarget || base === normalizedTarget; - }); - return match?.id ?? null; - }; - - const ensureScratchLane = async (state: QueueRehearsalState): Promise => { - if (state.scratchLaneId) return state.scratchLaneId; - const targetLaneId = await resolveTargetLaneId(state.targetBranch); - if (!targetLaneId) { - throw new Error(`No lane is available for queue target branch "${state.targetBranch ?? "unknown"}".`); - } - const scratch = await laneService.createChild({ - parentLaneId: targetLaneId, - name: `queue-rehearsal-${state.groupName ?? state.groupId}-${Date.now()}`, - description: `Queue rehearsal scratch lane for ${state.groupName ?? state.groupId}`, - }); - state.scratchLaneId = scratch.id; - persistAndEmitState(state); - return scratch.id; - }; - - const readChangedPaths = async (worktreePath: string, beforeRef: string, afterRef: string): Promise => { - const diff = await runGit(["diff", "--name-only", `${beforeRef}..${afterRef}`], { - cwd: worktreePath, - timeoutMs: 20_000, - }); - if (diff.exitCode !== 0) return []; - return diff.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - }; - - const readPendingPaths = async (worktreePath: string): Promise => { - const status = await runGit(["status", "--porcelain"], { cwd: worktreePath, timeoutMs: 20_000 }); - if (status.exitCode !== 0) return []; - return status.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => line.slice(3).trim()) - .filter(Boolean); - }; - - const readConflictedPaths = async (worktreePath: string): Promise => { - const diff = await runGit(["diff", "--name-only", "--diff-filter=U"], { - cwd: worktreePath, - timeoutMs: 20_000, - }); - if (diff.exitCode !== 0) return []; - return diff.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - }; - - const stageAndCommit = async (worktreePath: string, message: string): Promise => { - const pending = await readPendingPaths(worktreePath); - if (!pending.length) { - const sha = await runGit(["rev-parse", "HEAD"], { cwd: worktreePath, timeoutMs: 10_000 }); - return sha.exitCode === 0 ? sha.stdout.trim() : null; - } - await runGitOrThrow(["add", "-A"], { cwd: worktreePath, timeoutMs: 60_000 }); - await runGitOrThrow(["commit", "-m", message], { cwd: worktreePath, timeoutMs: 90_000 }); - const sha = await runGit(["rev-parse", "HEAD"], { cwd: worktreePath, timeoutMs: 10_000 }); - return sha.exitCode === 0 ? sha.stdout.trim() : null; - }; - - const abortOperation = async (worktreePath: string, method: MergeMethod): Promise => { - if (method === "rebase") { - await runGit(["cherry-pick", "--abort"], { cwd: worktreePath, timeoutMs: 20_000 }); - return; - } - await runGit(["merge", "--abort"], { cwd: worktreePath, timeoutMs: 20_000 }); - }; - - const runResolver = async ( - state: QueueRehearsalState, - entry: QueueRehearsalEntry, - scratchLaneId: string, - ): Promise<{ ok: true; run: ConflictExternalResolverRunSummary } | { ok: false; error: string }> => { - if (!state.config.autoResolve || !conflictService) { - return { ok: false, error: "Queue rehearsal found conflicts that require manual resolution." }; - } - const provider = state.config.resolverProvider - ?? (state.config.resolverModel?.includes("anthropic/") ? "claude" : "codex"); - entry.state = "resolving"; - entry.updatedAt = nowIso(); - state.activeResolverRunId = null; - persistAndEmitState(state); - emitStep(state.groupId, state.rehearsalId, entry.prId, "resolving", entry.position); - - const run = await conflictService.runExternalResolver({ - provider, - targetLaneId: scratchLaneId, - sourceLaneIds: [entry.laneId], - cwdLaneId: scratchLaneId, - model: state.config.resolverModel, - reasoningEffort: state.config.reasoningEffort, - permissionMode: state.config.permissionMode, - originSurface: state.config.originSurface, - originMissionId: state.config.originMissionId, - originRunId: state.config.originRunId, - originLabel: state.config.originLabel ?? `queue-rehearsal:${state.groupId}`, - }); - state.activeResolverRunId = run.runId; - entry.resolverRunId = run.runId; - persistAndEmitState(state); - if (run.status !== "completed") { - return { ok: false, error: run.error ?? "Shared resolver job did not complete successfully." }; - } - return { ok: true, run }; - }; - - const continueCherryPick = async (worktreePath: string): Promise => { - const result = await runGit(["cherry-pick", "--continue"], { cwd: worktreePath, timeoutMs: 90_000 }); - if (result.exitCode === 0) return; - const combined = `${result.stdout}\n${result.stderr}`.toLowerCase(); - if (combined.includes("previous cherry-pick is now empty") || combined.includes("nothing to commit")) { - await runGitOrThrow(["cherry-pick", "--skip"], { cwd: worktreePath, timeoutMs: 30_000 }); - return; - } - throw new Error(result.stderr.trim() || result.stdout.trim() || "Unable to continue queue rehearsal cherry-pick."); - }; - - const rehearseMergeLike = async ( - state: QueueRehearsalState, - entry: QueueRehearsalEntry, - worktreePath: string, - sourceBranch: string, - ): Promise<{ commitSha: string | null; changedFiles: string[]; resolvedByAi: boolean; conflictPaths: string[] }> => { - const beforeHead = (await runGitOrThrow(["rev-parse", "HEAD"], { cwd: worktreePath, timeoutMs: 10_000 })).trim(); - const args = state.config.method === "merge" - ? ["merge", "--no-ff", "--no-commit", sourceBranch] - : ["merge", "--squash", sourceBranch]; - const merge = await runGit(args, { cwd: worktreePath, timeoutMs: 120_000 }); - let resolvedByAi = false; - let conflictPaths: string[] = []; - if (merge.exitCode !== 0) { - conflictPaths = await readConflictedPaths(worktreePath); - if (!conflictPaths.length) { - throw new Error(merge.stderr.trim() || merge.stdout.trim() || "Queue rehearsal merge failed."); - } - const resolved = await runResolver(state, entry, state.scratchLaneId!); - if (!resolved.ok) { - await abortOperation(worktreePath, state.config.method); - throw new Error(resolved.error); - } - resolvedByAi = true; - conflictPaths = conflictPaths.length > 0 ? conflictPaths : resolved.run.changedFiles; - } - const commitMessage = state.config.method === "merge" - ? `Rehearse queue merge for PR #${entry.prNumber ?? entry.prId}` - : `Rehearse queue squash for PR #${entry.prNumber ?? entry.prId}`; - const commitSha = await stageAndCommit(worktreePath, commitMessage); - const afterHead = (await runGitOrThrow(["rev-parse", "HEAD"], { cwd: worktreePath, timeoutMs: 10_000 })).trim(); - const changedFiles = beforeHead === afterHead ? [] : await readChangedPaths(worktreePath, beforeHead, afterHead); - return { commitSha, changedFiles, resolvedByAi, conflictPaths }; - }; - - const rehearseRebase = async ( - state: QueueRehearsalState, - entry: QueueRehearsalEntry, - worktreePath: string, - sourceBranch: string, - ): Promise<{ commitSha: string | null; changedFiles: string[]; resolvedByAi: boolean; conflictPaths: string[] }> => { - const beforeHead = (await runGitOrThrow(["rev-parse", "HEAD"], { cwd: worktreePath, timeoutMs: 10_000 })).trim(); - const mergeBase = (await runGitOrThrow(["merge-base", "HEAD", sourceBranch], { cwd: worktreePath, timeoutMs: 10_000 })).trim(); - const commitsRaw = await runGit(["rev-list", "--reverse", `${mergeBase}..${sourceBranch}`], { - cwd: worktreePath, - timeoutMs: 30_000, - }); - if (commitsRaw.exitCode !== 0) { - throw new Error(commitsRaw.stderr.trim() || "Unable to enumerate commits for queue rehearsal rebase."); - } - const commits = commitsRaw.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - let resolvedByAi = false; - let conflictPaths: string[] = []; - for (const commit of commits) { - const pick = await runGit(["cherry-pick", commit], { cwd: worktreePath, timeoutMs: 90_000 }); - if (pick.exitCode === 0) continue; - conflictPaths = await readConflictedPaths(worktreePath); - if (!conflictPaths.length) { - const combined = `${pick.stdout}\n${pick.stderr}`.toLowerCase(); - if (combined.includes("previous cherry-pick is now empty") || combined.includes("nothing to commit")) { - await runGitOrThrow(["cherry-pick", "--skip"], { cwd: worktreePath, timeoutMs: 30_000 }); - continue; - } - throw new Error(pick.stderr.trim() || pick.stdout.trim() || "Queue rehearsal cherry-pick failed."); - } - const resolved = await runResolver(state, entry, state.scratchLaneId!); - if (!resolved.ok) { - await abortOperation(worktreePath, "rebase"); - throw new Error(resolved.error); - } - resolvedByAi = true; - await runGitOrThrow(["add", "-A"], { cwd: worktreePath, timeoutMs: 60_000 }); - await continueCherryPick(worktreePath); - } - const afterHead = (await runGitOrThrow(["rev-parse", "HEAD"], { cwd: worktreePath, timeoutMs: 10_000 })).trim(); - const changedFiles = beforeHead === afterHead ? [] : await readChangedPaths(worktreePath, beforeHead, afterHead); - return { - commitSha: afterHead, - changedFiles, - resolvedByAi, - conflictPaths, - }; - }; - - const maybeArchiveScratchLane = (state: QueueRehearsalState): void => { - if (state.config.preserveScratchLane || !state.scratchLaneId) return; - try { - laneService.archive({ laneId: state.scratchLaneId }); - } catch (error) { - logger.warn("queue_rehearsal.archive_scratch_failed", { - rehearsalId: state.rehearsalId, - scratchLaneId: state.scratchLaneId, - error: getErrorMessage(error), - }); - } - }; - - const markFailure = ( - state: QueueRehearsalState, - entry: QueueRehearsalEntry, - waitReason: QueueRehearsalWaitReason, - message: string, - entryState: QueueRehearsalEntry["state"] = "failed", - ): void => { - entry.state = entryState; - entry.updatedAt = nowIso(); - entry.error = message; - state.state = entryState === "blocked" ? "paused" : "failed"; - state.lastError = message; - state.waitReason = waitReason; - state.activePrId = entry.prId; - state.completedAt = state.state === "failed" ? nowIso() : null; - persistAndEmitState(state); - emitStep(state.groupId, state.rehearsalId, entry.prId, entry.state, entry.position); - if (state.state === "failed") maybeArchiveScratchLane(state); - }; - - const launchLoop = (rehearsalId: string): void => { - const prior = activeRehearsalLoops.get(rehearsalId) ?? Promise.resolve(); - const loopPromise = prior.then(async () => { - const row = getRow(rehearsalId); - if (!row) return; - const state = rowToState(row); - if (state.state !== "running") return; - - const scratchLaneId = await ensureScratchLane(state); - const scratch = laneService.getLaneBaseAndBranch(scratchLaneId); - for (let index = state.currentPosition; index < state.entries.length; index += 1) { - const entry = state.entries[index]!; - if (entry.state === "ready" || entry.state === "resolved" || entry.state === "cancelled") { - state.currentPosition = index + 1; - continue; - } - state.currentPosition = index; - state.activePrId = entry.prId; - state.activeResolverRunId = null; - state.lastError = null; - state.waitReason = null; - entry.state = "rehearsing"; - entry.updatedAt = nowIso(); - persistAndEmitState(state); - emitStep(state.groupId, state.rehearsalId, entry.prId, "rehearsing", entry.position); - - const lane = laneService.getLaneBaseAndBranch(entry.laneId); - const sourceBranch = normalizeBranchName(lane.branchRef); - - try { - const result = state.config.method === "rebase" - ? await rehearseRebase(state, entry, scratch.worktreePath, sourceBranch) - : await rehearseMergeLike(state, entry, scratch.worktreePath, sourceBranch); - entry.state = result.resolvedByAi ? "resolved" : "ready"; - entry.updatedAt = nowIso(); - entry.resolvedByAi = result.resolvedByAi; - entry.simulatedCommitSha = result.commitSha; - entry.changedFiles = result.changedFiles; - entry.conflictPaths = result.conflictPaths; - entry.error = undefined; - state.activePrId = null; - state.activeResolverRunId = null; - persistAndEmitState(state); - emitStep(state.groupId, state.rehearsalId, entry.prId, entry.state, entry.position); - } catch (error) { - const message = getErrorMessage(error); - const waitReason: QueueRehearsalWaitReason = message.includes("manual") ? "manual" : "resolver_failed"; - markFailure( - state, - entry, - waitReason, - message, - waitReason === "manual" ? "blocked" : "failed", - ); - return; - } - } - - state.state = "completed"; - state.activePrId = null; - state.activeResolverRunId = null; - state.lastError = null; - state.waitReason = null; - state.currentPosition = state.entries.length; - state.completedAt = nowIso(); - persistAndEmitState(state); - maybeArchiveScratchLane(state); - }).catch((error) => { - const row = getRow(rehearsalId); - if (row) { - const state = rowToState(row); - state.state = "failed"; - state.lastError = getErrorMessage(error); - state.waitReason = "manual"; - state.completedAt = nowIso(); - persistAndEmitState(state); - maybeArchiveScratchLane(state); - } - logger.error("queue_rehearsal.loop_fatal", { - rehearsalId, - error: getErrorMessage(error), - }); - }); - activeRehearsalLoops.set(rehearsalId, loopPromise); - void loopPromise.finally(() => { - if (activeRehearsalLoops.get(rehearsalId) === loopPromise) { - activeRehearsalLoops.delete(rehearsalId); - } - }); - }; - - const startQueueRehearsal = async (args: StartQueueRehearsalArgs): Promise => { - const existing = getRowByGroup(args.groupId); - if (existing && existing.state === "running") { - return rowToState(existing); - } - - const group = getGroup(args.groupId); - const prs = await prService.listGroupPrs(args.groupId); - const entries: QueueRehearsalEntry[] = prs.map((pr, index) => ({ - prId: pr.id, - laneId: pr.laneId, - laneName: pr.title || pr.headBranch, - position: index, - prNumber: pr.githubPrNumber, - githubUrl: pr.githubUrl, - state: pr.state === "open" || pr.state === "draft" ? "pending" : "cancelled", - updatedAt: null, - })); - const rehearsalId = randomUUID(); - const now = nowIso(); - const state: QueueRehearsalState = { - rehearsalId, - groupId: args.groupId, - groupName: group?.name ?? null, - targetBranch: group?.target_branch ?? prs[0]?.baseBranch ?? null, - state: "running", - entries, - currentPosition: 0, - scratchLaneId: null, - activePrId: entries.find((entry) => entry.state === "pending")?.prId ?? null, - activeResolverRunId: null, - lastError: null, - waitReason: null, - config: resolveConfig(args), - startedAt: now, - completedAt: null, - updatedAt: now, - }; - persistAndEmitState(state); - launchLoop(rehearsalId); - return state; - }; - - const cancelQueueRehearsal = (rehearsalId: string): QueueRehearsalState | null => { - const row = getRow(rehearsalId); - if (!row) return null; - const state = rowToState(row); - if (state.state === "completed" || state.state === "cancelled" || state.state === "failed") return state; - for (const entry of state.entries) { - if (entry.state === "pending" || entry.state === "rehearsing" || entry.state === "resolving") { - entry.state = "cancelled"; - entry.updatedAt = nowIso(); - } - } - state.state = "cancelled"; - state.lastError = "Queue rehearsal cancelled by operator."; - state.waitReason = "canceled"; - state.activePrId = null; - state.activeResolverRunId = null; - state.completedAt = nowIso(); - persistAndEmitState(state); - maybeArchiveScratchLane(state); - return state; - }; - - const getQueueRehearsalState = (rehearsalId: string): QueueRehearsalState | null => { - const row = getRow(rehearsalId); - return row ? rowToState(row) : null; - }; - - const getQueueRehearsalStateByGroup = (groupId: string): QueueRehearsalState | null => { - const row = getRowByGroup(groupId, true); - return row ? rowToState(row) : null; - }; - - const listQueueRehearsals = (args: { includeCompleted?: boolean; limit?: number } = {}): QueueRehearsalState[] => - listRows(args.includeCompleted ?? true, args.limit ?? 50).map(rowToState); - - const init = (): void => { - const interrupted = db.all( - `select ${Q_COLS} - from queue_rehearsal_state qrs - left join pr_groups pg on pg.id = qrs.group_id - where qrs.project_id = ? and qrs.state = 'running'`, - [projectId], - ); - for (const row of interrupted) { - const state = rowToState(row); - state.state = "paused"; - state.lastError = "Queue rehearsal was interrupted by app shutdown and should be rerun."; - state.waitReason = "manual"; - const currentEntry = state.entries[state.currentPosition]; - if (currentEntry && (currentEntry.state === "rehearsing" || currentEntry.state === "resolving")) { - currentEntry.state = "blocked"; - currentEntry.updatedAt = nowIso(); - } - persistAndEmitState(state); - } - }; - - return { - startQueueRehearsal, - cancelQueueRehearsal, - getQueueRehearsalState, - getQueueRehearsalStateByGroup, - listQueueRehearsals, - init, - }; -} diff --git a/apps/desktop/src/main/services/shared/queueRebase.ts b/apps/desktop/src/main/services/shared/queueRebase.ts new file mode 100644 index 00000000..00bafb7f --- /dev/null +++ b/apps/desktop/src/main/services/shared/queueRebase.ts @@ -0,0 +1,229 @@ +import type { AdeDb } from "../state/kvDb"; +import { runGit, runGitOrThrow } from "../git/git"; +import { normalizeBranchName } from "./utils"; + +type QueueMembershipRow = { + group_id: string; + position: number; + group_name: string | null; + target_branch: string | null; +}; + +type QueueMemberRow = { + lane_id: string; + pr_id: string | null; + position: number; + pr_state: string | null; +}; + +type QueueLandingStateRow = { + entries_json: string | null; +}; + +type QueueLandingEntryState = { + prId: string; + state: string; +}; + +export type QueueRebaseOverride = { + comparisonRef: string; + displayBaseBranch: string; + groupContext: string; + baseLabel: string; + queueGroupId: string; + queuePosition: number; +}; + +function parseLandingEntries(raw: string | null | undefined): QueueLandingEntryState[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const prId = typeof (entry as { prId?: unknown }).prId === "string" ? (entry as { prId: string }).prId.trim() : ""; + const state = typeof (entry as { state?: unknown }).state === "string" ? (entry as { state: string }).state.trim() : ""; + if (!prId || !state) return null; + return { prId, state }; + }) + .filter((entry): entry is QueueLandingEntryState => Boolean(entry)); + } catch { + return []; + } +} + +function isQueueMemberCompleted(args: { prState: string | null; queueEntryState: string | null }): boolean { + if (args.queueEntryState === "landed" || args.queueEntryState === "skipped") return true; + return args.prState === "merged"; +} + +async function resolveRemoteAwareTargetRef(args: { + projectRoot: string; + targetBranch: string; +}): Promise { + const branch = normalizeBranchName(args.targetBranch).trim(); + if (!branch) return args.targetBranch; + const remoteTrackingRef = `refs/remotes/origin/${branch}`; + const remoteCheck = await runGit( + ["rev-parse", "--verify", remoteTrackingRef], + { cwd: args.projectRoot, timeoutMs: 10_000 }, + ); + if (remoteCheck.exitCode === 0 && remoteCheck.stdout.trim()) { + return `origin/${branch}`; + } + return branch; +} + +export async function resolveQueueRebaseOverride(args: { + db: AdeDb; + projectId: string; + projectRoot: string; + laneId: string; +}): Promise { + const memberships = args.db.all( + ` + select m.group_id, m.position, g.name as group_name, g.target_branch + from pr_group_members m + join pr_groups g on g.id = m.group_id + where m.lane_id = ? + and g.project_id = ? + and g.group_type = 'queue' + order by g.created_at desc, m.position asc + `, + [args.laneId, args.projectId], + ); + + for (const membership of memberships) { + const targetBranch = normalizeBranchName(String(membership.target_branch ?? "").trim()); + if (!targetBranch) continue; + + const members = args.db.all( + ` + select m.lane_id, m.pr_id, m.position, pr.state as pr_state + from pr_group_members m + join pr_groups g on g.id = m.group_id + left join pull_requests pr on pr.id = m.pr_id and pr.project_id = g.project_id + where m.group_id = ? + and g.project_id = ? + order by m.position asc + `, + [membership.group_id, args.projectId], + ); + if (members.length === 0) continue; + + const queueStateRow = args.db.get( + ` + select entries_json + from queue_landing_state + where group_id = ? + and project_id = ? + order by started_at desc + limit 1 + `, + [membership.group_id, args.projectId], + ); + const queueEntryStateByPrId = new Map(); + for (const entry of parseLandingEntries(queueStateRow?.entries_json ?? null)) { + queueEntryStateByPrId.set(entry.prId, entry.state); + } + + // Landed queue members are removed from `pr_group_members`, so preserve their + // original prefix by looking at the lowest remaining queue position. + let landedPrefixCount = Math.max(0, Number(members[0]?.position ?? 0)); + for (const member of members) { + const queueEntryState = member.pr_id ? (queueEntryStateByPrId.get(member.pr_id) ?? null) : null; + const isCompleted = isQueueMemberCompleted({ prState: member.pr_state, queueEntryState }); + if (isCompleted) { + landedPrefixCount += 1; + continue; + } + break; + } + + if (landedPrefixCount === 0) continue; + + const targetMember = members.find((member) => member.lane_id === args.laneId) ?? null; + if (!targetMember) continue; + const targetQueueEntryState = targetMember.pr_id ? (queueEntryStateByPrId.get(targetMember.pr_id) ?? null) : null; + const targetCompleted = isQueueMemberCompleted({ + prState: targetMember.pr_state, + queueEntryState: targetQueueEntryState, + }); + if (targetCompleted) continue; + + const comparisonRef = await resolveRemoteAwareTargetRef({ + projectRoot: args.projectRoot, + targetBranch, + }); + const groupContext = membership.group_name?.trim() ? membership.group_name.trim() : membership.group_id; + return { + comparisonRef, + displayBaseBranch: targetBranch, + groupContext, + baseLabel: `queue target ${targetBranch}`, + queueGroupId: membership.group_id, + queuePosition: Number(targetMember.position), + }; + } + + return null; +} + +export async function fetchRemoteTrackingBranch(args: { + projectRoot: string; + targetBranch: string | null | undefined; +}): Promise { + const branch = normalizeBranchName(String(args.targetBranch ?? "").trim()); + if (!branch) return false; + try { + await runGitOrThrow( + ["fetch", "--prune", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`], + { cwd: args.projectRoot, timeoutMs: 120_000 }, + ); + return true; + } catch { + const fallback = await runGit(["fetch", "--prune", "origin"], { + cwd: args.projectRoot, + timeoutMs: 120_000, + }); + return fallback.exitCode === 0; + } +} + +export async function fetchQueueTargetTrackingBranches(args: { + db: AdeDb; + projectId: string; + projectRoot: string; +}): Promise { + const rows = args.db.all<{ target_branch: string | null }>( + ` + select distinct g.target_branch + from pr_groups g + where g.project_id = ? + and g.group_type = 'queue' + and exists ( + select 1 + from pr_group_members m + where m.group_id = g.id + ) + `, + [args.projectId], + ); + + const branches = new Set(); + for (const row of rows) { + const branch = normalizeBranchName(String(row.target_branch ?? "").trim()); + if (branch) branches.add(branch); + } + + for (const branch of branches) { + await fetchRemoteTrackingBranch({ + projectRoot: args.projectRoot, + targetBranch: branch, + }).catch(() => { + // Best-effort refresh only. Rebase scans can still proceed against the + // existing local tracking ref if fetch is unavailable. + }); + } +} diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 3b5834ad..f6f9ffa1 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -1023,37 +1023,6 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) { try { db.run("alter table queue_landing_state add column wait_reason text"); } catch {} try { db.run("alter table queue_landing_state add column updated_at text"); } catch {} - // Queue rehearsal state table (dry-run queue landing on an isolated scratch lane) - db.run(` - create table if not exists queue_rehearsal_state ( - id text primary key, - group_id text not null, - project_id text not null, - state text not null, - entries_json text not null, - config_json text not null default '{}', - current_position integer not null default 0, - scratch_lane_id text, - active_pr_id text, - active_resolver_run_id text, - last_error text, - wait_reason text, - started_at text not null, - completed_at text, - updated_at text, - foreign key(group_id) references pr_groups(id), - foreign key(project_id) references projects(id) - ) - `); - db.run("create index if not exists idx_queue_rehearsal_state_group on queue_rehearsal_state(group_id)"); - try { db.run("alter table queue_rehearsal_state add column config_json text not null default '{}'"); } catch {} - try { db.run("alter table queue_rehearsal_state add column scratch_lane_id text"); } catch {} - try { db.run("alter table queue_rehearsal_state add column active_pr_id text"); } catch {} - try { db.run("alter table queue_rehearsal_state add column active_resolver_run_id text"); } catch {} - try { db.run("alter table queue_rehearsal_state add column last_error text"); } catch {} - try { db.run("alter table queue_rehearsal_state add column wait_reason text"); } catch {} - try { db.run("alter table queue_rehearsal_state add column updated_at text"); } catch {} - // Rebase dismiss/defer persistence db.run(` create table if not exists rebase_dismissed ( diff --git a/apps/desktop/src/main/services/state/onConflictAudit.test.ts b/apps/desktop/src/main/services/state/onConflictAudit.test.ts index c8441a52..ca05d49c 100644 --- a/apps/desktop/src/main/services/state/onConflictAudit.test.ts +++ b/apps/desktop/src/main/services/state/onConflictAudit.test.ts @@ -100,11 +100,6 @@ const APPROVED_CONFLICT_TARGETS: ConflictTarget[] = [ table: "queue_landing_state", columns: "id", }, - { - file: "src/main/services/prs/queueRehearsalService.ts", - table: "queue_rehearsal_state", - columns: "id", - }, { file: "src/main/services/sessions/sessionDeltaService.ts", table: "session_deltas", diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 6bf95809..ae5c04b3 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -235,8 +235,8 @@ import type { PrAiResolutionEventPayload, CommitIntegrationArgs, LandQueueNextArgs, + ReorderQueuePrsArgs, QueueLandingState, - QueueRehearsalState, PrHealth, RebaseNeed, RebaseLaneArgs, @@ -249,22 +249,48 @@ import type { RebaseAbortArgs, RebaseRun, RebaseRunEventPayload, + CleanupIntegrationWorkflowArgs, + CleanupIntegrationWorkflowResult, + CreateIntegrationPrArgs, + CreateIntegrationPrResult, + DeleteIntegrationProposalArgs, + DeleteIntegrationProposalResult, DeletePrArgs, DeletePrResult, + DismissIntegrationCleanupArgs, + DraftPrDescriptionArgs, + GitHubPrSnapshot, + LandPrArgs, + LandResult, + LandStackArgs, + LandStackEnhancedArgs, LinkPrToLaneArgs, - PrEventPayload, + ListIntegrationWorkflowsArgs, + PrActionRun, + PrActivityEvent, PrCheck, PrComment, - PrReview, - PrStatus, - PrSummary, - PrMergeContext, + PrConflictAnalysis, PrDetail, + PrEventPayload, PrFile, - PrActionRun, - PrActivityEvent, + PrIssueResolutionPromptPreviewArgs, + PrIssueResolutionPromptPreviewResult, + PrIssueResolutionStartArgs, + PrIssueResolutionStartResult, PrLabel, + PrMergeContext, + PrReview, + PrReviewThread, + PrReviewThreadComment, + PrStatus, + PrSummary, PrUser, + PrWithConflicts, + ReplyToPrReviewThreadArgs, + ResolvePrReviewThreadArgs, + ResumeQueueAutomationArgs, + StartQueueAutomationArgs, AddPrCommentArgs, UpdatePrTitleArgs, UpdatePrBodyArgs, @@ -278,9 +304,6 @@ import type { AiReviewSummary, UpdateIntegrationProposalArgs, UpdatePrDescriptionArgs, - LandPrArgs, - LandStackArgs, - LandResult, ListOverlapsArgs, LaneSummary, ListMissionsArgs, @@ -919,19 +942,20 @@ declare global { getChecks: (prId: string) => Promise; getComments: (prId: string) => Promise; getReviews: (prId: string) => Promise; + getReviewThreads: (prId: string) => Promise; updateDescription: (args: UpdatePrDescriptionArgs) => Promise; delete: (args: DeletePrArgs) => Promise; - draftDescription: (args: import("../shared/types").DraftPrDescriptionArgs) => Promise<{ title: string; body: string }>; + draftDescription: (args: DraftPrDescriptionArgs) => Promise<{ title: string; body: string }>; land: (args: LandPrArgs) => Promise; landStack: (args: LandStackArgs) => Promise; openInGitHub: (prId: string) => Promise; createQueue: (args: CreateQueuePrsArgs) => Promise; - createIntegration: (args: import("../shared/types").CreateIntegrationPrArgs) => Promise; + createIntegration: (args: CreateIntegrationPrArgs) => Promise; simulateIntegration: (args: SimulateIntegrationArgs) => Promise; - commitIntegration: (args: CommitIntegrationArgs) => Promise; + commitIntegration: (args: CommitIntegrationArgs) => Promise; listProposals(): Promise; updateProposal(args: UpdateIntegrationProposalArgs): Promise; - deleteProposal(args: import("../shared/types").DeleteIntegrationProposalArgs): Promise; + deleteProposal(args: DeleteIntegrationProposalArgs): Promise; createIntegrationLaneForProposal(args: CreateIntegrationLaneForProposalArgs): Promise; startIntegrationResolution(args: StartIntegrationResolutionArgs): Promise; recheckIntegrationStep(args: RecheckIntegrationStepArgs): Promise; @@ -941,30 +965,31 @@ declare global { aiResolutionInput(args: PrAiResolutionInputArgs): Promise; aiResolutionStop(args: PrAiResolutionStopArgs): Promise; onAiResolutionEvent: (cb: (ev: PrAiResolutionEventPayload) => void) => () => void; - landStackEnhanced: (args: import("../shared/types").LandStackEnhancedArgs) => Promise; + issueResolutionStart(args: PrIssueResolutionStartArgs): Promise; + issueResolutionPreviewPrompt(args: PrIssueResolutionPromptPreviewArgs): Promise; + landStackEnhanced: (args: LandStackEnhancedArgs) => Promise; landQueueNext: (args: LandQueueNextArgs) => Promise; - startQueueAutomation: (args: import("../shared/types").StartQueueAutomationArgs) => Promise; + startQueueAutomation: (args: StartQueueAutomationArgs) => Promise; pauseQueueAutomation: (queueId: string) => Promise; - resumeQueueAutomation: (args: import("../shared/types").ResumeQueueAutomationArgs) => Promise; + resumeQueueAutomation: (args: ResumeQueueAutomationArgs) => Promise; cancelQueueAutomation: (queueId: string) => Promise; - startQueueRehearsal: (args: import("../shared/types").StartQueueRehearsalArgs) => Promise; - cancelQueueRehearsal: (rehearsalId: string) => Promise; + reorderQueuePrs: (args: ReorderQueuePrsArgs) => Promise; getHealth: (prId: string) => Promise; getQueueState: (groupId: string) => Promise; listQueueStates: (args?: { includeCompleted?: boolean; limit?: number }) => Promise; - getQueueRehearsalState: (groupId: string) => Promise; - listQueueRehearsals: (args?: { includeCompleted?: boolean; limit?: number }) => Promise; - getConflictAnalysis: (prId: string) => Promise; + getConflictAnalysis: (prId: string) => Promise; getMergeContext: (prId: string) => Promise; - listWithConflicts: () => Promise; - getGitHubSnapshot: (args?: { force?: boolean }) => Promise; - listIntegrationWorkflows: (args?: import("../shared/types").ListIntegrationWorkflowsArgs) => Promise; + listWithConflicts: () => Promise; + getGitHubSnapshot: (args?: { force?: boolean }) => Promise; + listIntegrationWorkflows: (args?: ListIntegrationWorkflowsArgs) => Promise; onEvent: (cb: (ev: PrEventPayload) => void) => () => void; getDetail: (prId: string) => Promise; getFiles: (prId: string) => Promise; getActionRuns: (prId: string) => Promise; getActivity: (prId: string) => Promise; addComment: (args: AddPrCommentArgs) => Promise; + replyToReviewThread: (args: ReplyToPrReviewThreadArgs) => Promise; + resolveReviewThread: (args: ResolvePrReviewThreadArgs) => Promise; updateTitle: (args: UpdatePrTitleArgs) => Promise; updateBody: (args: UpdatePrBodyArgs) => Promise; setLabels: (args: SetPrLabelsArgs) => Promise; @@ -974,8 +999,8 @@ declare global { reopen: (args: ReopenPrArgs) => Promise; rerunChecks: (args: RerunPrChecksArgs) => Promise; aiReviewSummary: (args: AiReviewSummaryArgs) => Promise; - dismissIntegrationCleanup: (args: import("../shared/types").DismissIntegrationCleanupArgs) => Promise; - cleanupIntegrationWorkflow: (args: import("../shared/types").CleanupIntegrationWorkflowArgs) => Promise; + dismissIntegrationCleanup: (args: DismissIntegrationCleanupArgs) => Promise; + cleanupIntegrationWorkflow: (args: CleanupIntegrationWorkflowArgs) => Promise; }; rebase: { scanNeeds: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d2c8af42..22326ab2 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -179,6 +179,8 @@ import type { PrCheck, PrComment, PrReview, + PrReviewThread, + PrReviewThreadComment, PrStatus, PrSummary, PrDetail, @@ -188,6 +190,8 @@ import type { PrLabel, PrUser, AddPrCommentArgs, + ReplyToPrReviewThreadArgs, + ResolvePrReviewThreadArgs, UpdatePrTitleArgs, UpdatePrBodyArgs, SetPrLabelsArgs, @@ -311,6 +315,10 @@ import type { CleanupIntegrationWorkflowResult, PrAiResolutionStartArgs, PrAiResolutionStartResult, + PrIssueResolutionStartArgs, + PrIssueResolutionPromptPreviewArgs, + PrIssueResolutionPromptPreviewResult, + PrIssueResolutionStartResult, PrAiResolutionGetSessionArgs, PrAiResolutionGetSessionResult, PrAiResolutionInputArgs, @@ -319,11 +327,10 @@ import type { CommitIntegrationArgs, LandStackEnhancedArgs, LandQueueNextArgs, + ReorderQueuePrsArgs, ResumeQueueAutomationArgs, StartQueueAutomationArgs, - StartQueueRehearsalArgs, QueueLandingState, - QueueRehearsalState, GitHubPrSnapshot, PrConflictAnalysis, PrMergeContext, @@ -1314,6 +1321,7 @@ contextBridge.exposeInMainWorld("ade", { getChecks: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetChecks, { prId }), getComments: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetComments, { prId }), getReviews: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetReviews, { prId }), + getReviewThreads: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetReviewThreads, { prId }), updateDescription: async (args: UpdatePrDescriptionArgs): Promise => ipcRenderer.invoke(IPC.prsUpdateDescription, args), delete: async (args: DeletePrArgs): Promise => ipcRenderer.invoke(IPC.prsDelete, args), draftDescription: async (args: DraftPrDescriptionArgs): Promise<{ title: string; body: string }> => @@ -1347,20 +1355,14 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.prsResumeQueueAutomation, args), cancelQueueAutomation: (queueId: string): Promise => ipcRenderer.invoke(IPC.prsCancelQueueAutomation, { queueId }), - startQueueRehearsal: (args: StartQueueRehearsalArgs): Promise => - ipcRenderer.invoke(IPC.prsStartQueueRehearsal, args), - cancelQueueRehearsal: (rehearsalId: string): Promise => - ipcRenderer.invoke(IPC.prsCancelQueueRehearsal, { rehearsalId }), + reorderQueuePrs: (args: ReorderQueuePrsArgs): Promise => + ipcRenderer.invoke(IPC.prsReorderQueue, args), getHealth: (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetHealth, { prId }), getQueueState: (groupId: string): Promise => ipcRenderer.invoke(IPC.prsGetQueueState, { groupId }), listQueueStates: (args?: { includeCompleted?: boolean; limit?: number }): Promise => ipcRenderer.invoke(IPC.prsListQueueStates, args ?? {}), - getQueueRehearsalState: (groupId: string): Promise => - ipcRenderer.invoke(IPC.prsGetQueueRehearsalState, { groupId }), - listQueueRehearsals: (args?: { includeCompleted?: boolean; limit?: number }): Promise => - ipcRenderer.invoke(IPC.prsListQueueRehearsals, args ?? {}), getConflictAnalysis: (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetConflictAnalysis, { prId }), getMergeContext: (prId: string): Promise => @@ -1387,6 +1389,10 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.prsAiResolutionInput, args), aiResolutionStop: (args: PrAiResolutionStopArgs): Promise => ipcRenderer.invoke(IPC.prsAiResolutionStop, args), + issueResolutionStart: (args: PrIssueResolutionStartArgs): Promise => + ipcRenderer.invoke(IPC.prsIssueResolutionStart, args), + issueResolutionPreviewPrompt: (args: PrIssueResolutionPromptPreviewArgs): Promise => + ipcRenderer.invoke(IPC.prsIssueResolutionPreviewPrompt, args), onAiResolutionEvent: (cb: (ev: PrAiResolutionEventPayload) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: PrAiResolutionEventPayload) => cb(payload); ipcRenderer.on(IPC.prsAiResolutionEvent, listener); @@ -1402,6 +1408,10 @@ contextBridge.exposeInMainWorld("ade", { getActionRuns: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetActionRuns, { prId }), getActivity: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetActivity, { prId }), addComment: async (args: AddPrCommentArgs): Promise => ipcRenderer.invoke(IPC.prsAddComment, args), + replyToReviewThread: async (args: ReplyToPrReviewThreadArgs): Promise => + ipcRenderer.invoke(IPC.prsReplyToReviewThread, args), + resolveReviewThread: async (args: ResolvePrReviewThreadArgs): Promise => + ipcRenderer.invoke(IPC.prsResolveReviewThread, args), updateTitle: async (args: UpdatePrTitleArgs): Promise => ipcRenderer.invoke(IPC.prsUpdateTitle, args), updateBody: async (args: UpdatePrBodyArgs): Promise => ipcRenderer.invoke(IPC.prsUpdateBody, args), setLabels: async (args: SetPrLabelsArgs): Promise => ipcRenderer.invoke(IPC.prsSetLabels, args), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 25956bae..7fb3b333 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -545,42 +545,6 @@ const MOCK_QUEUE_STATE: Record = { }, }; -const MOCK_QUEUE_REHEARSAL_STATE: Record = { - "queue-commerce-v3": { - rehearsalId: "rehearsal-commerce-v3", - groupId: "queue-commerce-v3", - groupName: "Release v3.0 - Commerce", - targetBranch: "main", - state: "completed", - entries: [ - { prId: "pr-q1", laneId: "lane-payments", laneName: "feature/payments", position: 0, prNumber: 160, githubUrl: "https://github.com/mock/repo/pull/160", state: "ready", changedFiles: ["payments.ts"], updatedAt: yesterday }, - { prId: "pr-q2", laneId: "lane-checkout", laneName: "feature/checkout-flow", position: 1, prNumber: 161, githubUrl: "https://github.com/mock/repo/pull/161", state: "resolved", resolvedByAi: true, changedFiles: ["checkout.ts"], conflictPaths: ["checkout.ts"], updatedAt: now }, - ], - currentPosition: 2, - scratchLaneId: "lane-queue-rehearsal", - activePrId: null, - activeResolverRunId: null, - lastError: null, - waitReason: null, - config: { - method: "squash", - autoResolve: true, - resolverProvider: "claude", - resolverModel: "anthropic/claude-sonnet-4-6", - reasoningEffort: "medium", - permissionMode: "guarded_edit", - preserveScratchLane: true, - originSurface: "queue", - originMissionId: null, - originRunId: null, - originLabel: "Release v3.0 - Commerce", - }, - startedAt: yesterday, - completedAt: now, - updatedAt: now, - }, -}; - // ── Integration simulation result ───────────────────────────── const MOCK_INTEGRATION_SIMULATION: any = { proposalId: "sim-mock-1", @@ -1455,6 +1419,7 @@ if (typeof window !== "undefined" && !(window as any).ade) { getChecks: async (prId: string) => MOCK_CHECKS_BY_PR[prId] ?? [], getComments: async (prId: string) => MOCK_COMMENTS_BY_PR[prId] ?? [], getReviews: async (prId: string) => MOCK_REVIEWS_BY_PR[prId] ?? [], + getReviewThreads: resolvedArg([]), updateDescription: resolvedArg(undefined), delete: resolvedArg({ deleted: true }), draftDescription: resolvedArg({ title: "AI-drafted title", body: "AI-drafted body" }), @@ -1502,29 +1467,9 @@ if (typeof window !== "undefined" && !(window as any).ade) { if (state) state.state = "cancelled"; return state; }, - startQueueRehearsal: async (args: { groupId: string; autoResolve?: boolean; method?: string; resolverModel?: string; reasoningEffort?: string }) => { - const state = MOCK_QUEUE_REHEARSAL_STATE[args.groupId]; - if (!state) throw new Error(`Unknown queue group: ${args.groupId}`); - state.state = "running"; - state.config = { - ...state.config, - autoResolve: args.autoResolve ?? state.config.autoResolve, - method: args.method ?? state.config.method, - resolverModel: args.resolverModel ?? state.config.resolverModel, - reasoningEffort: args.reasoningEffort ?? state.config.reasoningEffort, - }; - return state; - }, - cancelQueueRehearsal: async (rehearsalId: string) => { - const state = Object.values(MOCK_QUEUE_REHEARSAL_STATE).find((candidate) => candidate.rehearsalId === rehearsalId) ?? null; - if (state) state.state = "cancelled"; - return state; - }, getHealth: resolvedArg({}), getQueueState: async (groupId: string) => MOCK_QUEUE_STATE[groupId] ?? null, listQueueStates: async () => Object.values(MOCK_QUEUE_STATE), - getQueueRehearsalState: async (groupId: string) => MOCK_QUEUE_REHEARSAL_STATE[groupId] ?? null, - listQueueRehearsals: async () => Object.values(MOCK_QUEUE_REHEARSAL_STATE), getConflictAnalysis: resolvedArg({}), getMergeContext: async (prId: string) => MOCK_MERGE_CONTEXTS[prId] ?? { prId, groupId: null, groupType: null, sourceLaneIds: [], targetLaneId: null, integrationLaneId: null, members: [] }, @@ -1539,6 +1484,15 @@ if (typeof window !== "undefined" && !(window as any).ade) { error: null, context: { sourceTab: "normal" as const, laneId: "lane-1" } }), + issueResolutionStart: async () => ({ + sessionId: "mock-pr-issue-session", + laneId: "lane-dashboard", + href: "/work?laneId=lane-dashboard&sessionId=mock-pr-issue-session", + }), + issueResolutionPreviewPrompt: async () => ({ + title: "Resolve PR #1 issues", + prompt: "Mock PR issue resolver prompt", + }), aiResolutionInput: resolvedArg(undefined), aiResolutionStop: resolvedArg(undefined), onAiResolutionEvent: noop, @@ -1548,6 +1502,8 @@ if (typeof window !== "undefined" && !(window as any).ade) { getActionRuns: resolvedArg([]), getActivity: resolvedArg([]), addComment: resolvedArg({ id: "mock", author: "you", body: "", source: "issue", url: null, path: null, line: null, createdAt: null, updatedAt: null }), + replyToReviewThread: resolvedArg({ id: "thread-reply", author: "you", authorAvatarUrl: null, body: "", url: null, createdAt: null, updatedAt: null }), + resolveReviewThread: resolvedArg(undefined), updateTitle: resolvedArg(undefined), updateBody: resolvedArg(undefined), setLabels: resolvedArg(undefined), diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index ee7256fa..09f87e71 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -3874,7 +3874,6 @@ function GraphInner() { mergeMethod={prDialog.mergeMethod} onRefresh={refreshPrDialogDetail} onNavigate={(path) => navigate(path)} - onTabChange={() => {}} onShowInGraph={(laneId) => navigate(`/graph?focusLane=${encodeURIComponent(laneId)}`)} /> diff --git a/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx b/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx index 8eb08b6d..233f027d 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneRebaseBanner.tsx @@ -77,7 +77,7 @@ export function LaneRebaseBanner({ {s.behindCount} BEHIND
- Rebase this lane onto its parent to pick up new commits. + Rebase this lane onto {s.baseLabel?.trim() || "its parent"} to pick up new commits.
diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index b395aaac..f14e3ca2 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -1597,7 +1597,7 @@ export function LanesPage() { display: "inline-flex", alignItems: "center", padding: "2px 6px", borderRadius: 6, fontFamily: MONO_FONT, fontSize: 9, fontWeight: 700, color: COLORS.warning, background: `${COLORS.warning}18`, border: `1px solid ${COLORS.warning}30`, - }} title={`Behind parent by ${rebaseSuggestion.behindCount} commit(s)`}> + }} title={`Behind ${rebaseSuggestion.baseLabel?.trim() || "base"} by ${rebaseSuggestion.behindCount} commit(s)`}> ↑{rebaseSuggestion.behindCount} ) : null} diff --git a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx index 606c6a06..c17a4c1b 100644 --- a/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx +++ b/apps/desktop/src/renderer/components/missions/CreateMissionDialog.tsx @@ -1014,7 +1014,6 @@ function CreateMissionDialogInner({ autoRebase: true, ciGating: true, autoLand: false, - rehearseQueue: false, autoResolveConflicts: false, archiveLaneOnLand: false, mergeMethod: "squash" as MergeMethod, @@ -1111,24 +1110,6 @@ function CreateMissionDialogInner({ /> CI gating -
diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx new file mode 100644 index 00000000..1241b3bc --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment jsdom + +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LaneSummary } from "../../../shared/types"; + +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "lane", + laneType: "worktree", + baseRef: "origin/main", + branchRef: "feature/lane", + worktreePath: "/tmp/lane-1", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-03-23T12:00:00.000Z", + ...overrides, + }; +} + +const mockLanes: LaneSummary[] = [ + makeLane({ + id: "lane-primary", + name: "main", + laneType: "primary", + branchRef: "main", + worktreePath: "/tmp/main", + childCount: 2, + }), + makeLane({ + id: "lane-1", + name: "01 queue lane", + branchRef: "feature/queue-1", + worktreePath: "/tmp/lane-1", + parentLaneId: "lane-primary", + stackDepth: 1, + status: { dirty: false, ahead: 1, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-23T12:01:00.000Z", + }), + makeLane({ + id: "lane-2", + name: "02 queue lane", + branchRef: "feature/queue-2", + worktreePath: "/tmp/lane-2", + parentLaneId: "lane-primary", + stackDepth: 1, + status: { dirty: false, ahead: 2, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-23T12:02:00.000Z", + }), +]; + +vi.mock("../../state/appStore", () => ({ + useAppStore: (selector: (state: { lanes: LaneSummary[] }) => unknown) => selector({ lanes: mockLanes }), +})); + +import { CreatePrModal } from "./CreatePrModal"; + +describe("CreatePrModal queue workflow", () => { + const originalAde = globalThis.window.ade; + const createQueue = vi.fn(); + + beforeEach(() => { + createQueue.mockReset(); + createQueue.mockResolvedValue({ + groupId: "queue-group-1", + prs: [], + errors: [], + }); + + globalThis.window.ade = { + prs: { + createQueue, + }, + git: { + getSyncStatus: vi.fn().mockResolvedValue(null), + }, + } as any; + }); + + afterEach(() => { + globalThis.window.ade = originalAde; + cleanup(); + }); + + it("adds selected lanes to the queue order and removes them from the queue builder", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByRole("button", { name: /queue workflow/i })[0]!); + await user.click(screen.getByRole("checkbox", { name: /01 queue lane/i })); + await user.click(screen.getByRole("checkbox", { name: /02 queue lane/i })); + + expect(document.querySelectorAll("[data-queue-lane-id]").length).toBe(2); + + const laneOneRow = document.querySelector('[data-queue-lane-id="lane-1"]'); + expect(laneOneRow).toBeTruthy(); + await user.click(within(laneOneRow as HTMLElement).getByTitle("Remove lane from queue")); + + expect(document.querySelectorAll("[data-queue-lane-id]").length).toBe(1); + expect(document.querySelector('[data-queue-lane-id="lane-1"]')).toBeNull(); + expect((screen.getByRole("checkbox", { name: /01 queue lane/i }) as HTMLInputElement).checked).toBe(false); + }); + + it("uses the dragged queue order when creating queue PRs", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByRole("button", { name: /queue workflow/i })[0]!); + await user.click(screen.getByRole("checkbox", { name: /01 queue lane/i })); + await user.click(screen.getByRole("checkbox", { name: /02 queue lane/i })); + + const laneOneRow = document.querySelector('[data-queue-lane-id="lane-1"]'); + const laneTwoRow = document.querySelector('[data-queue-lane-id="lane-2"]'); + expect(laneOneRow).toBeTruthy(); + expect(laneTwoRow).toBeTruthy(); + + fireEvent.dragStart(laneTwoRow as HTMLElement); + fireEvent.dragOver(laneOneRow as HTMLElement); + fireEvent.drop(laneOneRow as HTMLElement); + + await user.click(screen.getByRole("button", { name: /next step/i })); + await user.click(screen.getByRole("button", { name: /create pr/i })); + + await waitFor(() => expect(createQueue).toHaveBeenCalledTimes(1)); + expect(createQueue).toHaveBeenCalledWith( + expect.objectContaining({ + laneIds: ["lane-2", "lane-1"], + targetBranch: "main", + }), + ); + }); +}); diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index e452e36a..07cf0a86 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -1,13 +1,12 @@ import React from "react"; import * as Dialog from "@radix-ui/react-dialog"; -import { GitPullRequest, GitMerge, Stack as Layers, CheckCircle, Warning, CircleNotch, X, GitBranch, Sparkle, ArrowRight, ArrowLeft, Check } from "@phosphor-icons/react"; +import { GitPullRequest, GitMerge, Stack as Layers, CheckCircle, Warning, CircleNotch, X, GitBranch, Sparkle, ArrowRight, ArrowLeft, Check, DotsSixVertical, Trash } from "@phosphor-icons/react"; import { useAppStore } from "../../state/appStore"; import type { MergeMethod, PrSummary, IntegrationProposal, IntegrationProposalStep, - CreateQueuePrsResult, CreateIntegrationPrResult, GitUpstreamSyncStatus, } from "../../../shared/types"; @@ -16,7 +15,6 @@ import { isDirtyWorktreeErrorMessage, stripDirtyWorktreePrefix } from "./shared/ import { buildLaneRebaseRecommendedLaneIds, describeLanePrIssues } from "./shared/lanePrWarnings"; type CreateMode = "normal" | "queue" | "integration"; -type WizardStep = "select-type" | "configure" | "execute"; /** Alias mapping from old `C` tokens to centralized COLORS. */ const C = { @@ -77,6 +75,17 @@ const textareaStyle: React.CSSProperties = { resize: "none" as const, }; +const errorBannerStyle: React.CSSProperties = { + background: `${C.error}0D`, + border: `1px solid ${C.error}33`, + borderRadius: 0, + padding: "10px 14px", + fontSize: 11, + fontFamily: "'JetBrains Mono', monospace", + color: C.error, + whiteSpace: "pre-wrap", +}; + function StepOutcome({ outcome }: { outcome: IntegrationProposalStep["outcome"] }) { if (outcome === "clean") return ; if (outcome === "conflict") return ; @@ -211,6 +220,12 @@ function buildLaneWarningSummaries(args: { .filter((item): item is LaneWarningSummary => item != null); } +function outcomeColor(outcome: string): string { + if (outcome === "clean") return C.success; + if (outcome === "conflict") return C.warning; + return C.error; +} + function getCreateActionLabel(mode: CreateMode, busy: boolean): string { if (busy) { return mode === "integration" ? "SAVING..." : "CREATING..."; @@ -218,6 +233,108 @@ function getCreateActionLabel(mode: CreateMode, busy: boolean): string { return mode === "integration" ? "SAVE PROPOSAL" : "CREATE PR"; } +export function reorderQueueLaneIds(queueLaneIds: string[], draggedLaneId: string, targetLaneId: string): string[] { + const draggedIndex = queueLaneIds.indexOf(draggedLaneId); + const targetIndex = queueLaneIds.indexOf(targetLaneId); + if (draggedIndex < 0 || targetIndex < 0 || draggedIndex === targetIndex) return queueLaneIds; + + const next = [...queueLaneIds]; + const [moved] = next.splice(draggedIndex, 1); + next.splice(targetIndex, 0, moved); + return next; +} + +function LaneCheckboxList({ + lanes: displayLanes, + selectedIds, + warningItemsById, + onToggle, + maxHeight = 240, +}: { + lanes: Array<{ id: string; name: string; branchRef: string }>; + selectedIds: string[]; + warningItemsById: Record; + onToggle: (laneId: string) => void; + maxHeight?: number; +}) { + return ( +
+ {displayLanes.map((lane) => { + const checked = selectedIds.includes(lane.id); + const warningCount = warningItemsById[lane.id]?.length ?? 0; + return ( + + ); + })} +
+ ); +} + function LaneWarningPanel({ items, loading, @@ -317,7 +434,6 @@ export function CreatePrModal({ const lanes = useAppStore((s) => s.lanes); const primaryLane = React.useMemo(() => lanes.find((l) => l.laneType === "primary") ?? null, [lanes]); - const [, setStep] = React.useState("select-type"); const [mode, setMode] = React.useState("normal"); // Internal numeric step for stepper (1=BRANCH, 2=DETAILS, 3=REVIEW) @@ -334,6 +450,7 @@ export function CreatePrModal({ // Queue PRs const [queueLaneIds, setQueueLaneIds] = React.useState([]); const [queueDraft, setQueueDraft] = React.useState(false); + const [queueDragLaneId, setQueueDragLaneId] = React.useState(null); // Body & AI draft const [normalBody, setNormalBody] = React.useState(""); @@ -380,7 +497,6 @@ export function CreatePrModal({ React.useEffect(() => { if (open) return; const id = setTimeout(() => { - setStep("select-type"); setMode("normal"); setNumericStep(1); setMergeMethod("squash"); @@ -389,6 +505,7 @@ export function CreatePrModal({ setNormalDraft(false); setQueueLaneIds([]); setQueueDraft(false); + setQueueDragLaneId(null); setBusy(false); setExecError(null); setResults(null); @@ -514,11 +631,9 @@ export function CreatePrModal({ // No PR created — proposal saved for later commit from Integration tab setResults([]); } - setStep("execute"); setNumericStep(3); } catch (err: unknown) { setExecError(err instanceof Error ? err.message : String(err)); - setStep("execute"); setNumericStep(3); } finally { setBusy(false); @@ -533,6 +648,15 @@ export function CreatePrModal({ ); }; + const handleQueueLaneDrop = React.useCallback((targetLaneId: string) => { + if (!queueDragLaneId || queueDragLaneId === targetLaneId) { + setQueueDragLaneId(null); + return; + } + setQueueLaneIds((prev) => reorderQueueLaneIds(prev, queueDragLaneId, targetLaneId)); + setQueueDragLaneId(null); + }, [queueDragLaneId]); + const toggleIntegrationSource = (laneId: string) => { setIntegrationSources((prev) => prev.includes(laneId) ? prev.filter((id) => id !== laneId) : [...prev, laneId] @@ -546,14 +670,8 @@ export function CreatePrModal({ !!proposal && proposal.overallOutcome !== "blocked"; - /* Can user advance from step 1 to step 2? */ - const canAdvanceStep1 = - (mode === "normal" && !!normalLaneId) || - (mode === "queue" && queueLaneIds.length > 0) || - (mode === "integration" && canCreateIntegration); - - /* Can user create from step 2? */ - const canCreateFromStep2 = + /* Can user advance to step 2 or submit from step 2? Same readiness check. */ + const canProceed = (mode === "normal" && !!normalLaneId) || (mode === "queue" && queueLaneIds.length > 0) || (mode === "integration" && canCreateIntegration); @@ -891,80 +1009,124 @@ export function CreatePrModal({ {/* ── Queue mode: Lane selection ─────────────────── */} {mode === "queue" && (
- SELECT LANES (IN QUEUE ORDER) -
- {nonPrimaryLanes.map((lane) => { - const checked = queueLaneIds.includes(lane.id); - const warningCount = laneWarningItemsById[lane.id]?.length ?? 0; - return ( -
)} @@ -1708,18 +1768,7 @@ export function CreatePrModal({ {/* Error display */} {execError && ( -
- {execError} -
+
{execError}
)} )} @@ -1792,10 +1841,10 @@ export function CreatePrModal({
{numericStep === 1 && ( + +
+ ) : null), +})); + +import { PrDetailPane } from "./PrDetailPane"; + +function makeCheck(overrides: Partial = {}): PrCheck { + return { name: "ci / unit", status: "completed", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null, ...overrides }; +} + +function makeThread(overrides: Partial = {}): PrReviewThread { + return { + id: "thread-1", + isResolved: false, + isOutdated: false, + path: "src/prs.ts", + line: 18, + originalLine: 18, + startLine: null, + originalStartLine: null, + diffSide: "RIGHT", + url: null, + createdAt: null, + updatedAt: null, + comments: [{ id: "comment-1", author: "reviewer", authorAvatarUrl: null, body: "Please tighten this logic.", url: null, createdAt: null, updatedAt: null }], + ...overrides, + }; +} + +const visibilityCases: Array<{ + name: string; + checks: PrCheck[]; + reviewThreads: PrReviewThread[]; + visible: boolean; +}> = [ + { + name: "shows for failed checks only", + checks: [makeCheck()], + reviewThreads: [], + visible: true, + }, + { + name: "hides while checks are still running", + checks: [ + makeCheck(), + makeCheck({ name: "ci / lint", status: "in_progress", conclusion: null }), + ], + reviewThreads: [], + visible: false, + }, + { + name: "shows for unresolved review threads only", + checks: [makeCheck({ conclusion: "success" })], + reviewThreads: [makeThread()], + visible: true, + }, + { + name: "hides when nothing actionable remains", + checks: [makeCheck({ conclusion: "success" })], + reviewThreads: [makeThread({ + isResolved: true, + comments: [{ id: "comment-1", author: "reviewer", authorAvatarUrl: null, body: "Looks good now.", url: null, createdAt: null, updatedAt: null }], + })], + visible: false, + }, +]; + +function makePr(): PrWithConflicts { + return { + id: "pr-80", + laneId: "lane-1", + projectId: "project-1", + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 80, + githubUrl: "https://github.com/ade-dev/ade/pull/80", + githubNodeId: "PR_kwDOExample", + title: "Stabilize GitHub PR flows", + state: "open", + baseBranch: "main", + headBranch: "feature/pr-80", + checksStatus: "failing", + reviewStatus: "changes_requested", + additions: 25, + deletions: 8, + lastSyncedAt: "2026-03-23T12:00:00.000Z", + createdAt: "2026-03-23T11:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + conflictAnalysis: null, + }; +} + +function makeLane(): LaneSummary { + return { + id: "lane-1", + name: "feature/pr-80", + description: "Tighten the GitHub PR lane.", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/pr-80", + worktreePath: "/tmp/lane-1", + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-23T10:00:00.000Z", + archivedAt: null, + }; +} + +function makeStatus(): PrStatus { + return { + prId: "pr-80", + state: "open", + checksStatus: "failing", + reviewStatus: "changes_requested", + isMergeable: false, + mergeConflicts: false, + behindBaseBy: 0, + }; +} + +function renderPane(args: { + checks: PrCheck[]; + reviewThreads: PrReviewThread[]; + lanes?: LaneSummary[]; + onNavigate?: (path: string) => void; + activity?: PrActivityEvent[]; +}) { + const issueResolutionStart = vi.fn().mockResolvedValue({ + sessionId: "session-1", + laneId: "lane-1", + href: "/work?laneId=lane-1&sessionId=session-1", + }); + const issueResolutionPreviewPrompt = vi.fn().mockResolvedValue({ + title: "Resolve PR #80 issues", + prompt: "Prepared issue resolver prompt", + }); + const getReviewThreads = vi.fn().mockResolvedValue(args.reviewThreads); + const writeClipboardText = vi.fn().mockResolvedValue(undefined); + Object.assign(window, { + ade: { + prs: { + getDetail: vi.fn().mockResolvedValue({ + prId: "pr-80", + body: "This PR improves GitHub PR flows.", + labels: [], + assignees: [], + requestedReviewers: [], + author: { login: "octocat", avatarUrl: null }, + isDraft: false, + milestone: null, + linkedIssues: [], + }), + getFiles: vi.fn().mockResolvedValue([]), + getActionRuns: vi.fn().mockResolvedValue([]), + getActivity: vi.fn().mockResolvedValue(args.activity ?? []), + getReviewThreads, + issueResolutionStart, + issueResolutionPreviewPrompt, + openInGitHub: vi.fn().mockResolvedValue(undefined), + }, + app: { + openExternal: vi.fn(), + writeClipboardText, + }, + }, + }); + + return { + issueResolutionStart, + issueResolutionPreviewPrompt, + getReviewThreads, + writeClipboardText, + ...render( + , + ), + }; +} + +describe("PrDetailPane issue resolver CTA", () => { + beforeEach(() => { + mockUsePrs.mockReturnValue({ + resolverModel: "openai/gpt-5.4-codex", + resolverReasoningLevel: "high", + resolverPermissionMode: "guarded_edit", + setResolverModel: vi.fn(), + setResolverReasoningLevel: vi.fn(), + setResolverPermissionMode: vi.fn(), + }); + }); + + afterEach(() => { + cleanup(); + }); + + it.each(visibilityCases)("$name", async ({ checks, reviewThreads, visible }) => { + renderPane({ checks, reviewThreads }); + + await waitFor(() => { + if (visible) { + expect(screen.getByRole("button", { name: /resolve issues with agent/i })).toBeTruthy(); + } else { + expect(screen.queryByRole("button", { name: /resolve issues with agent/i })).toBeNull(); + } + }); + }); + + it("shows the action in both the header and the checks tab when issues are actionable", async () => { + const user = userEvent.setup(); + renderPane({ + checks: [makeCheck()], + reviewThreads: [makeThread()], + }); + + await user.click(screen.getByRole("button", { name: /ci \/ checks/i })); + + await waitFor(() => { + expect(screen.getAllByRole("button", { name: /resolve issues with agent/i }).length).toBeGreaterThanOrEqual(2); + }); + }); + + it("launches the issue resolver chat and navigates to the work session", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + const { issueResolutionStart } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + onNavigate, + }); + + await user.click(await screen.findByRole("button", { name: /resolve issues with agent/i })); + await user.click(screen.getByRole("button", { name: /launch resolver/i })); + + await waitFor(() => { + expect(issueResolutionStart).toHaveBeenCalledWith(expect.objectContaining({ + prId: "pr-80", + scope: "checks", + additionalInstructions: "extra context", + })); + expect(onNavigate).toHaveBeenCalledWith("/work?laneId=lane-1&sessionId=session-1"); + }); + }); + + it("reloads review threads when opening the resolver", async () => { + const user = userEvent.setup(); + const { getReviewThreads } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + }); + + await waitFor(() => { + expect(getReviewThreads).toHaveBeenCalledTimes(1); + }); + + await user.click(await screen.findByRole("button", { name: /resolve issues with agent/i })); + + await waitFor(() => { + expect(getReviewThreads).toHaveBeenCalledTimes(2); + }); + }); + + it("copies the prepared resolver prompt to the clipboard", async () => { + const user = userEvent.setup(); + const { issueResolutionPreviewPrompt, writeClipboardText } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + }); + + await user.click(await screen.findByRole("button", { name: /resolve issues with agent/i })); + await user.click(screen.getByRole("button", { name: /copy resolver prompt/i })); + + await waitFor(() => { + expect(issueResolutionPreviewPrompt).toHaveBeenCalledWith(expect.objectContaining({ + prId: "pr-80", + scope: "checks", + additionalInstructions: "extra context", + })); + expect(writeClipboardText).toHaveBeenCalledWith("Prepared issue resolver prompt"); + }); + }); + + it("renders review activity bodies as markdown instead of raw source text", async () => { + renderPane({ + checks: [makeCheck()], + reviewThreads: [], + activity: [ + { + id: "review-1", + type: "review", + author: "coderabbitai[bot]", + avatarUrl: null, + body: "**Actionable comments posted: 3**\n\n
Prompt for AI AgentsUse the current code.
", + timestamp: "2026-03-23T12:00:00.000Z", + metadata: { state: "commented" }, + }, + ], + }); + + 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 f9c88658..d4f3173b 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -6,15 +6,18 @@ import { GitBranch, GitMerge, GitCommit, GithubLogo, CheckCircle, XCircle, Circle, CircleNotch, Sparkle, ArrowRight, Eye, ChatText, Code, ClockCounterClockwise, PencilSimple, X, Check, ArrowsClockwise, Warning, Play, Rocket, Tag, - CaretDown, CaretRight, UserCircle, DotsThreeVertical, Robot, Trash, Archive, + CaretDown, CaretRight, UserCircle, DotsThreeVertical, Robot, Trash, Archive, Stack as Layers, } from "@phosphor-icons/react"; import type { PrWithConflicts, PrCheck, PrReview, PrComment, PrStatus, PrDetail, - PrFile, PrActionRun, PrActivityEvent, AiReviewSummary, + PrFile, PrActionRun, PrActivityEvent, AiReviewSummary, PrReviewThread, LaneSummary, MergeMethod, LandResult, } from "../../../../shared/types"; -import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, recessedStyle, inlineBadge, outlineButton, primaryButton, dangerButton } from "../../lanes/laneDesignTokens"; +import { getPrIssueResolutionAvailability } from "../../../../shared/prIssueResolution"; +import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, inlineBadge, outlineButton, primaryButton, dangerButton } from "../../lanes/laneDesignTokens"; import { getPrChecksBadge, getPrReviewsBadge, getPrStateBadge, InlinePrBadge } from "../shared/prVisuals"; +import { PrIssueResolverModal } from "../shared/PrIssueResolverModal"; +import { usePrs } from "../state/PrsContext"; // ---- Sub-tab type ---- type DetailTab = "overview" | "files" | "checks" | "activity"; @@ -235,6 +238,50 @@ function CheckIcon({ check }: { check: PrCheck }) { return ; } +// ---- Shared activity event helpers (used by OverviewTab and ActivityTab) ---- +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; + if (ev.type === "state_change") return COLORS.success; + if (ev.type === "deployment") return COLORS.success; + if (ev.type === "force_push") return COLORS.warning; + if (ev.type === "commit") return COLORS.accent; + if (ev.type === "ci_run") return COLORS.warning; + if (ev.type === "label") return COLORS.info; + return COLORS.textMuted; +} + +function activityEventLabel(ev: PrActivityEvent): string { + if (ev.type === "comment") return ev.metadata?.source === "review" ? "review comment" : "comment"; + if (ev.type === "review") return "review"; + if (ev.type === "state_change") return "state change"; + if (ev.type === "deployment") return "deployed"; + if (ev.type === "force_push") return "force push"; + if (ev.type === "commit") return "commit"; + if (ev.type === "ci_run") return "CI"; + if (ev.type === "label") return "label"; + if (ev.type === "review_request") return "review request"; + return String(ev.type).replace(/_/g, " "); +} + +function ActivityEventIcon({ event, withGlow }: { event: PrActivityEvent; withGlow?: boolean }) { + const col = activityEventColor(event); + const s = withGlow + ? { color: col, filter: `drop-shadow(0 0 3px ${col}40)` } + : { color: col, flexShrink: 0 as const }; + + if (event.type === "comment") return ; + if (event.type === "review") return ; + if (event.type === "state_change") return ; + if (event.type === "deployment") return ; + if (event.type === "force_push") return ; + if (event.type === "commit") return ; + if (event.type === "ci_run") return ; + if (event.type === "label") return ; + if (event.type === "review_request") return ; + return ; +} + const FILE_STATUS_COLORS: Record = { added: COLORS.success, removed: COLORS.danger, @@ -270,19 +317,49 @@ type PrDetailPaneProps = { mergeMethod: MergeMethod; onRefresh: () => Promise; onNavigate: (path: string) => void; - onTabChange: (tab: string) => void; onShowInGraph?: (laneId: string) => void; onOpenRebaseTab?: () => void; + queueContext?: { groupId: string; label?: string | null } | null; + onOpenQueueView?: (groupId: string) => void; }; -export function PrDetailPane({ pr, status, checks, reviews, comments, detailBusy, lanes, mergeMethod, onRefresh, onNavigate, onTabChange, onShowInGraph, onOpenRebaseTab }: PrDetailPaneProps) { +export function PrDetailPane({ + pr, + status, + checks, + reviews, + comments, + detailBusy, + lanes, + mergeMethod, + onRefresh, + onNavigate, + onShowInGraph, + onOpenRebaseTab, + queueContext, + onOpenQueueView, +}: PrDetailPaneProps) { + const { + resolverModel, + resolverReasoningLevel, + resolverPermissionMode, + setResolverModel, + setResolverReasoningLevel, + setResolverPermissionMode, + } = usePrs(); const [activeTab, setActiveTab] = React.useState("overview"); const [detail, setDetail] = React.useState(null); const [files, setFiles] = React.useState([]); const [actionRuns, setActionRuns] = React.useState([]); const [activity, setActivity] = React.useState([]); + const [reviewThreads, setReviewThreads] = React.useState([]); const [aiSummary, setAiSummary] = React.useState(null); const [aiSummaryBusy, setAiSummaryBusy] = React.useState(false); + const [showIssueResolverModal, setShowIssueResolverModal] = React.useState(false); + const [issueResolverBusy, setIssueResolverBusy] = React.useState(false); + const [issueResolverCopyBusy, setIssueResolverCopyBusy] = React.useState(false); + const [issueResolverCopyNotice, setIssueResolverCopyNotice] = React.useState(null); + const [issueResolverError, setIssueResolverError] = React.useState(null); // Action states const [actionBusy, setActionBusy] = React.useState(false); @@ -307,17 +384,19 @@ export function PrDetailPane({ pr, status, checks, reviews, comments, detailBusy const loadDetail = React.useCallback(async () => { const requestId = ++detailLoadSeqRef.current; try { - const [d, f, a, act] = await Promise.all([ + const [d, f, a, act, threads] = await Promise.all([ window.ade.prs.getDetail(pr.id).catch(() => null), window.ade.prs.getFiles(pr.id).catch(() => []), window.ade.prs.getActionRuns(pr.id).catch(() => []), window.ade.prs.getActivity(pr.id).catch(() => []), + window.ade.prs.getReviewThreads(pr.id).catch(() => []), ]); if (requestId !== detailLoadSeqRef.current) return; setDetail(d); setFiles(f); setActionRuns(a); setActivity(act); + setReviewThreads(threads); } catch { // silently fail - basic data still available from context } @@ -329,9 +408,15 @@ export function PrDetailPane({ pr, status, checks, reviews, comments, detailBusy setFiles([]); setActionRuns([]); setActivity([]); + setReviewThreads([]); setAiSummary(null); setActionError(null); setActionResult(null); + setIssueResolverError(null); + setIssueResolverBusy(false); + setIssueResolverCopyBusy(false); + setIssueResolverCopyNotice(null); + setShowIssueResolverModal(false); setEditingTitle(false); setEditingBody(false); setShowLabelEditor(false); @@ -344,6 +429,12 @@ export function PrDetailPane({ pr, status, checks, reviews, comments, detailBusy }; }, [loadDetail, pr.id]); + React.useEffect(() => { + if (!issueResolverCopyNotice) return; + const timer = window.setTimeout(() => setIssueResolverCopyNotice(null), 2500); + return () => window.clearTimeout(timer); + }, [issueResolverCopyNotice]); + // ---- Action helper to reduce repetitive try/catch/finally ---- const runAction = async (fn: () => Promise) => { setActionBusy(true); @@ -439,6 +530,83 @@ export function PrDetailPane({ pr, status, checks, reviews, comments, detailBusy } finally { setAiSummaryBusy(false); } }; + const laneForPr = React.useMemo( + () => lanes.find((lane) => lane.id === pr.laneId && !lane.archivedAt) ?? null, + [lanes, pr.laneId], + ); + const issueResolutionAvailability = React.useMemo(() => { + const availability = getPrIssueResolutionAvailability(checks, reviewThreads); + if (laneForPr) return availability; + return { + ...availability, + hasActionableChecks: false, + hasActionableComments: false, + hasAnyActionableIssues: false, + }; + }, [checks, laneForPr, reviewThreads]); + + const handleOpenIssueResolver = React.useCallback(() => { + setIssueResolverError(null); + setIssueResolverCopyNotice(null); + setShowIssueResolverModal(true); + void loadDetail(); + }, [loadDetail]); + + const handleLaunchIssueResolver = React.useCallback(async ( + args: { scope: "checks" | "comments" | "both"; additionalInstructions: string }, + ) => { + setIssueResolverBusy(true); + setIssueResolverError(null); + try { + const result = await window.ade.prs.issueResolutionStart({ + prId: pr.id, + scope: args.scope, + modelId: resolverModel, + reasoning: resolverReasoningLevel || null, + permissionMode: resolverPermissionMode, + additionalInstructions: args.additionalInstructions, + }); + setShowIssueResolverModal(false); + onNavigate(result.href); + } catch (err: unknown) { + setIssueResolverError(err instanceof Error ? err.message : String(err)); + } finally { + setIssueResolverBusy(false); + } + }, [onNavigate, pr.id, resolverModel, resolverPermissionMode, resolverReasoningLevel]); + + const handleCopyIssueResolverPrompt = React.useCallback(async ( + args: { scope: "checks" | "comments" | "both"; additionalInstructions: string }, + ) => { + setIssueResolverCopyBusy(true); + setIssueResolverError(null); + setIssueResolverCopyNotice(null); + try { + const preview = await window.ade.prs.issueResolutionPreviewPrompt({ + prId: pr.id, + scope: args.scope, + modelId: resolverModel, + reasoning: resolverReasoningLevel || null, + permissionMode: resolverPermissionMode, + additionalInstructions: args.additionalInstructions, + }); + if (window.ade?.app?.writeClipboardText) { + await window.ade.app.writeClipboardText(preview.prompt); + } else if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(preview.prompt); + } else { + throw new Error("Clipboard access is not available in this environment."); + } + setIssueResolverCopyNotice("Prompt copied to clipboard."); + } catch (err: unknown) { + setIssueResolverError(err instanceof Error ? err.message : String(err)); + } finally { + setIssueResolverCopyBusy(false); + } + }, [pr.id, resolverModel, resolverPermissionMode, resolverReasoningLevel]); + + const localBehindCount = laneForPr?.status?.behind ?? 0; + const sc = getPrStateBadge(pr.state); const cc = getPrChecksBadge(pr.checksStatus); const rc = getPrReviewsBadge(pr.reviewStatus); @@ -539,7 +707,7 @@ export function PrDetailPane({ pr, status, checks, reviews, comments, detailBusy borderTop: "none", borderLeft: "none", borderRight: "none", - borderRadius: isActive ? "8px 8px 0 0" : "8px 8px 0 0", + borderRadius: "8px 8px 0 0", cursor: "pointer", transition: "all 120ms ease", }} > @@ -562,9 +730,28 @@ export function PrDetailPane({ pr, status, checks, reviews, comments, detailBusy {/* Right-side action buttons */}
+ {issueResolutionAvailability.hasAnyActionableIssues ? ( + + ) : null} + {queueContext && onOpenQueueView ? ( + + ) : null} {onShowInGraph ? (
+ + { + if (!nextOpen) { + setIssueResolverError(null); + setIssueResolverCopyNotice(null); + } + setShowIssueResolverModal(nextOpen); + }} + onModelChange={setResolverModel} + onReasoningEffortChange={setResolverReasoningLevel} + onPermissionModeChange={setResolverPermissionMode} + onLaunch={handleLaunchIssueResolver} + onCopyPrompt={handleCopyIssueResolverPrompt} + /> ); } @@ -970,48 +1184,6 @@ function OverviewTab(props: OverviewTabProps) { ); })()} - {/* ---- PR Description ---- */} -
-
- Description -
- - {!props.editingBody && ( - - )} -
-
- {props.editingBody ? ( -
-