Redact deleted issue comments

This commit is contained in:
Dotta
2026-06-03 17:32:25 +00:00
parent 78dc3625ac
commit 7f70759e61
27 changed files with 20722 additions and 83 deletions
+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)}
@@ -5,6 +5,7 @@ import {
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(eq(issueComments.id, commentId))
.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,63 @@ export function documentAnnotationService(db: Db) {
return comment;
}),
cleanupForIssueCommentDeletion: async (
issueId: string,
issueCommentId: string,
actor: ActorInput,
) => db.transaction(async (tx) => {
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) {
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"),
));
}
return { deletedCommentIds, resolvedThreadIds: emptyThreadIds };
}),
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([
@@ -2013,7 +2014,7 @@ export function mergeCoalescedContextSnapshot(
return merged;
}
async function buildPaperclipWakePayload(input: {
export async function buildPaperclipWakePayload(input: {
db: Db;
companyId: string;
contextSnapshot: Record<string, unknown>;
@@ -2072,6 +2073,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)
@@ -2100,7 +2106,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;
@@ -2118,8 +2125,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 }
@@ -6559,6 +6571,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 }];
@@ -7094,6 +7107,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(
@@ -7101,7 +7119,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,
+81 -3
View File
@@ -96,6 +96,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)) {
@@ -2957,6 +2958,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);
@@ -3032,6 +3034,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);
@@ -3133,7 +3136,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 & {
@@ -3141,6 +3156,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),
@@ -3769,6 +3800,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 '\\'
)
`;
@@ -4257,7 +4289,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>();
@@ -5689,6 +5725,48 @@ export function issueService(db: Db) {
});
},
tombstoneComment: async (
commentId: string,
actor: {
actorType: "agent" | "user";
agentId?: string | null;
userId?: string | null;
runId?: string | null;
},
) => {
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));
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
});
},
addComment: async (
issueId: string,
body: string,
@@ -5948,7 +6026,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)) {