diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 04c245598f..d7bd197f27 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -584,6 +584,11 @@ container getWorkspace: (taskId) => workspace().getWorkspace(taskId), linkBranch: (taskId, branch, source) => workspace().linkBranch(taskId, branch, source), + accumulatePrUrl: (taskId, prUrl) => { + void ctx + .get(GIT_PR_STATUS_PROVIDER) + .accumulatePrUrl(taskId, prUrl); + }, }; }); container.load(gitHostModule); diff --git a/packages/core/src/git-pr/git-pr.test.ts b/packages/core/src/git-pr/git-pr.test.ts index 869fcdd732..aa2ae111aa 100644 --- a/packages/core/src/git-pr/git-pr.test.ts +++ b/packages/core/src/git-pr/git-pr.test.ts @@ -126,6 +126,7 @@ function makeHost(over: Partial = {}): CreatePrHost { prUrl: "https://github.com/o/r/pull/1", }), linkBranch: vi.fn(), + accumulatePrUrl: vi.fn(), getPrState: vi.fn().mockResolvedValue({ prStatus: "open" }), ...over, }; @@ -163,6 +164,10 @@ describe("GitPrService.createPr", () => { }); expect(host.push).toHaveBeenCalledWith("/repo", undefined); expect(host.linkBranch).toHaveBeenCalledWith("task-1", "feature", "user"); + expect(host.accumulatePrUrl).toHaveBeenCalledWith( + "task-1", + "https://github.com/o/r/pull/1", + ); expect(onProgress).toHaveBeenLastCalledWith( "complete", "Pull request created", diff --git a/packages/core/src/git-pr/git-pr.ts b/packages/core/src/git-pr/git-pr.ts index 6c06bfd0d8..cc5e83a768 100644 --- a/packages/core/src/git-pr/git-pr.ts +++ b/packages/core/src/git-pr/git-pr.ts @@ -333,6 +333,9 @@ Rules: if (linkedBranch) { host.linkBranch(input.taskId, linkedBranch, "user"); } + if (result.data.prUrl) { + host.accumulatePrUrl(input.taskId, result.data.prUrl); + } } onProgress( diff --git a/packages/core/src/git-pr/identifiers.ts b/packages/core/src/git-pr/identifiers.ts index ed4e415a46..cde8b7bb26 100644 --- a/packages/core/src/git-pr/identifiers.ts +++ b/packages/core/src/git-pr/identifiers.ts @@ -100,5 +100,6 @@ export interface CreatePrHost { env?: Record, ): Promise<{ success: boolean; message: string; prUrl: string | null }>; linkBranch(taskId: string, branch: string, source: "user"): void; + accumulatePrUrl(taskId: string, prUrl: string): void; getPrState(directoryPath: string): Promise; } diff --git a/packages/core/src/git/git-host.ts b/packages/core/src/git/git-host.ts index 0084145d07..d5285b921f 100644 --- a/packages/core/src/git/git-host.ts +++ b/packages/core/src/git/git-host.ts @@ -185,6 +185,8 @@ export class GitHostService extends TypedEventEmitter { }), linkBranch: (taskId, branch, source) => this.workspaceLookup.linkBranch(taskId, branch, source), + accumulatePrUrl: (taskId, prUrl) => + this.workspaceLookup.accumulatePrUrl(taskId, prUrl), getPrState: (dir) => this.getPrStateSnapshot(dir), }; } diff --git a/packages/core/src/git/host-git.ts b/packages/core/src/git/host-git.ts index 0871da8f05..eb23158bb4 100644 --- a/packages/core/src/git/host-git.ts +++ b/packages/core/src/git/host-git.ts @@ -47,4 +47,5 @@ export interface GitPrWorkspaceInfo { export interface GitWorkspaceLookup { getWorkspace(taskId: string): Promise; linkBranch(taskId: string, branch: string, source: "user"): void; + accumulatePrUrl(taskId: string, prUrl: string): void; } diff --git a/packages/host-router/src/ports/git-pr-status.ts b/packages/host-router/src/ports/git-pr-status.ts index 9fc55205a2..d886e695da 100644 --- a/packages/host-router/src/ports/git-pr-status.ts +++ b/packages/host-router/src/ports/git-pr-status.ts @@ -14,4 +14,5 @@ export interface IGitPrStatus { ): Promise; getCachedPrUrl(taskId: string): CachedPrUrlOutput; setPrimaryPrUrl(taskId: string, prUrl: string): Promise; + accumulatePrUrl(taskId: string, prUrl: string): Promise; } diff --git a/packages/ui/src/features/git-interaction/utils/resolveTaskPrUrls.test.ts b/packages/ui/src/features/git-interaction/utils/resolveTaskPrUrls.test.ts index 72f0e049ca..e3cff1a310 100644 --- a/packages/ui/src/features/git-interaction/utils/resolveTaskPrUrls.test.ts +++ b/packages/ui/src/features/git-interaction/utils/resolveTaskPrUrls.test.ts @@ -37,6 +37,11 @@ describe("resolveTaskPrUrls", () => { { cloudUrls: [PR_1], cachedUrls: [PR_1, PR_2], currentBranchUrl: PR_1 }, { primaryUrl: PR_1, otherUrls: [PR_2] }, ], + [ + "two accumulated local PRs surface an Other PRs entry", + { cloudUrls: [], cachedUrls: [PR_1, PR_2], currentBranchUrl: PR_2 }, + { primaryUrl: PR_1, otherUrls: [PR_2] }, + ], [ "dedupes across sources preserving cloud order", { diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 9654cb30b7..9ed124f97b 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -2228,7 +2228,7 @@ For git operations while detached: this.fetchGhLogin(session.repoPath), ]); if (!wasCreatedRecently(attribution.createdAt, Date.now())) return; - if (!wasCreatedByLogin(attribution.author, ghLogin)) return; + if (ghLogin && !wasCreatedByLogin(attribution.author, ghLogin)) return; this.log.info("Detected PR URL created during run", { taskRunId, prUrl }); diff --git a/packages/workspace-server/src/services/git/task-pr-status.test.ts b/packages/workspace-server/src/services/git/task-pr-status.test.ts index ca8dd5e7ba..0b86aa1b0a 100644 --- a/packages/workspace-server/src/services/git/task-pr-status.test.ts +++ b/packages/workspace-server/src/services/git/task-pr-status.test.ts @@ -274,3 +274,89 @@ describe("TaskPrStatusService.setPrimaryPrUrl", () => { }); }); }); + +describe("TaskPrStatusService.accumulatePrUrl", () => { + const PR_A = "https://github.com/acme/repo/pull/1"; + const PR_B = "https://github.com/acme/repo/pull/2"; + + function makeService(existingUrls: string[]) { + const urls = [...existingUrls]; + const gitService = { + getPrDetailsByUrl: vi + .fn() + .mockResolvedValue({ state: "open", merged: false, draft: false }), + } as unknown as GitService; + const workspaceService = { emit: vi.fn() }; + const workspaceRepo = { + findByTaskId: vi.fn().mockReturnValue({ id: "ws-1" }), + updatePrCache: vi.fn((_taskId, update) => { + if (update.prUrl && update.accumulate && !urls.includes(update.prUrl)) { + urls.push(update.prUrl); + } + }), + getPrUrls: vi.fn(() => [...urls]), + }; + const service = new TaskPrStatusService( + gitService, + workspaceRepo as unknown as IWorkspaceRepository, + workspaceService as unknown as WorkspaceService, + ); + return { service, gitService, workspaceService, workspaceRepo }; + } + + it("records the first PR as a fresh accumulate and emits it", async () => { + const { service, workspaceService, workspaceRepo } = makeService([]); + + await service.accumulatePrUrl("task-1", PR_A); + + expect(workspaceRepo.updatePrCache).toHaveBeenCalledWith("task-1", { + prUrl: PR_A, + prState: "open", + accumulate: true, + }); + expect(workspaceService.emit).toHaveBeenCalledWith("taskPrInfoChanged", { + taskId: "task-1", + prUrl: PR_A, + prUrls: [PR_A], + prState: "open", + }); + }); + + it("appends a second distinct PR so both survive", async () => { + const { service, workspaceService } = makeService([PR_A]); + + await service.accumulatePrUrl("task-1", PR_B); + + expect(workspaceService.emit).toHaveBeenCalledWith("taskPrInfoChanged", { + taskId: "task-1", + prUrl: PR_B, + prUrls: [PR_A, PR_B], + prState: "open", + }); + }); + + it("is idempotent when re-accumulating an existing PR", async () => { + const { service, workspaceService } = makeService([PR_A]); + + await service.accumulatePrUrl("task-1", PR_A); + + expect(workspaceService.emit).toHaveBeenCalledWith("taskPrInfoChanged", { + taskId: "task-1", + prUrl: PR_A, + prUrls: [PR_A], + prState: "open", + }); + }); + + it("does nothing when the task has no workspace row", async () => { + const { service, gitService, workspaceService, workspaceRepo } = + makeService([]); + workspaceRepo.findByTaskId.mockReturnValue(null); + + await service.accumulatePrUrl("task-1", PR_A); + + expect(gitService.getPrDetailsByUrl).not.toHaveBeenCalled(); + expect(workspaceRepo.updatePrCache).not.toHaveBeenCalled(); + expect(workspaceService.emit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/workspace-server/src/services/git/task-pr-status.ts b/packages/workspace-server/src/services/git/task-pr-status.ts index d499c58087..ba7353f0c9 100644 --- a/packages/workspace-server/src/services/git/task-pr-status.ts +++ b/packages/workspace-server/src/services/git/task-pr-status.ts @@ -69,6 +69,27 @@ export class TaskPrStatusService { }); } + async accumulatePrUrl(taskId: string, prUrl: string): Promise { + if (!this.workspaceRepo.findByTaskId(taskId)) return; + const details = await this.gitService + .getPrDetailsByUrl(prUrl) + .catch(() => null); + const prState: SidebarPrState = details + ? mapPrState(details.state, details.merged, details.draft) + : null; + this.workspaceRepo.updatePrCache(taskId, { + prUrl, + prState, + accumulate: true, + }); + this.workspaceService.emit("taskPrInfoChanged", { + taskId, + prUrl, + prUrls: this.workspaceRepo.getPrUrls(taskId), + prState, + }); + } + private async computeWorktreeHasDiff(taskId: string): Promise { const workspace = await this.workspaceService.getWorkspace(taskId); if (