diff --git a/apps/api/src/storage/task-board.test.ts b/apps/api/src/storage/task-board.test.ts index a86434b4a3..8c021204d6 100644 --- a/apps/api/src/storage/task-board.test.ts +++ b/apps/api/src/storage/task-board.test.ts @@ -73,15 +73,6 @@ describe("shouldAdvanceToReview", () => { ).toBe(false); }); - it("advances a repo-backed task on thread-finish too (backstop for missed PR detection)", () => { - expect( - shouldAdvanceToReview({ - status: "in_progress", - threads: [thread("completed")], - }), - ).toBe(true); - }); - it("only fires from in_progress, not from other lanes", () => { for (const status of ["triage", "todo", "in_review", "done"] as const) { expect( diff --git a/apps/api/src/tools/task-board/reports-task-guards.integration.test.ts b/apps/api/src/tools/task-board/reports-task-guards.integration.test.ts index 51409b778b..c9ea67712f 100644 --- a/apps/api/src/tools/task-board/reports-task-guards.integration.test.ts +++ b/apps/api/src/tools/task-board/reports-task-guards.integration.test.ts @@ -153,6 +153,34 @@ describe("reports-task guards", () => { expect(cleared.item.repo).toBeNull(); }); + // The CMS submit-for-review path opens the PR directly (no run), so it links the PR + advances via UPDATE — the only place these two effects happen together off a run. + it("links a PR and advances to In Review via UPDATE (CMS submit-for-review)", async () => { + const task = await taskBoard.create({ + organizationId: ORG, + title: "cms review", + status: "in_progress", + by: USER, + }); + const pr = { + url: "https://github.com/deco-sites/casaevideo/pull/7", + prNumber: 7, + repoOwner: "deco-sites", + repoName: "casaevideo", + }; + const res = await TASK_BOARD_ITEM_UPDATE.handler( + { id: task.id, status: "in_review", linkPr: pr }, + ctx, + ); + expect(res.item.status).toBe("in_review"); + expect( + (await taskBoard.listPrs(task.id, ORG)).map((p) => p.number), + ).toEqual([7]); + + // Idempotent — re-linking the same PR (a retry) does not duplicate. + await TASK_BOARD_ITEM_UPDATE.handler({ id: task.id, linkPr: pr }, ctx); + expect(await taskBoard.listPrs(task.id, ORG)).toHaveLength(1); + }); + it("the paywall fires BEFORE the delegation write (no delegated-but-idle task)", async () => { const config = { enforced: true, diff --git a/apps/api/src/tools/task-board/update.ts b/apps/api/src/tools/task-board/update.ts index dfd498cf9b..9162000e1d 100644 --- a/apps/api/src/tools/task-board/update.ts +++ b/apps/api/src/tools/task-board/update.ts @@ -127,6 +127,18 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ tagIds: z.array(z.string()).optional(), /** Link an existing chat thread to this task (many-to-many, idempotent). */ linkThreadId: z.string().optional(), + /** Associate an opened pull request with this task (idempotent). Used by the + * CMS submit-for-review flow, which opens the PR via a direct API call + * outside any agent run — so the run-based PR hooks never see it. */ + linkPr: z + .object({ + url: z.string(), + prNumber: z.number(), + repoOwner: z.string(), + repoName: z.string(), + connectionId: z.string().nullable().optional(), + }) + .optional(), }), outputSchema: z.object({ item: TaskBoardItemSchema }), handler: async (input, ctx) => { @@ -167,6 +179,24 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ ); } + // Associate a PR opened outside a run (CMS submit-for-review) — org-verify, then idempotent insert. + if (input.linkPr) { + const target = await ctx.storage.taskBoard.getById( + input.id, + organizationId, + ); + if (!target) throw new Error(`Task board item not found: ${input.id}`); + await ctx.storage.taskBoard.linkPr({ + taskBoardItemId: input.id, + organizationId, + url: input.linkPr.url, + prNumber: input.linkPr.prNumber, + repoOwner: input.linkPr.repoOwner, + repoName: input.linkPr.repoName, + connectionId: input.linkPr.connectionId ?? null, + }); + } + const hasFieldUpdate = input.title !== undefined || input.description !== undefined || diff --git a/apps/web/src/components/chat/pills/new-task-dialog.tsx b/apps/web/src/components/chat/pills/new-task-dialog.tsx new file mode 100644 index 0000000000..3201511682 --- /dev/null +++ b/apps/web/src/components/chat/pills/new-task-dialog.tsx @@ -0,0 +1,91 @@ +/** + * "New task" entry for the task-based flow (opened from the task pill). Ask what + * to do: describe it and the agent runs it on a fresh task, or edit the site by + * hand in a new CMS environment. Presentational — the caller owns both actions. + */ + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@decocms/ui/components/dialog.tsx"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { Edit05, Lightning01 } from "@untitledui/icons"; +import { useT } from "@/i18n/use-t.ts"; + +export function NewTaskDialog({ + open, + onClose, + onSubmitPrompt, + onEditManually, + isSubmitting, +}: { + open: boolean; + onClose: () => void; + /** Describe → run on the agent. Receives the trimmed prompt. */ + onSubmitPrompt: (text: string) => void; + /** New CMS environment to edit by hand. */ + onEditManually: () => void; + isSubmitting?: boolean; +}) { + const t = useT(); + const [text, setText] = useState(""); + + const submit = () => { + const trimmed = text.trim(); + if (!trimmed) return; + onSubmitPrompt(trimmed); + }; + + return ( + !next && onClose()}> + + + {t("chat.newTask.heading")} + + +