mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Audit pipeline case issue unlink events
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f570e14d02
commit
0ee0b2891c
5 files changed
+113
-22
No files matched your search
@@ -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'
|
||||
));
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export const pipelineCaseEvents = pgTable(
|
||||
'review_decided',
|
||||
'conversation_opened',
|
||||
'issue_linked',
|
||||
'issue_unlinked',
|
||||
'automation_executed',
|
||||
'automation_failed',
|
||||
'blockers_set',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -732,19 +732,22 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
|
||||
.then((rows) => 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<typeof pipelineServic
|
||||
const caseId = req.params.caseId as string;
|
||||
const linkId = req.params.linkId as string;
|
||||
const companyId = await assertCaseAccess(db, req, caseId);
|
||||
actorForMutation(req);
|
||||
const [deleted] = await db
|
||||
.delete(pipelineCaseIssueLinks)
|
||||
.where(and(
|
||||
eq(pipelineCaseIssueLinks.id, linkId),
|
||||
eq(pipelineCaseIssueLinks.companyId, companyId),
|
||||
eq(pipelineCaseIssueLinks.caseId, caseId),
|
||||
))
|
||||
.returning();
|
||||
const actor = actorForMutation(req);
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user