Infer comment reopen for closed assigned issues

This commit is contained in:
Dotta committed 2026-04-14 11:45:22 -05:00
1 parent 29f3721c28
commit 9884ad73d6
6 files changed
+247 -31

No files matched your search

@@ -225,6 +225,40 @@ describe("issue comment reopen routes", () => {
);
});
it("implicitly reopens closed issues via the PATCH comment path when reassigning to an agent", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const res = await request(await installActor(createApp()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
status: "todo",
actorAgentId: null,
actorUserId: "local-board",
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
reopened: true,
reopenedFrom: "done",
status: "todo",
}),
}),
);
});
it("reopens closed issues via the PATCH comment path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
@@ -259,6 +293,48 @@ describe("issue comment reopen routes", () => {
);
});
it("implicitly reopens closed issues via POST comments when an agent is assigned", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "hello" });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({
reopenedFrom: "done",
}),
}),
);
});
it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => {
mockIssueService.getById.mockResolvedValue({
...makeIssue("done"),
assigneeAgentId: null,
assigneeUserId: "local-board",
});
const res = await request(await installActor(createApp()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "hello" });
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("interrupts an active run before a combined comment update", async () => {
const issue = {
...makeIssue("todo"),
+41 -6
View File
@@ -142,6 +142,22 @@ function summarizeExecutionParticipants(
);
}
function isClosedIssueStatus(status: string | null | undefined): status is "done" | "cancelled" {
return status === "done" || status === "cancelled";
}
function shouldImplicitlyReopenCommentForAgent(input: {
issueStatus: string | null | undefined;
assigneeAgentId: string | null | undefined;
actorType: "agent" | "user";
actorId: string;
}) {
if (!isClosedIssueStatus(input.issueStatus)) return false;
if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false;
if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false;
return true;
}
function diffExecutionParticipants(
previousPolicy: NormalizedExecutionPolicy | null,
nextPolicy: NormalizedExecutionPolicy | null,
@@ -1339,7 +1355,7 @@ export function issueRoutes(
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req);
const isClosed = existing.status === "done" || existing.status === "cancelled";
const isClosed = isClosedIssueStatus(existing.status);
const existingRelations =
Array.isArray(req.body.blockedByIssueIds)
? await svc.getRelationSummaries(existing.id)
@@ -1351,6 +1367,17 @@ export function issueRoutes(
hiddenAt: hiddenAtRaw,
...updateFields
} = req.body;
const requestedAssigneeAgentId =
req.body.assigneeAgentId === undefined ? existing.assigneeAgentId : (req.body.assigneeAgentId as string | null);
const effectiveReopenRequested =
reopenRequested ||
(!!commentBody &&
shouldImplicitlyReopenCommentForAgent({
issueStatus: existing.status,
assigneeAgentId: requestedAssigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
}));
let interruptedRunId: string | null = null;
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
@@ -1393,7 +1420,7 @@ export function issueRoutes(
if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
}
if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) {
if (commentBody && effectiveReopenRequested && isClosed && updateFields.status === undefined) {
updateFields.status = "todo";
}
if (req.body.executionPolicy !== undefined) {
@@ -1552,7 +1579,7 @@ export function issueRoutes(
const hasFieldChanges = Object.keys(previous).length > 0;
const reopened =
commentBody &&
reopenRequested === true &&
effectiveReopenRequested &&
isClosed &&
previous.status !== undefined &&
issue.status === "todo";
@@ -1774,7 +1801,7 @@ export function issueRoutes(
const selfComment = actorIsAgent && actor.actorId === assigneeId;
const skipAssigneeCommentWake = selfComment || isClosed;
if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) {
if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) {
addWakeup(assigneeId, {
source: "automation",
triggerDetail: "system",
@@ -2259,13 +2286,21 @@ export function issueRoutes(
const actor = getActorInfo(req);
const reopenRequested = req.body.reopen === true;
const interruptRequested = req.body.interrupt === true;
const isClosed = issue.status === "done" || issue.status === "cancelled";
const isClosed = isClosedIssueStatus(issue.status);
const effectiveReopenRequested =
reopenRequested ||
shouldImplicitlyReopenCommentForAgent({
issueStatus: issue.status,
assigneeAgentId: issue.assigneeAgentId,
actorType: actor.actorType,
actorId: actor.actorId,
});
let reopened = false;
let reopenFromStatus: string | null = null;
let interruptedRunId: string | null = null;
let currentIssue = issue;
if (reopenRequested && isClosed) {
if (effectiveReopenRequested && isClosed) {
const reopenedIssue = await svc.update(id, { status: "todo" });
if (!reopenedIssue) {
res.status(404).json({ error: "Issue not found" });
+46
View File
@@ -178,6 +178,52 @@ describe("CommentThread", () => {
});
});
it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => {
const root = createRoot(container);
const onAdd = vi.fn(async () => {});
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[]}
issueStatus="done"
currentAssigneeValue="agent:agent-1"
onAdd={onAdd}
/>
</MemoryRouter>,
);
});
expect(container.textContent).not.toContain("Re-open");
const editor = container.querySelector('textarea[aria-label="Comment editor"]') as HTMLTextAreaElement | null;
const submitButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent === "Comment",
) as HTMLButtonElement | undefined;
expect(editor).not.toBeNull();
expect(submitButton).toBeDefined();
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(editor, "Please pick this back up");
editor?.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
submitButton?.click();
});
expect(onAdd).toHaveBeenCalledWith("Please pick this back up", true, undefined);
act(() => {
root.unmount();
});
});
it("renders linked approvals inline in the timeline", () => {
const root = createRoot(container);
const agent: Agent = {
+11 -12
View File
@@ -134,6 +134,11 @@ function parseReassignment(target: string): CommentReassignment | null {
return null;
}
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
const isClosed = issueStatus === "done" || issueStatus === "cancelled";
return isClosed && assigneeValue.startsWith("agent:");
}
function humanizeValue(value: string | null): string {
if (!value) return "None";
return value.replace(/_/g, " ");
@@ -647,6 +652,7 @@ export function CommentThread({
pendingApprovalAction = null,
onVote,
onAdd,
issueStatus,
agentMap,
currentUserId,
imageUploadHandler,
@@ -663,7 +669,6 @@ export function CommentThread({
composerDisabledReason = null,
}: CommentThreadProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
@@ -784,14 +789,17 @@ export function CommentThread({
if (!trimmed) return;
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
const reopen = shouldImplicitlyReopenComment(
issueStatus,
hasReassignment ? reassignTarget : currentAssigneeValue,
) ? true : undefined;
const submittedBody = trimmed;
setSubmitting(true);
setBody("");
try {
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
await onAdd(submittedBody, reopen, reassignment ?? undefined);
if (draftKey) clearDraft(draftKey);
setReopen(true);
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
setBody((current) =>
@@ -935,15 +943,6 @@ export function CommentThread({
</Button>
</div>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}
+64 -1
View File
@@ -11,6 +11,10 @@ const { markdownEditorFocusMock } = vi.hoisted(() => ({
markdownEditorFocusMock: vi.fn(),
}));
const { appendMock } = vi.hoisted(() => ({
appendMock: vi.fn(async () => undefined),
}));
const { threadMessagesMock } = vi.hoisted(() => ({
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
}));
@@ -32,7 +36,7 @@ vi.mock("@assistant-ui/react", () => ({
Content: () => null,
Parts: () => null,
},
useAui: () => ({ thread: () => ({ append: vi.fn() }) }),
useAui: () => ({ thread: () => ({ append: appendMock }) }),
useAuiState: () => false,
useMessage: () => ({
id: "message",
@@ -126,6 +130,7 @@ describe("IssueChatThread", () => {
afterEach(() => {
container.remove();
vi.useRealTimers();
appendMock.mockReset();
markdownEditorFocusMock.mockReset();
threadMessagesMock.mockReset();
});
@@ -338,6 +343,64 @@ describe("IssueChatThread", () => {
});
});
it("hides the reopen control and infers reopen for closed agent-assigned issue replies", async () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
issueStatus="done"
currentAssigneeValue="agent:agent-1"
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).not.toContain("Re-open");
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
const submitButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent === "Send",
) as HTMLButtonElement | undefined;
expect(editor).not.toBeNull();
expect(submitButton).toBeDefined();
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(editor, "Please pick this back up");
editor?.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
submitButton?.click();
});
expect(appendMock).toHaveBeenCalledWith(
expect.objectContaining({
content: [{ type: "text", text: "Please pick this back up" }],
runConfig: {
custom: {
reopen: true,
},
},
}),
);
act(() => {
root.unmount();
});
});
it("exposes a composer focus handle that forwards to the editor", () => {
const root = createRoot(container);
const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>();
+9 -12
View File
@@ -436,6 +436,11 @@ function parseReassignment(target: string): PaperclipIssueRuntimeReassignment |
return null;
}
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
const isClosed = issueStatus === "done" || issueStatus === "cancelled";
return isClosed && assigneeValue.startsWith("agent:");
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
function commentDateLabel(date: Date | string | undefined): string {
@@ -1609,7 +1614,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
}, forwardedRef) {
const api = useAui();
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
@@ -1678,6 +1682,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined;
const reopen = shouldImplicitlyReopenComment(
issueStatus,
hasReassignment ? reassignTarget : currentAssigneeValue,
) ? true : undefined;
const submittedBody = trimmed;
const viewportSnapshot = captureComposerViewportSnapshot(composerContainerRef.current);
@@ -1699,7 +1707,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
queueViewportRestore(viewportSnapshot);
await appendPromise;
if (draftKey) clearDraft(draftKey);
setReopen(issueStatus === "done" || issueStatus === "cancelled");
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
setBody((current) =>
@@ -1783,16 +1790,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
</div>
) : null}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(event) => setReopen(event.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 ? (
<InlineEntitySelector
value={reassignTarget}