diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 96dcde474f..eb450d13b1 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -13,6 +13,7 @@ const mockIssueService = vi.hoisted(() => ({ findMentionedAgents: vi.fn(), listWakeableBlockedDependents: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), + getRelationSummaries: vi.fn(), })); const mockAccessService = vi.hoisted(() => ({ @@ -1240,6 +1241,62 @@ describe.sequential("issue comment reopen routes", () => { )); }); + it("does not implicitly reopen a blocked issue via PATCH when the same request wires blockers", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("blocked")); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.getDependencyReadiness.mockResolvedValue({ + issueId: "11111111-1111-4111-8111-111111111111", + blockerIssueIds: [], + unresolvedBlockerIssueIds: [], + unresolvedBlockerCount: 0, + allBlockersDone: true, + isDependencyReady: true, + }); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("blocked"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ + blockedByIssueIds: ["33333333-3333-4333-8333-333333333333"], + comment: "wired the dependency this issue is waiting on", + }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalled(); + const patch = mockIssueService.update.mock.calls[0][1] as Record; + expect(patch.status).toBeUndefined(); + expect(patch.blockedByIssueIds).toEqual(["33333333-3333-4333-8333-333333333333"]); + }); + + it("still implicitly reopens a blocked issue via PATCH when the same request clears blockers", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("blocked")); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.getDependencyReadiness.mockResolvedValue({ + issueId: "11111111-1111-4111-8111-111111111111", + blockerIssueIds: [], + unresolvedBlockerIssueIds: [], + unresolvedBlockerCount: 0, + allBlockersDone: true, + isDependencyReady: true, + }); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("blocked"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ blockedByIssueIds: [], comment: "nothing left to wait on, please continue" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalled(); + const patch = mockIssueService.update.mock.calls[0][1] as Record; + expect(patch.status).toBe("todo"); + }); + it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => { mockIssueService.getById.mockResolvedValue({ ...makeIssue("done"), diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 43ac00e6e4..dbdfe47064 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1836,7 +1836,14 @@ function shouldImplicitlyMoveCommentedIssueToTodo(input: { actorRunId: string | null | undefined; checkoutRunId: string | null | undefined; executionRunId: string | null | undefined; + requestAddsExplicitBlockers?: boolean; }) { + // A request that wires a non-empty blockedByIssueIds list is declaring that + // the issue is waiting on other work. The implicit reopen exists for plain + // conversational comments ("please continue"), not structured dependency + // edits — flipping to todo here would contradict the caller's stated intent + // in the same request. + if (input.requestAddsExplicitBlockers) return false; // Local-CLI agents post comments under user auth, so the actor.type is "user" // even though the comment originates from the same heartbeat run that owns // the issue lock. Without this guard, an agent that closes its own issue and @@ -8709,6 +8716,8 @@ export function issueRoutes( actorRunId: actor.runId, checkoutRunId: existing.checkoutRunId, executionRunId: existing.executionRunId, + requestAddsExplicitBlockers: + Array.isArray(req.body.blockedByIssueIds) && req.body.blockedByIssueIds.length > 0, })) || shouldResumeInProgressScheduledRetry); const updateReferenceSummaryBefore = titleOrDescriptionChanged