mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Infer comment reopen for closed assigned issues
This commit is contained in:
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"),
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user