mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(comments): add server-authoritative reply context
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8a0db228a6
commit
e785d0d5f2
9 files changed
+402
-7
No files matched your search
@@ -843,6 +843,47 @@ describe("renderPaperclipWakePrompt", () => {
|
||||
expect(fallbackPrompt).toContain("- fallback fetch needed: yes");
|
||||
});
|
||||
|
||||
it("renders replied-to comment context and a fetch path when truncated", () => {
|
||||
const issueId = "11111111-1111-4111-8111-111111111111";
|
||||
const replyTargetId = "22222222-2222-4222-8222-222222222222";
|
||||
const prompt = renderPaperclipWakePrompt({
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: issueId,
|
||||
identifier: "PAP-1581",
|
||||
title: "Handle a direct reply",
|
||||
status: "in_progress",
|
||||
},
|
||||
commentWindow: {
|
||||
requestedCount: 1,
|
||||
includedCount: 1,
|
||||
missingCount: 0,
|
||||
},
|
||||
comments: [{
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
issueId,
|
||||
body: "This is my reply.",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-07-13T01:00:00.000Z",
|
||||
author: { type: "user", id: "board-user" },
|
||||
replyToComment: {
|
||||
id: replyTargetId,
|
||||
body: "Full original body prefix",
|
||||
bodyTruncated: true,
|
||||
createdAt: "2026-07-13T00:00:00.000Z",
|
||||
author: { type: "agent", id: "agent-1" },
|
||||
},
|
||||
}],
|
||||
fallbackFetchNeeded: false,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("This comment is a reply to an earlier comment by agent agent-1");
|
||||
expect(prompt).toContain("Treat that earlier comment as the direct subject of this reply.");
|
||||
expect(prompt).toContain("Full original body prefix");
|
||||
expect(prompt).toContain(`GET /api/issues/${issueId}/comments/${replyTargetId}`);
|
||||
expect(prompt).toContain("Replying comment:\nThis is my reply.");
|
||||
});
|
||||
|
||||
it("renders the execution workspace branch guard only on non-resumed sessions", () => {
|
||||
const payload = {
|
||||
reason: "issue_assigned",
|
||||
|
||||
@@ -450,6 +450,14 @@ type PaperclipWakeComment = {
|
||||
createdAt: string | null;
|
||||
authorType: string | null;
|
||||
authorId: string | null;
|
||||
replyToComment: {
|
||||
id: string | null;
|
||||
body: string;
|
||||
bodyTruncated: boolean;
|
||||
createdAt: string | null;
|
||||
authorType: string | null;
|
||||
authorId: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PaperclipWakePlanReviewAuthor = {
|
||||
@@ -668,6 +676,9 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null
|
||||
function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | null {
|
||||
const comment = parseObject(value);
|
||||
const author = parseObject(comment.author);
|
||||
const replyToComment = parseObject(comment.replyToComment);
|
||||
const replyToAuthor = parseObject(replyToComment.author);
|
||||
const replyToBody = asString(replyToComment.body, "");
|
||||
const body = asString(comment.body, "");
|
||||
if (!body.trim()) return null;
|
||||
return {
|
||||
@@ -678,6 +689,16 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n
|
||||
createdAt: asString(comment.createdAt, "").trim() || null,
|
||||
authorType: asString(author.type, "").trim() || null,
|
||||
authorId: asString(author.id, "").trim() || null,
|
||||
replyToComment: replyToBody.trim()
|
||||
? {
|
||||
id: asString(replyToComment.id, "").trim() || null,
|
||||
body: replyToBody,
|
||||
bodyTruncated: asBoolean(replyToComment.bodyTruncated, false),
|
||||
createdAt: asString(replyToComment.createdAt, "").trim() || null,
|
||||
authorType: asString(replyToAuthor.type, "").trim() || null,
|
||||
authorId: asString(replyToAuthor.id, "").trim() || null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1676,8 +1697,25 @@ export function renderPaperclipWakePrompt(
|
||||
: comment.authorType ?? "unknown";
|
||||
lines.push(
|
||||
`${index + 1}. comment ${comment.id ?? "unknown"} at ${comment.createdAt ?? "unknown"} by ${authorLabel}`,
|
||||
comment.body,
|
||||
);
|
||||
if (comment.replyToComment) {
|
||||
const replyAuthorLabel = comment.replyToComment.authorId
|
||||
? `${comment.replyToComment.authorType ?? "unknown"} ${comment.replyToComment.authorId}`
|
||||
: comment.replyToComment.authorType ?? "unknown";
|
||||
lines.push(
|
||||
`This comment is a reply to an earlier comment by ${replyAuthorLabel} (${comment.replyToComment.createdAt ?? "unknown"}).`,
|
||||
"Treat that earlier comment as the direct subject of this reply.",
|
||||
"Full original comment:",
|
||||
comment.replyToComment.body,
|
||||
);
|
||||
if (comment.replyToComment.bodyTruncated) {
|
||||
lines.push(
|
||||
`[original comment body truncated; fetch the rest via GET /api/issues/${comment.issueId ?? "{issueId}"}/comments/${comment.replyToComment.id ?? "{commentId}"}]`,
|
||||
);
|
||||
}
|
||||
lines.push("Replying comment:");
|
||||
}
|
||||
lines.push(comment.body);
|
||||
if (comment.bodyTruncated) {
|
||||
lines.push("[comment body truncated]");
|
||||
}
|
||||
|
||||
@@ -922,9 +922,19 @@ export interface IssueCommentMetadataSection {
|
||||
rows: IssueCommentMetadataRow[];
|
||||
}
|
||||
|
||||
export interface IssueCommentReplyToMetadata {
|
||||
commentId: string;
|
||||
authorType: IssueCommentAuthorType;
|
||||
authorAgentId?: string | null;
|
||||
authorUserId?: string | null;
|
||||
excerpt: string;
|
||||
excerptTruncated: boolean;
|
||||
}
|
||||
|
||||
export interface IssueCommentMetadata {
|
||||
version: 1;
|
||||
sourceRunId?: string | null;
|
||||
replyTo?: IssueCommentReplyToMetadata | null;
|
||||
sections: IssueCommentMetadataSection[];
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,38 @@ describe("issue validators", () => {
|
||||
|
||||
expect(parsed.presentation?.detailsDefaultOpen).toBe(false);
|
||||
expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111");
|
||||
expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3);
|
||||
expect(parsed.metadata?.sections?.[0]?.rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("accepts only a reply target id from comment clients", () => {
|
||||
const parsed = addIssueCommentSchema.parse({
|
||||
body: "Following up on that point.",
|
||||
metadata: {
|
||||
replyTo: {
|
||||
commentId: "11111111-1111-4111-8111-111111111111",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.metadata).toEqual({
|
||||
replyTo: {
|
||||
commentId: "11111111-1111-4111-8111-111111111111",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects client-supplied reply snapshots", () => {
|
||||
const parsed = addIssueCommentSchema.safeParse({
|
||||
body: "Forged quote attempt.",
|
||||
metadata: {
|
||||
replyTo: {
|
||||
commentId: "11111111-1111-4111-8111-111111111111",
|
||||
excerpt: "The target never said this.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects arbitrary issue comment metadata", () => {
|
||||
|
||||
@@ -554,19 +554,46 @@ export const issueCommentMetadataSectionSchema = z.object({
|
||||
rows: z.array(issueCommentMetadataRowSchema).min(1).max(50),
|
||||
}).strict();
|
||||
|
||||
const issueCommentReplyToMetadataSchema = z.object({
|
||||
commentId: z.string().uuid(),
|
||||
authorType: issueCommentAuthorTypeSchema,
|
||||
authorAgentId: z.string().uuid().nullable().optional(),
|
||||
authorUserId: z.string().trim().min(1).nullable().optional(),
|
||||
excerpt: z.string(),
|
||||
excerptTruncated: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export const issueCommentMetadataSchema = z.object({
|
||||
version: z.literal(1),
|
||||
sourceRunId: z.string().uuid().nullable().optional(),
|
||||
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20),
|
||||
replyTo: issueCommentReplyToMetadataSchema.nullable().optional(),
|
||||
sections: z.array(issueCommentMetadataSectionSchema).max(20),
|
||||
}).strict();
|
||||
|
||||
export type IssueCommentMetadata = z.infer<typeof issueCommentMetadataSchema>;
|
||||
|
||||
const addIssueCommentMetadataSchema = z.object({
|
||||
version: z.literal(1).optional(),
|
||||
sourceRunId: z.string().uuid().nullable().optional(),
|
||||
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20).optional(),
|
||||
replyTo: z.object({
|
||||
commentId: z.string().uuid(),
|
||||
}).strict().optional(),
|
||||
}).strict().superRefine((value, ctx) => {
|
||||
if (!value.replyTo && (!value.version || !value.sections)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Comment metadata requires structured sections or a reply target",
|
||||
path: ["sections"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const addIssueCommentSchema = z.object({
|
||||
body: multilineTextSchema.pipe(z.string().min(1)),
|
||||
authorType: issueCommentAuthorTypeSchema.optional(),
|
||||
presentation: issueCommentPresentationSchema.nullable().optional(),
|
||||
metadata: issueCommentMetadataSchema.nullable().optional(),
|
||||
metadata: addIssueCommentMetadataSchema.nullable().optional(),
|
||||
reopen: z.boolean().optional(),
|
||||
resume: z.boolean().optional(),
|
||||
interrupt: z.boolean().optional(),
|
||||
|
||||
@@ -792,6 +792,46 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", null],
|
||||
["cross-issue", {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
issueId: "99999999-9999-4999-8999-999999999999",
|
||||
companyId,
|
||||
body: "Other issue body",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "other-user",
|
||||
deletedAt: null,
|
||||
}],
|
||||
["deleted", {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
issueId,
|
||||
companyId,
|
||||
body: "Deleted body",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-user",
|
||||
deletedAt: new Date("2026-07-13T00:00:00.000Z"),
|
||||
}],
|
||||
])("rejects a %s reply target with 422", async (_case, replyTarget) => {
|
||||
mockIssueService.getComment.mockResolvedValueOnce(replyTarget);
|
||||
|
||||
const res = await request(await createApp(boardActor()))
|
||||
.post(`/api/issues/${issueId}/comments`)
|
||||
.send({
|
||||
body: "Reply body",
|
||||
metadata: {
|
||||
replyTo: {
|
||||
commentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-mentioned peer agents from posting comments", async () => {
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
|
||||
allowed: input.action === "issue:read",
|
||||
|
||||
@@ -4,6 +4,7 @@ import request from "supertest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
@@ -45,6 +46,7 @@ describeEmbeddedPostgres("deleted issue comment redaction", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueReferenceMentions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(companyMemberships);
|
||||
@@ -187,6 +189,108 @@ describeEmbeddedPostgres("deleted issue comment redaction", () => {
|
||||
expect(JSON.stringify(wakePayload)).not.toContain("secret metadata");
|
||||
});
|
||||
|
||||
it("injects the canonical replied-to comment into wake payloads", async () => {
|
||||
const { companyId, issueId } = await seedIssue();
|
||||
const targetCommentId = randomUUID();
|
||||
const replyCommentId = randomUUID();
|
||||
const targetBody = "o".repeat(4_005);
|
||||
await db.insert(issueComments).values([
|
||||
{
|
||||
id: targetCommentId,
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "board-user-1",
|
||||
authorType: "user",
|
||||
body: targetBody,
|
||||
createdAt: new Date("2026-07-13T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: replyCommentId,
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "board-user-1",
|
||||
authorType: "user",
|
||||
body: "Reply body",
|
||||
metadata: {
|
||||
version: 1,
|
||||
sections: [],
|
||||
replyTo: {
|
||||
commentId: targetCommentId,
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-user-1",
|
||||
excerpt: targetBody.slice(0, 200),
|
||||
excerptTruncated: true,
|
||||
},
|
||||
},
|
||||
createdAt: new Date("2026-07-13T00:01:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const wakePayload = await buildPaperclipWakePayload({
|
||||
db,
|
||||
companyId,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
commentId: replyCommentId,
|
||||
wakeCommentIds: [replyCommentId],
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wakePayload?.comments).toEqual([
|
||||
expect.objectContaining({
|
||||
id: replyCommentId,
|
||||
replyToComment: {
|
||||
id: targetCommentId,
|
||||
author: { type: "user", id: "board-user-1" },
|
||||
createdAt: "2026-07-13T00:00:00.000Z",
|
||||
body: targetBody.slice(0, 4_000),
|
||||
bodyTruncated: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores a server-authored truncated reply snapshot", async () => {
|
||||
const { companyId, issueId } = await seedIssue();
|
||||
const targetCommentId = randomUUID();
|
||||
const targetBody = "x".repeat(205);
|
||||
await db.insert(issueComments).values({
|
||||
id: targetCommentId,
|
||||
companyId,
|
||||
issueId,
|
||||
authorType: "user",
|
||||
authorUserId: "board-user-1",
|
||||
body: targetBody,
|
||||
});
|
||||
|
||||
const response = await request(createApp(companyId))
|
||||
.post(`/api/issues/${issueId}/comments`)
|
||||
.send({
|
||||
body: "Reply body",
|
||||
metadata: {
|
||||
replyTo: {
|
||||
commentId: targetCommentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
expect(response.body.metadata).toEqual({
|
||||
version: 1,
|
||||
sections: [],
|
||||
replyTo: {
|
||||
commentId: targetCommentId,
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "board-user-1",
|
||||
excerpt: "x".repeat(200),
|
||||
excerptTruncated: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes deleted comment bodies from company search", async () => {
|
||||
const { companyId, issueId } = await seedIssue();
|
||||
await db.insert(issueComments).values({
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
isUuidLike,
|
||||
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
||||
type CompactIssue,
|
||||
type AddIssueComment,
|
||||
type CompanySearchQuery,
|
||||
type CompanySearchResponse,
|
||||
type ExecutionWorkspace,
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
type IssueBlockerDiagnosticNode,
|
||||
type IssueBlockerDiagnosticsReadiness,
|
||||
type IssueBlockerDiagnosticsResponse,
|
||||
type IssueCommentMetadata,
|
||||
type IssueSubtreeDiagnosticEdge,
|
||||
type IssueSubtreeDiagnosticNode,
|
||||
type IssueSubtreeDiagnosticsResponse,
|
||||
@@ -283,6 +285,8 @@ type SuccessfulRunHandoffActivityRow = {
|
||||
type TaskWatchdogService = ReturnType<typeof taskWatchdogService>;
|
||||
type TaskWatchdogServiceFactory = typeof taskWatchdogService;
|
||||
|
||||
const ISSUE_COMMENT_REPLY_EXCERPT_CHARS = 200;
|
||||
|
||||
function applyCreateIssueStatusDefault(req: Request, res: Response, next: () => void) {
|
||||
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
||||
next();
|
||||
@@ -3979,6 +3983,48 @@ export function issueRoutes(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveIssueCommentMetadata(
|
||||
issueId: string,
|
||||
requestedMetadata: AddIssueComment["metadata"],
|
||||
): Promise<IssueCommentMetadata | null> {
|
||||
if (!requestedMetadata) return null;
|
||||
|
||||
const replyTargetId = requestedMetadata.replyTo?.commentId;
|
||||
if (!replyTargetId) {
|
||||
return {
|
||||
version: requestedMetadata.version ?? 1,
|
||||
...(requestedMetadata.sourceRunId !== undefined
|
||||
? { sourceRunId: requestedMetadata.sourceRunId }
|
||||
: {}),
|
||||
sections: requestedMetadata.sections ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const replyTarget = await svc.getComment(replyTargetId);
|
||||
if (!replyTarget || replyTarget.issueId !== issueId || replyTarget.deletedAt) {
|
||||
throw unprocessable("Reply target comment is missing, belongs to another issue, or was deleted");
|
||||
}
|
||||
|
||||
const excerpt = replyTarget.body.slice(0, ISSUE_COMMENT_REPLY_EXCERPT_CHARS);
|
||||
return {
|
||||
version: requestedMetadata.version ?? 1,
|
||||
...(requestedMetadata.sourceRunId !== undefined
|
||||
? { sourceRunId: requestedMetadata.sourceRunId }
|
||||
: {}),
|
||||
sections: requestedMetadata.sections ?? [],
|
||||
replyTo: {
|
||||
commentId: replyTarget.id,
|
||||
authorType:
|
||||
replyTarget.authorType ??
|
||||
(replyTarget.authorAgentId ? "agent" : replyTarget.authorUserId ? "user" : "system"),
|
||||
authorAgentId: replyTarget.authorAgentId ?? null,
|
||||
authorUserId: replyTarget.authorUserId ?? null,
|
||||
excerpt,
|
||||
excerptTruncated: excerpt.length < replyTarget.body.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function assertExplicitResumeIntentAllowed(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -9585,6 +9631,7 @@ export function issueRoutes(
|
||||
presentation: req.body.presentation,
|
||||
metadata: req.body.metadata,
|
||||
})) return;
|
||||
const commentMetadata = await resolveIssueCommentMetadata(id, req.body.metadata);
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
||||
if (closedExecutionWorkspace) {
|
||||
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||
@@ -9803,7 +9850,7 @@ export function issueRoutes(
|
||||
const commentOptions = {
|
||||
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
|
||||
presentation: req.body.presentation ?? null,
|
||||
metadata: req.body.metadata ?? null,
|
||||
metadata: commentMetadata,
|
||||
sourceTrust,
|
||||
};
|
||||
let txResult: { comment: Awaited<ReturnType<typeof svc.addComment>>; issue: NonNullable<Awaited<ReturnType<typeof svc.update>>> };
|
||||
@@ -9887,7 +9934,7 @@ export function issueRoutes(
|
||||
}, {
|
||||
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
|
||||
presentation: req.body.presentation ?? null,
|
||||
metadata: req.body.metadata ?? null,
|
||||
metadata: commentMetadata,
|
||||
sourceTrust: await sourceTrustForActorWrite(currentIssue, actor),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4145,7 +4145,38 @@ export async function buildPaperclipWakePayload(input: {
|
||||
),
|
||||
);
|
||||
|
||||
const replyTargetIds = Array.from(new Set(commentRows.flatMap((comment) => {
|
||||
const metadata = parseObject(comment.metadata);
|
||||
const replyTo = parseObject(metadata.replyTo);
|
||||
const commentId = readNonEmptyString(replyTo.commentId);
|
||||
return commentId ? [commentId] : [];
|
||||
})));
|
||||
const replyTargetRows = replyTargetIds.length === 0
|
||||
? []
|
||||
: await input.db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
authorType: issueComments.authorType,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
presentation: issueComments.presentation,
|
||||
metadata: issueComments.metadata,
|
||||
deletedAt: issueComments.deletedAt,
|
||||
sourceTrust: issueComments.sourceTrust,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
inArray(issueComments.id, replyTargetIds),
|
||||
),
|
||||
);
|
||||
|
||||
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
|
||||
const replyTargetsById = new Map(replyTargetRows.map((comment) => [comment.id, comment]));
|
||||
const comments: Array<Record<string, unknown>> = [];
|
||||
let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS;
|
||||
let truncated = false;
|
||||
@@ -4181,6 +4212,31 @@ export async function buildPaperclipWakePayload(input: {
|
||||
if (bodyTruncated) truncated = true;
|
||||
remainingBodyChars -= body.length;
|
||||
|
||||
const metadata = deletedAt ? null : safeRow.metadata ?? null;
|
||||
const replyToCommentId = readNonEmptyString(parseObject(parseObject(metadata).replyTo).commentId);
|
||||
const replyTarget = replyToCommentId ? replyTargetsById.get(replyToCommentId) : null;
|
||||
const safeReplyTarget = replyTarget && !input.exposeLowTrustRaw
|
||||
? sanitizeQuarantinedCommentForHigherTrust(replyTarget)
|
||||
: replyTarget;
|
||||
const replyToComment = safeReplyTarget &&
|
||||
safeReplyTarget.issueId === row.issueId &&
|
||||
!safeReplyTarget.deletedAt
|
||||
? {
|
||||
id: safeReplyTarget.id,
|
||||
author: safeReplyTarget.authorAgentId
|
||||
? { type: "agent", id: safeReplyTarget.authorAgentId }
|
||||
: safeReplyTarget.authorUserId
|
||||
? { type: "user", id: safeReplyTarget.authorUserId }
|
||||
: { type: safeReplyTarget.authorType ?? "system", id: null },
|
||||
createdAt: safeReplyTarget.createdAt.toISOString(),
|
||||
body: safeReplyTarget.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS
|
||||
? safeReplyTarget.body.slice(0, MAX_INLINE_WAKE_COMMENT_BODY_CHARS)
|
||||
: safeReplyTarget.body,
|
||||
bodyTruncated: safeReplyTarget.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS,
|
||||
}
|
||||
: null;
|
||||
if (replyToComment?.bodyTruncated) truncated = true;
|
||||
|
||||
comments.push({
|
||||
id: row.id,
|
||||
issueId: row.issueId,
|
||||
@@ -4188,7 +4244,7 @@ export async function buildPaperclipWakePayload(input: {
|
||||
body,
|
||||
bodyTruncated,
|
||||
presentation: deletedAt ? null : safeRow.presentation ?? null,
|
||||
metadata: deletedAt ? null : safeRow.metadata ?? null,
|
||||
metadata,
|
||||
deletedAt: deletedAt ? deletedAt.toISOString() : null,
|
||||
deletedByType: deletedAt ? row.deletedByType ?? null : null,
|
||||
deletedByAgentId: deletedAt ? row.deletedByAgentId ?? null : null,
|
||||
@@ -4201,6 +4257,7 @@ export async function buildPaperclipWakePayload(input: {
|
||||
: row.authorUserId
|
||||
? { type: "user", id: row.authorUserId }
|
||||
: { type: "system", id: null },
|
||||
...(replyToComment ? { replyToComment } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user