Merge pull request #7554 from paperclipai/codex/pap-10343-comment-redaction

[codex] Redact deleted issue comments
This commit is contained in:
Dotta
2026-06-05 05:32:08 -10:00
committed by GitHub
30 changed files with 40631 additions and 98 deletions
@@ -9,6 +9,7 @@ import {
documentAnnotationThreads,
documentRevisions,
documents,
issueComments,
issueDocuments,
issues,
} from "@paperclipai/db";
@@ -58,6 +59,7 @@ describeEmbeddedPostgres("documentAnnotationService", () => {
await db.delete(documentRevisions);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(companies);
});
@@ -180,4 +182,136 @@ describeEmbeddedPostgres("documentAnnotationService", () => {
const threads = await db.select().from(documentAnnotationThreads);
expect(threads).toHaveLength(0);
});
it("removes linked annotation comments and resolves empty threads when an issue comment is deleted", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const [issueComment] = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "board-user",
body: "Delete this linked comment",
})
.returning();
const thread = await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Linked annotation body",
issueCommentId: issueComment.id,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const cleanup = await annotations.cleanupForIssueCommentDeletion(
issueId,
issueComment.id,
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
expect(cleanup.deletedCommentIds).toEqual([thread.comments[0]!.id]);
expect(cleanup.resolvedThreadIds).toEqual([thread.id]);
await expect(
db.select().from(documentAnnotationComments).where(eq(documentAnnotationComments.id, thread.comments[0]!.id)),
).resolves.toHaveLength(0);
const [updatedThread] = await db
.select()
.from(documentAnnotationThreads)
.where(eq(documentAnnotationThreads.id, thread.id));
expect(updatedThread?.status).toBe("resolved");
expect(updatedThread?.resolvedByUserId).toBe("board-user");
});
it("rejects annotation comments linked to already-deleted issue comments", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const [issueComment] = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "board-user",
body: "",
deletedAt: new Date("2026-06-05T03:00:00.000Z"),
deletedByType: "user",
deletedByUserId: "board-user",
})
.returning();
await expect(
annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Do not link this annotation to a deleted comment",
issueCommentId: issueComment.id,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
),
).rejects.toMatchObject({
status: 422,
message: "Linked issue comment must belong to this issue",
});
await expect(db.select().from(documentAnnotationComments)).resolves.toHaveLength(0);
});
it("does not report already-resolved empty threads as newly resolved during linked comment cleanup", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const [issueComment] = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "board-user",
body: "Delete this linked comment from a resolved thread",
})
.returning();
const thread = await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Linked annotation body",
issueCommentId: issueComment.id,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
await db
.update(documentAnnotationThreads)
.set({ status: "resolved", resolvedByUserId: "board-user", resolvedAt: new Date("2026-06-05T03:05:00.000Z") })
.where(eq(documentAnnotationThreads.id, thread.id));
const cleanup = await annotations.cleanupForIssueCommentDeletion(
issueId,
issueComment.id,
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
expect(cleanup.deletedCommentIds).toEqual([thread.comments[0]!.id]);
expect(cleanup.resolvedThreadIds).toEqual([]);
});
});
@@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({
assertCheckoutOwner: vi.fn(),
getComment: vi.fn(),
removeComment: vi.fn(),
tombstoneComment: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
@@ -38,6 +39,24 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
const mockDocumentAnnotationService = vi.hoisted(() => ({
cleanupForIssueCommentDeletion: vi.fn(async () => ({ deletedCommentIds: [], resolvedThreadIds: [] })),
remapOpenThreadsForDocument: vi.fn(async () => []),
}));
const mockIssueReferenceService = vi.hoisted(() => ({
deleteCommentSource: vi.fn(async () => undefined),
deleteDocumentSource: vi.fn(async () => undefined),
diffIssueReferenceSummary: vi.fn(() => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
})),
emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })),
listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })),
syncComment: vi.fn(async () => undefined),
syncDocument: vi.fn(async () => undefined),
syncIssue: vi.fn(async () => undefined),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@@ -79,7 +98,7 @@ function registerModuleMocks() {
}),
accessService: () => mockAccessService,
agentService: () => ({ getById: vi.fn(async () => null) }),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentAnnotationService: () => mockDocumentAnnotationService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
@@ -91,19 +110,7 @@ function registerModuleMocks() {
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueReferenceService: () => mockIssueReferenceService,
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity,
@@ -188,6 +195,19 @@ describe.sequential("issue comment cancel routes", () => {
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.getComment.mockResolvedValue(makeComment());
mockIssueService.removeComment.mockResolvedValue(makeComment());
mockIssueService.tombstoneComment.mockImplementation(async (_commentId, _actor, options) => {
const deleted = makeComment({
body: "",
metadata: null,
deletedAt: new Date("2026-04-11T15:05:00.000Z"),
deletedByType: "user",
deletedByAgentId: null,
deletedByUserId: "local-board",
deletedByRunId: null,
});
await options?.afterTombstone?.(deleted, "tx");
return deleted;
});
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
@@ -214,13 +234,23 @@ describe.sequential("issue comment cancel routes", () => {
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockLogActivity.mockResolvedValue(undefined);
mockDocumentAnnotationService.cleanupForIssueCommentDeletion.mockResolvedValue({
deletedCommentIds: [],
resolvedThreadIds: [],
});
mockIssueReferenceService.deleteCommentSource.mockResolvedValue(undefined);
mockIssueReferenceService.syncComment.mockResolvedValue(undefined);
});
it("cancels a queued comment from its author and restores the deleted body", async () => {
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status).toBe(200);
expect(res.status, JSON.stringify({
body: res.body,
tombstoneCalls: mockIssueService.tombstoneComment.mock.calls,
activityCalls: mockLogActivity.mock.calls,
})).toBe(200);
expect(res.body).toMatchObject({
id: "comment-1",
body: "Queued follow-up",
@@ -239,6 +269,18 @@ describe.sequential("issue comment cancel routes", () => {
);
});
it("rejects stale queued cancellation after the active run is gone", async () => {
mockHeartbeatService.getRun.mockResolvedValue(null);
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status).toBe(409);
expect(res.body.error).toBe("Queued comment can no longer be canceled");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
expect(mockIssueService.tombstoneComment).not.toHaveBeenCalled();
});
it("rejects canceling comments that are no longer queued", async () => {
mockIssueService.getComment.mockResolvedValue(
makeComment({
@@ -248,11 +290,12 @@ describe.sequential("issue comment cancel routes", () => {
);
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status).toBe(409);
expect(res.body.error).toBe("Only queued comments can be canceled");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
expect(mockIssueService.tombstoneComment).not.toHaveBeenCalled();
});
it("rejects canceling another actor's queued comment", async () => {
@@ -263,10 +306,92 @@ describe.sequential("issue comment cancel routes", () => {
);
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1?mode=cancel");
expect(res.status).toBe(403);
expect(res.body.error).toBe("Only the comment author can cancel queued comments");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
});
it("deletes a normal authored comment as a tombstone without returning the original body", async () => {
mockIssueService.getComment.mockResolvedValue(
makeComment({
body: "Sensitive original comment body",
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "Sensitive metadata copy" }] }] },
createdAt: new Date("2026-04-11T14:58:00.000Z"),
updatedAt: new Date("2026-04-11T14:58:00.000Z"),
}),
);
mockDocumentAnnotationService.cleanupForIssueCommentDeletion.mockResolvedValue({
deletedCommentIds: ["annotation-comment-1"],
resolvedThreadIds: ["annotation-thread-1"],
});
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
id: "comment-1",
body: "",
metadata: null,
deletedByType: "user",
deletedByUserId: "local-board",
});
expect(JSON.stringify(res.body)).not.toContain("Sensitive original comment body");
expect(JSON.stringify(res.body)).not.toContain("Sensitive metadata copy");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
expect(mockIssueService.tombstoneComment).toHaveBeenCalledWith(
"comment-1",
{
actorType: "user",
agentId: null,
userId: "local-board",
runId: null,
},
expect.objectContaining({ afterTombstone: expect.any(Function) }),
);
expect(mockIssueReferenceService.syncComment).toHaveBeenCalledWith("comment-1", "tx");
expect(mockDocumentAnnotationService.cleanupForIssueCommentDeletion).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"comment-1",
expect.objectContaining({
actorType: "user",
userId: "local-board",
}),
"tx",
);
expect(mockIssueReferenceService.deleteCommentSource).toHaveBeenCalledWith("annotation-comment-1", "tx");
const deletedActivity = mockLogActivity.mock.calls.find((call) => call[1]?.action === "issue.comment_deleted")?.[1];
expect(deletedActivity).toEqual(expect.objectContaining({
action: "issue.comment_deleted",
details: expect.objectContaining({
deletedAnnotationCommentIds: ["annotation-comment-1"],
resolvedAnnotationThreadIds: ["annotation-thread-1"],
}),
}));
expect(deletedActivity?.details).toEqual(expect.not.objectContaining({
bodySnippet: expect.anything(),
}));
expect(JSON.stringify(deletedActivity?.details ?? {})).not.toContain("Sensitive original comment body");
expect(JSON.stringify(deletedActivity?.details ?? {})).not.toContain("Sensitive metadata copy");
});
it("rejects deleting another actor's normal comment", async () => {
mockIssueService.getComment.mockResolvedValue(
makeComment({
authorUserId: "someone-else",
createdAt: new Date("2026-04-11T14:58:00.000Z"),
updatedAt: new Date("2026-04-11T14:58:00.000Z"),
}),
);
const res = await request(await installActor(createApp()))
.delete("/api/issues/11111111-1111-4111-8111-111111111111/comments/comment-1");
expect(res.status).toBe(403);
expect(res.body.error).toBe("Only the comment author can delete comments");
expect(mockIssueService.removeComment).not.toHaveBeenCalled();
expect(mockIssueService.tombstoneComment).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,248 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
companies,
createDb,
issueComments,
issueReferenceMentions,
issues,
} from "@paperclipai/db";
import { companySearchQuerySchema } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { companySearchService } from "../services/company-search.js";
import { buildPaperclipWakePayload } from "../services/heartbeat.js";
import { issueReferenceService } from "../services/issue-references.js";
import { issueService } from "../services/issues.js";
import type { StorageService } from "../storage/types.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue comment redaction tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("deleted issue comment redaction", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-comment-redaction-");
db = createDb(tempDb.connectionString);
await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm"));
}, 20_000);
afterEach(async () => {
await db.delete(issueReferenceMentions);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedIssue() {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Comment Redaction Co",
issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "RED-1",
title: "Deleted comment redaction",
status: "todo",
priority: "medium",
});
return { companyId, issueId };
}
function createApp(companyId: string) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "board-user-1",
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "owner", status: "active" }],
source: "cloud_tenant",
isInstanceAdmin: true,
};
next();
});
const storage: StorageService = {
provider: "local_disk",
putFile: vi.fn(async () => {
throw new Error("Unexpected storage.putFile call");
}),
getObject: vi.fn(async () => {
throw new Error("Unexpected storage.getObject call");
}),
headObject: vi.fn(async () => ({ exists: false })),
deleteObject: vi.fn(async () => undefined),
};
app.use("/api", issueRoutes(db, storage));
app.use(errorHandler);
return app;
}
it("redacts deleted comment bodies from ordinary reads, heartbeat context, and wake payloads", async () => {
const { companyId, issueId } = await seedIssue();
const commentId = randomUUID();
const deletedAt = new Date("2026-06-03T12:00:00.000Z");
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
authorUserId: "board-user-1",
body: "secret deleted body",
presentation: { kind: "system_notice", tone: "warning" },
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "secret metadata" }] }] },
deletedAt,
deletedByType: "user",
deletedByUserId: "board-user-1",
});
const comments = await issueService(db).listComments(issueId, { order: "asc" });
expect(comments).toHaveLength(1);
expect(comments[0]).toMatchObject({
id: commentId,
body: "",
presentation: null,
metadata: null,
deletedAt,
deletedByType: "user",
deletedByUserId: "board-user-1",
});
const exactComment = await issueService(db).getComment(commentId);
expect(exactComment?.body).toBe("");
expect(exactComment?.metadata).toBeNull();
const heartbeatContext = await request(createApp(companyId))
.get(`/api/issues/${issueId}/heartbeat-context`)
.query({ wakeCommentId: commentId });
expect(heartbeatContext.status, JSON.stringify(heartbeatContext.body)).toBe(200);
expect(heartbeatContext.body.wakeComment).toMatchObject({
id: commentId,
body: "",
metadata: null,
deletedByUserId: "board-user-1",
});
expect(JSON.stringify(heartbeatContext.body)).not.toContain("secret deleted body");
expect(JSON.stringify(heartbeatContext.body)).not.toContain("secret metadata");
const wakePayload = await buildPaperclipWakePayload({
db,
companyId,
contextSnapshot: {
issueId,
commentId,
wakeCommentIds: [commentId],
wakeReason: "issue_commented",
},
});
expect(wakePayload?.comments).toEqual([
expect.objectContaining({
id: commentId,
body: "",
bodyTruncated: false,
presentation: null,
metadata: null,
deletedAt: deletedAt.toISOString(),
deletedByUserId: "board-user-1",
}),
]);
expect(JSON.stringify(wakePayload)).not.toContain("secret deleted body");
expect(JSON.stringify(wakePayload)).not.toContain("secret metadata");
});
it("excludes deleted comment bodies from company search", async () => {
const { companyId, issueId } = await seedIssue();
await db.insert(issueComments).values({
companyId,
issueId,
body: "vanished-search-needle",
deletedAt: new Date("2026-06-03T12:00:00.000Z"),
deletedByType: "user",
deletedByUserId: "board-user-1",
});
const result = await companySearchService(db).search(
companyId,
companySearchQuerySchema.parse({ q: "vanished-search-needle", scope: "comments" }),
);
expect(result.results).toEqual([]);
});
it("clears issue references sourced from deleted comment bodies", async () => {
const companyId = randomUUID();
const sourceIssueId = randomUUID();
const targetIssueId = randomUUID();
const commentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Reference Redaction Co",
issuePrefix: "REF",
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: sourceIssueId,
companyId,
identifier: "REF-1",
title: "Source issue",
status: "todo",
priority: "medium",
},
{
id: targetIssueId,
companyId,
identifier: "REF-2",
title: "Target issue",
status: "todo",
priority: "medium",
},
]);
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId: sourceIssueId,
body: "Follow up in REF-2",
});
const refs = issueReferenceService(db);
await refs.syncComment(commentId);
expect((await refs.listIssueReferenceSummary(sourceIssueId)).outbound.map((item) => item.issue.id)).toEqual([
targetIssueId,
]);
await db.update(issueComments).set({
deletedAt: new Date("2026-06-03T12:00:00.000Z"),
deletedByType: "user",
deletedByUserId: "board-user-1",
});
await refs.syncComment(commentId);
expect((await refs.listIssueReferenceSummary(sourceIssueId)).outbound).toEqual([]);
});
});
@@ -107,4 +107,57 @@ describe("plugin SDK test harness", () => {
"missing required capability 'authorization.audit.read'",
);
});
it("returns tombstone-safe deleted comments from the in-memory issue helper", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-comment-redaction",
apiVersion: 1,
version: "0.1.0",
displayName: "Comment Redaction",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["issue.comments.read"],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
harness.seed({
issues: [{
id: "issue-1",
companyId: "company-1",
title: "Comment redaction",
status: "todo",
priority: "medium",
}],
issueComments: [{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "secret plugin-visible body",
presentation: { kind: "system_notice", tone: "warning" },
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "secret plugin metadata" }] }] },
deletedAt: new Date("2026-06-03T12:00:00.000Z"),
deletedByType: "user",
deletedByUserId: "user-1",
createdAt: new Date("2026-06-03T11:00:00.000Z"),
updatedAt: new Date("2026-06-03T12:00:00.000Z"),
}],
});
const comments = await harness.ctx.issues.listComments("issue-1", "company-1");
expect(comments).toHaveLength(1);
expect(comments[0]).toMatchObject({
id: "comment-1",
body: "",
presentation: null,
metadata: null,
deletedByUserId: "user-1",
});
expect(JSON.stringify(comments)).not.toContain("secret plugin-visible body");
expect(JSON.stringify(comments)).not.toContain("secret plugin metadata");
});
});
+90 -16
View File
@@ -5634,24 +5634,93 @@ export function issueRoutes(
actor.actorType === "agent"
? comment.authorAgentId === actor.agentId
: comment.authorUserId === actor.actorId;
if (!actorOwnsComment) {
res.status(403).json({ error: "Only the comment author can cancel queued comments" });
return;
}
const deleteMode = req.query.mode === "cancel" ? "cancel" : "delete";
const activeRun = await resolveActiveIssueRun(issue);
if (!activeRun) {
res.status(409).json({ error: "Queued comment can no longer be canceled" });
const isQueuedComment = activeRun ? isQueuedIssueCommentForActiveRun({ comment, activeRun }) : false;
if (deleteMode === "cancel" || isQueuedComment) {
if (!actorOwnsComment) {
res.status(403).json({ error: "Only the comment author can cancel queued comments" });
return;
}
if (!activeRun) {
res.status(409).json({ error: "Queued comment can no longer be canceled" });
return;
}
if (!isQueuedComment) {
res.status(409).json({ error: "Only queued comments can be canceled" });
return;
}
const removed = await svc.removeComment(commentId);
if (!removed) {
res.status(404).json({ error: "Comment not found" });
return;
}
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.comment_cancelled",
entityType: "issue",
entityId: issue.id,
details: {
commentId: removed.id,
bodySnippet: removed.body.slice(0, 120),
identifier: issue.identifier,
issueTitle: issue.title,
source: "queue_cancel",
queueTargetRunId: activeRun.id,
},
});
res.json(removed);
return;
}
if (!isQueuedIssueCommentForActiveRun({ comment, activeRun })) {
res.status(409).json({ error: "Only queued comments can be canceled" });
if (!actorOwnsComment) {
res.status(403).json({ error: "Only the comment author can delete comments" });
return;
}
const removed = await svc.removeComment(commentId);
if (!removed) {
if (comment.deletedAt) {
res.json(comment);
return;
}
let annotationCleanup = { deletedCommentIds: [] as string[], resolvedThreadIds: [] as string[] };
const deleted = await svc.tombstoneComment(
commentId,
{
actorType: actor.actorType,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
},
{
afterTombstone: async (deletedComment, tx) => {
await issueReferencesSvc.syncComment(deletedComment.id, tx);
annotationCleanup = await documentAnnotationsSvc.cleanupForIssueCommentDeletion(issue.id, deletedComment.id, {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId,
}, tx);
await Promise.all(
annotationCleanup.deletedCommentIds.map((annotationCommentId) =>
issueReferencesSvc.deleteCommentSource(annotationCommentId, tx)
),
);
},
},
);
if (!deleted) {
res.status(404).json({ error: "Comment not found" });
return;
}
@@ -5662,20 +5731,25 @@ export function issueRoutes(
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.comment_cancelled",
action: "issue.comment_deleted",
entityType: "issue",
entityId: issue.id,
details: {
commentId: removed.id,
bodySnippet: removed.body.slice(0, 120),
commentId: deleted.id,
identifier: issue.identifier,
issueTitle: issue.title,
source: "queue_cancel",
queueTargetRunId: activeRun.id,
source: "author_delete",
deletedByType: actor.actorType,
deletedByAgentId: actor.actorType === "agent" ? actor.agentId : null,
deletedByUserId: actor.actorType === "user" ? actor.actorId : null,
deletedByRunId: actor.runId,
deletedAt: deleted.deletedAt,
deletedAnnotationCommentIds: annotationCleanup.deletedCommentIds,
resolvedAnnotationThreadIds: annotationCleanup.resolvedThreadIds,
},
});
res.json(removed);
res.json(deleted);
});
router.get("/issues/:id/feedback-votes", async (req, res) => {
+4
View File
@@ -359,6 +359,7 @@ export function companySearchService(db: Db) {
FROM issue_comments search_comments
WHERE search_comments.company_id = ${companyId}
AND search_comments.issue_id = issues.id
AND search_comments.deleted_at IS NULL
AND (
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
@@ -428,6 +429,7 @@ export function companySearchService(db: Db) {
FROM issue_comments coverage_comments
WHERE coverage_comments.company_id = ${companyId}
AND coverage_comments.issue_id = issues.id
AND coverage_comments.deleted_at IS NULL
AND lower(coverage_comments.body) LIKE '%' || search_token.value || '%' ESCAPE '\\'
)
OR EXISTS (
@@ -497,6 +499,7 @@ export function companySearchService(db: Db) {
FROM issue_comments search_comments
WHERE search_comments.company_id = ${companyId}
AND search_comments.issue_id = issues.id
AND search_comments.deleted_at IS NULL
AND (
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
@@ -514,6 +517,7 @@ export function companySearchService(db: Db) {
FROM issue_comments search_comments
WHERE search_comments.company_id = ${companyId}
AND search_comments.issue_id = issues.id
AND search_comments.deleted_at IS NULL
AND (
lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\'
OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)}
+93 -1
View File
@@ -1,10 +1,11 @@
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
documentAnnotationAnchorSnapshots,
documentAnnotationComments,
documentAnnotationThreads,
documents,
issueComments,
issueDocuments,
} from "@paperclipai/db";
import {
@@ -80,6 +81,7 @@ const commentSelect = {
authorAgentId: documentAnnotationComments.authorAgentId,
authorUserId: documentAnnotationComments.authorUserId,
createdByRunId: documentAnnotationComments.createdByRunId,
issueCommentId: documentAnnotationComments.issueCommentId,
createdAt: documentAnnotationComments.createdAt,
updatedAt: documentAnnotationComments.updatedAt,
};
@@ -140,6 +142,27 @@ export function documentAnnotationService(db: Db) {
.orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id));
}
async function assertLinkedIssueComment(
issueId: string,
commentId: string | null | undefined,
dbOrTx: any = db,
) {
if (!commentId) return null;
const comment = await dbOrTx
.select({
id: issueComments.id,
companyId: issueComments.companyId,
issueId: issueComments.issueId,
})
.from(issueComments)
.where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt)))
.then((rows: Array<{ id: string; companyId: string; issueId: string }>) => rows[0] ?? null);
if (!comment || comment.issueId !== issueId) {
throw unprocessable("Linked issue comment must belong to this issue");
}
return comment;
}
return {
listThreadsForIssueDocument: async (
issueId: string,
@@ -217,6 +240,7 @@ export function documentAnnotationService(db: Db) {
}
const now = new Date();
const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx);
const [thread] = await tx
.insert(documentAnnotationThreads)
.values({
@@ -258,6 +282,7 @@ export function documentAnnotationService(db: Db) {
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
issueCommentId: linkedIssueComment?.id ?? null,
createdAt: now,
updatedAt: now,
})
@@ -276,6 +301,7 @@ export function documentAnnotationService(db: Db) {
const thread = await getThreadForIssue(issueId, key, threadId, tx);
if (!thread) throw notFound("Annotation thread not found");
const now = new Date();
const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx);
const [comment] = await tx
.insert(documentAnnotationComments)
.values({
@@ -288,6 +314,7 @@ export function documentAnnotationService(db: Db) {
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
issueCommentId: linkedIssueComment?.id ?? null,
createdAt: now,
updatedAt: now,
})
@@ -299,6 +326,71 @@ export function documentAnnotationService(db: Db) {
return comment;
}),
cleanupForIssueCommentDeletion: async (
issueId: string,
issueCommentId: string,
actor: ActorInput,
dbOrTx: any = db,
) => {
const runCleanup = async (tx: any) => {
const linkedComments: Array<Pick<DocumentAnnotationComment, "id" | "threadId">> = await tx
.select({
id: documentAnnotationComments.id,
threadId: documentAnnotationComments.threadId,
})
.from(documentAnnotationComments)
.where(and(
eq(documentAnnotationComments.issueId, issueId),
eq(documentAnnotationComments.issueCommentId, issueCommentId),
));
if (linkedComments.length === 0) {
return { deletedCommentIds: [], resolvedThreadIds: [] };
}
const deletedCommentIds = linkedComments.map((comment) => comment.id);
const threadIds = [...new Set(linkedComments.map((comment) => comment.threadId))];
const now = new Date();
await tx
.delete(documentAnnotationComments)
.where(inArray(documentAnnotationComments.id, deletedCommentIds));
const remainingRows: Array<{ threadId: string; count: number }> = await tx
.select({
threadId: documentAnnotationComments.threadId,
count: sql<number>`count(*)::int`,
})
.from(documentAnnotationComments)
.where(inArray(documentAnnotationComments.threadId, threadIds))
.groupBy(documentAnnotationComments.threadId);
const remainingByThreadId = new Map(remainingRows.map((row) => [row.threadId, Number(row.count)]));
const emptyThreadIds = threadIds.filter((threadId) => (remainingByThreadId.get(threadId) ?? 0) === 0);
if (emptyThreadIds.length > 0) {
const resolvedRows: Array<{ id: string }> = await tx
.update(documentAnnotationThreads)
.set({
status: "resolved",
resolvedByAgentId: actor.actorType === "agent" ? actor.agentId ?? null : null,
resolvedByUserId: actor.actorType === "user" ? actor.userId ?? null : null,
resolvedAt: now,
updatedAt: now,
})
.where(and(
inArray(documentAnnotationThreads.id, emptyThreadIds),
eq(documentAnnotationThreads.status, "open"),
))
.returning({ id: documentAnnotationThreads.id });
return { deletedCommentIds, resolvedThreadIds: resolvedRows.map((row) => row.id) };
}
return { deletedCommentIds, resolvedThreadIds: [] };
};
return dbOrTx === db ? db.transaction(runCleanup) : runCleanup(dbOrTx);
},
updateThread: async (
issueId: string,
key: string,
+11 -2
View File
@@ -1,6 +1,6 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { and, asc, desc, eq, getTableColumns, gte, lte, ne, or } from "drizzle-orm";
import { and, asc, desc, eq, getTableColumns, gte, isNull, lte, ne, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
@@ -805,6 +805,7 @@ async function resolveFeedbackTarget(
metadata: issueComments.metadata,
createdByRunId: issueComments.createdByRunId,
body: issueComments.body,
deletedAt: issueComments.deletedAt,
createdAt: issueComments.createdAt,
})
.from(issueComments)
@@ -814,6 +815,9 @@ async function resolveFeedbackTarget(
if (!targetComment || targetComment.issueId !== issue.id || targetComment.companyId !== issue.companyId) {
throw notFound("Feedback target not found");
}
if (targetComment.deletedAt) {
throw notFound("Feedback target not found");
}
if (!targetComment.authorAgentId) {
throw unprocessable("Feedback voting is only available on agent-authored issue comments");
}
@@ -934,9 +938,14 @@ async function listIssueContextItems(
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdByRunId: issueComments.createdByRunId,
deletedAt: issueComments.deletedAt,
})
.from(issueComments)
.where(and(eq(issueComments.companyId, issue.companyId), eq(issueComments.issueId, issue.id))),
.where(and(
eq(issueComments.companyId, issue.companyId),
eq(issueComments.issueId, issue.id),
isNull(issueComments.deletedAt),
)),
db
.select({
targetId: documentRevisions.id,
+33 -5
View File
@@ -523,6 +523,7 @@ async function resolveRunScopedMentionedSkillKeys(input: {
and(
eq(issueComments.issueId, input.issueId),
eq(issueComments.companyId, input.companyId),
isNull(issueComments.deletedAt),
),
);
const mentionedSkillIds = extractMentionedSkillIdsFromSources([
@@ -2040,7 +2041,7 @@ export function mergeCoalescedContextSnapshot(
return merged;
}
async function buildPaperclipWakePayload(input: {
export async function buildPaperclipWakePayload(input: {
db: Db;
companyId: string;
contextSnapshot: Record<string, unknown>;
@@ -2099,6 +2100,11 @@ async function buildPaperclipWakePayload(input: {
authorUserId: issueComments.authorUserId,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
deletedAt: issueComments.deletedAt,
deletedByType: issueComments.deletedByType,
deletedByAgentId: issueComments.deletedByAgentId,
deletedByUserId: issueComments.deletedByUserId,
deletedByRunId: issueComments.deletedByRunId,
createdAt: issueComments.createdAt,
})
.from(issueComments)
@@ -2127,7 +2133,8 @@ async function buildPaperclipWakePayload(input: {
break;
}
const fullBody = row.body;
const deletedAt = row.deletedAt ?? null;
const fullBody = deletedAt ? "" : row.body;
const allowedBodyChars = Math.min(MAX_INLINE_WAKE_COMMENT_BODY_CHARS, remainingBodyChars);
if (allowedBodyChars <= 0) {
truncated = true;
@@ -2145,8 +2152,13 @@ async function buildPaperclipWakePayload(input: {
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
body,
bodyTruncated,
presentation: row.presentation ?? null,
metadata: row.metadata ?? null,
presentation: deletedAt ? null : row.presentation ?? null,
metadata: deletedAt ? null : row.metadata ?? null,
deletedAt: deletedAt ? deletedAt.toISOString() : null,
deletedByType: deletedAt ? row.deletedByType ?? null : null,
deletedByAgentId: deletedAt ? row.deletedByAgentId ?? null : null,
deletedByUserId: deletedAt ? row.deletedByUserId ?? null : null,
deletedByRunId: deletedAt ? row.deletedByRunId ?? null : null,
createdAt: row.createdAt.toISOString(),
author: row.authorAgentId
? { type: "agent", id: row.authorAgentId }
@@ -6695,6 +6707,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
eq(issueComments.companyId, run.companyId),
eq(issueComments.issueId, contextIssueId),
eq(issueComments.createdByRunId, run.id),
isNull(issueComments.deletedAt),
),
)
: [{ count: 0, latestAt: null }];
@@ -7230,6 +7243,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
authorUserId: issueComments.authorUserId,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
deletedAt: issueComments.deletedAt,
deletedByType: issueComments.deletedByType,
deletedByAgentId: issueComments.deletedByAgentId,
deletedByUserId: issueComments.deletedByUserId,
deletedByRunId: issueComments.deletedByRunId,
})
.from(issueComments)
.where(and(
@@ -7237,7 +7255,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
eq(issueComments.issueId, issueContext.id),
eq(issueComments.companyId, agent.companyId),
))
.then((rows) => rows[0] ?? null)
.then((rows) => {
const row = rows[0] ?? null;
return row?.deletedAt
? {
...row,
body: "",
presentation: null,
metadata: null,
}
: row;
})
: null;
const issueAssigneeOverrides =
issueContext && issueContext.assigneeAgentId === agent.id
+10 -2
View File
@@ -221,10 +221,11 @@ export function issueReferenceService(db: Db) {
companyId: issueComments.companyId,
issueId: issueComments.issueId,
body: issueComments.body,
deletedAt: issueComments.deletedAt,
})
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows: Array<{ id: string; companyId: string; issueId: string; body: string }>) => rows[0] ?? null);
.then((rows: Array<{ id: string; companyId: string; issueId: string; body: string; deletedAt: Date | null }>) => rows[0] ?? null);
if (!comment) throw notFound("Issue comment not found");
await replaceSourceMentions({
@@ -233,7 +234,7 @@ export function issueReferenceService(db: Db) {
sourceKind: "comment",
sourceRecordId: comment.id,
documentKey: null,
text: comment.body,
text: comment.deletedAt ? null : comment.body,
}, dbOrTx);
}
@@ -297,6 +298,12 @@ export function issueReferenceService(db: Db) {
.where(and(eq(issueReferenceMentions.sourceKind, "document"), eq(issueReferenceMentions.sourceRecordId, documentId)));
}
async function deleteCommentSource(commentId: string, dbOrTx: any = db) {
await dbOrTx
.delete(issueReferenceMentions)
.where(and(eq(issueReferenceMentions.sourceKind, "comment"), eq(issueReferenceMentions.sourceRecordId, commentId)));
}
async function syncAllForIssue(issueId: string, dbOrTx: any = db) {
const issue = await issueById(issueId, dbOrTx);
if (!issue) throw notFound("Issue not found");
@@ -429,6 +436,7 @@ export function issueReferenceService(db: Db) {
syncAnnotationComment,
syncDocument,
deleteDocumentSource,
deleteCommentSource,
syncAllForIssue,
syncAllForCompany,
listIssueReferenceSummary,
+88 -3
View File
@@ -34,6 +34,7 @@ import {
} from "@paperclipai/db";
import type {
AcceptedPlanDecomposition,
IssueComment,
IssueCommentAuthorType,
IssueCommentMetadata,
IssueCommentPresentation,
@@ -96,6 +97,7 @@ const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8;
const DELETED_ISSUE_COMMENT_BODY = "";
function assertTransition(from: string, to: string) {
if (from === to) return;
if (!ALL_ISSUE_STATUSES.includes(to)) {
@@ -2974,6 +2976,7 @@ async function listBlockedInboxIssues(
.where(and(
eq(issueComments.companyId, companyId),
inArray(issueComments.issueId, issueIdChunk),
isNull(issueComments.deletedAt),
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
));
for (const row of rows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId);
@@ -3049,6 +3052,7 @@ async function countBlockedInboxIssues(dbOrTx: any, companyId: string, filters?:
.where(and(
eq(issueComments.companyId, companyId),
inArray(issueComments.issueId, issueIdChunk),
isNull(issueComments.deletedAt),
sql<boolean>`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`,
));
for (const row of commentRows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId);
@@ -3150,7 +3154,19 @@ export function issueService(db: Db) {
}
}
function redactIssueComment<T extends { body: string; authorType?: string | null; authorAgentId?: string | null; authorUserId?: string | null; presentation?: unknown; metadata?: unknown }>(
function redactIssueComment<T extends {
body: string;
authorType?: string | null;
authorAgentId?: string | null;
authorUserId?: string | null;
presentation?: unknown;
metadata?: unknown;
deletedAt?: Date | string | null;
deletedByType?: "agent" | "user" | null;
deletedByAgentId?: string | null;
deletedByUserId?: string | null;
deletedByRunId?: string | null;
}>(
comment: T,
censorUsernameInLogs: boolean,
): T & {
@@ -3158,6 +3174,22 @@ export function issueService(db: Db) {
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
} {
const deletedAt = comment.deletedAt ?? null;
if (deletedAt) {
return {
...comment,
authorType: deriveIssueCommentAuthorType(comment),
body: "",
presentation: null,
metadata: null,
deletedAt,
deletedByType: comment.deletedByType ?? null,
deletedByAgentId: comment.deletedByAgentId ?? null,
deletedByUserId: comment.deletedByUserId ?? null,
deletedByRunId: comment.deletedByRunId ?? null,
};
}
return {
...comment,
authorType: deriveIssueCommentAuthorType(comment),
@@ -3786,6 +3818,7 @@ export function issueService(db: Db) {
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.deletedAt} IS NULL
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
)
`;
@@ -4280,7 +4313,11 @@ export function issueService(db: Db) {
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(and(eq(issueComments.companyId, parent.companyId), inArray(issueComments.issueId, childIdsForSummaries)))
.where(and(
eq(issueComments.companyId, parent.companyId),
inArray(issueComments.issueId, childIdsForSummaries),
isNull(issueComments.deletedAt),
))
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
: [];
const latestCommentByIssueId = new Map<string, string>();
@@ -5712,6 +5749,54 @@ export function issueService(db: Db) {
});
},
tombstoneComment: async (
commentId: string,
actor: {
actorType: "agent" | "user";
agentId?: string | null;
userId?: string | null;
runId?: string | null;
},
options?: {
afterTombstone?: (comment: IssueComment, tx: any) => Promise<void>;
},
) => {
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
return db.transaction(async (tx) => {
const now = new Date();
const [comment] = await tx
.update(issueComments)
.set({
body: DELETED_ISSUE_COMMENT_BODY,
presentation: null,
metadata: null,
deletedAt: now,
deletedByType: actor.actorType,
deletedByAgentId: actor.actorType === "agent" ? actor.agentId ?? null : null,
deletedByUserId: actor.actorType === "user" ? actor.userId ?? null : null,
deletedByRunId: actor.runId ?? null,
updatedAt: now,
})
.where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt)))
.returning();
if (!comment) return null;
await tx
.update(issues)
.set({ updatedAt: now })
.where(eq(issues.id, comment.issueId));
const redacted = redactIssueComment(comment, currentUserRedactionOptions.enabled);
await options?.afterTombstone?.(redacted, tx);
return redacted;
});
},
addComment: async (
issueId: string,
body: string,
@@ -5971,7 +6056,7 @@ export function issueService(db: Db) {
const comments = await db
.select({ body: issueComments.body })
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
.where(and(eq(issueComments.issueId, issueId), isNull(issueComments.deletedAt)));
for (const comment of comments) {
for (const projectId of extractProjectMentionIds(comment.body)) {