diff --git a/packages/db/src/migrations/0100_pipeline_case_issue_unlinked_event.sql b/packages/db/src/migrations/0100_pipeline_case_issue_unlinked_event.sql new file mode 100644 index 00000000000..1002c09957d --- /dev/null +++ b/packages/db/src/migrations/0100_pipeline_case_issue_unlinked_event.sql @@ -0,0 +1,20 @@ +ALTER TABLE "pipeline_case_events" DROP CONSTRAINT "pipeline_case_events_type_check";--> statement-breakpoint +ALTER TABLE "pipeline_case_events" ADD CONSTRAINT "pipeline_case_events_type_check" CHECK ("pipeline_case_events"."type" in ( + 'ingested', + 'updated', + 'claimed', + 'lease_released', + 'lease_expired', + 'transitioned', + 'transition_suggested', + 'suggestion_resolved', + 'review_decided', + 'conversation_opened', + 'issue_linked', + 'issue_unlinked', + 'automation_executed', + 'automation_failed', + 'blockers_set', + 'blockers_resolved', + 'children_terminal' + )); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 423634caf68..f4ed1df9750 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -701,6 +701,13 @@ "when": 1781044473360, "tag": "0099_moaning_ricochet", "breakpoints": true + }, + { + "idx": 100, + "version": "7", + "when": 1781059200000, + "tag": "0100_pipeline_case_issue_unlinked_event", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/pipeline_case_events.ts b/packages/db/src/schema/pipeline_case_events.ts index a5c0febc085..3ee580635d5 100644 --- a/packages/db/src/schema/pipeline_case_events.ts +++ b/packages/db/src/schema/pipeline_case_events.ts @@ -39,6 +39,7 @@ export const pipelineCaseEvents = pgTable( 'review_decided', 'conversation_opened', 'issue_linked', + 'issue_unlinked', 'automation_executed', 'automation_failed', 'blockers_set', diff --git a/server/src/__tests__/pipelines-routes.test.ts b/server/src/__tests__/pipelines-routes.test.ts index 3a682ec5fc5..3039fc33592 100644 --- a/server/src/__tests__/pipelines-routes.test.ts +++ b/server/src/__tests__/pipelines-routes.test.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; import express from "express"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; @@ -235,6 +236,54 @@ describeEmbeddedPostgres("pipeline routes", () => { await http.delete(`/api/pipelines/${pipelineId}/stages/${stageId}?moveCasesToStageId=${qaStage.body.id}`).expect(200); }); + it("writes an audit event when an agent removes a case issue link", async () => { + const company = await seedCompany(); + const [agent] = await db.insert(agents).values({ + companyId: company.id, + name: "Pipeline Agent", + role: "engineer", + adapterType: "codex_local", + }).returning(); + const runId = randomUUID(); + const agentActor: Express.Request["actor"] = { + type: "agent", + agentId: agent!.id, + companyId: company.id, + runId, + source: "agent_key", + }; + const http = request(app(agentActor)); + const pipeline = await http.post(`/api/companies/${company.id}/pipelines`).send({ key: "unlink", name: "Unlink audit" }).expect(201); + const created = await http + .post(`/api/pipelines/${pipeline.body.id}/cases`) + .send({ caseKey: "unlink", title: "Unlink audit" }) + .expect(201); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Linked work", + status: "todo", + priority: "medium", + }).returning(); + + const link = await http + .post(`/api/cases/${created.body.case.id}/issue-links`) + .send({ issueId: issue!.id, role: "work" }) + .expect(201); + await http.delete(`/api/cases/${created.body.case.id}/issue-links/${link.body.id}`).expect(200); + + const events = await http.get(`/api/cases/${created.body.case.id}/events`).expect(200); + const linkEvents = events.body.items.filter((event: { type: string }) => event.type === "issue_linked" || event.type === "issue_unlinked"); + expect(linkEvents.map((event: { type: string }) => event.type)).toEqual(["issue_linked", "issue_unlinked"]); + expect(linkEvents[1]).toMatchObject({ + actorType: "agent", + actorAgentId: agent!.id, + runId, + payload: { issueId: issue!.id, role: "work", linkId: link.body.id }, + }); + const remainingLinks = await db.select().from(pipelineCaseIssueLinks).where(eq(pipelineCaseIssueLinks.id, link.body.id)); + expect(remainingLinks).toHaveLength(0); + }); + it("paginates and caps case event responses", async () => { const company = await seedCompany(); const http = request(app(boardActor)); diff --git a/server/src/routes/pipelines.ts b/server/src/routes/pipelines.ts index 271347e7ded..02b655941a3 100644 --- a/server/src/routes/pipelines.ts +++ b/server/src/routes/pipelines.ts @@ -732,19 +732,22 @@ export function pipelineRoutes(db: Db, options: Parameters rows[0] ?? null); if (!targetIssue || targetIssue.companyId !== companyId) throw notFound("Issue not found"); try { - const [link] = await db.insert(pipelineCaseIssueLinks).values({ - companyId, - caseId, - issueId: req.body.issueId, - role: req.body.role, - createdByRunId: actor.type === "agent" ? actor.runId : null, - }).returning(); - await writeRouteEvent(db, { - companyId, - caseId, - type: "issue_linked", - actor, - payload: { issueId: req.body.issueId, role: req.body.role }, + const link = await db.transaction(async (tx) => { + const [created] = await tx.insert(pipelineCaseIssueLinks).values({ + companyId, + caseId, + issueId: req.body.issueId, + role: req.body.role, + createdByRunId: actor.type === "agent" ? actor.runId : null, + }).returning(); + await writeRouteEvent(tx, { + companyId, + caseId, + type: "issue_linked", + actor, + payload: { issueId: req.body.issueId, role: req.body.role }, + }); + return created!; }); res.status(201).json(link); } catch (error) { @@ -756,15 +759,26 @@ export function pipelineRoutes(db: Db, options: Parameters { + const [removed] = await tx + .delete(pipelineCaseIssueLinks) + .where(and( + eq(pipelineCaseIssueLinks.id, linkId), + eq(pipelineCaseIssueLinks.companyId, companyId), + eq(pipelineCaseIssueLinks.caseId, caseId), + )) + .returning(); + if (!removed) return null; + await writeRouteEvent(tx, { + companyId, + caseId, + type: "issue_unlinked", + actor, + payload: { issueId: removed.issueId, role: removed.role, linkId: removed.id }, + }); + return removed; + }); if (!deleted) throw notFound("Pipeline case issue link not found"); res.json({ deleted: true }); });