Merge origin/master into fix/issue-1255

- findMentionedAgents: keep normalizeAgentMentionToken + extractAgentMentionIds
- decode @mention tokens with entities.decodeHTMLStrict (full HTML entities)
- Add entities dependency; expand unit tests for Greptile follow-ups

Made-with: Cursor
This commit is contained in:
amit221 2026-03-24 10:03:15 +02:00
commit 53f0988006
334 changed files with 98279 additions and 9577 deletions

View file

@ -1,6 +1,7 @@
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
assets,
companies,
@ -19,7 +20,8 @@ import {
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { extractProjectMentionIds } from "@paperclipai/shared";
import { extractAgentMentionIds, extractProjectMentionIds } from "@paperclipai/shared";
import { decodeHTMLStrict } from "entities";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
@ -62,12 +64,16 @@ function applyStatusSideEffects(
export interface IssueFilters {
status?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
touchedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
parentId?: string;
labelId?: string;
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
q?: string;
}
@ -97,13 +103,6 @@ type IssueUserContextInput = {
updatedAt: Date | string;
};
function redactIssueComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null;
@ -138,6 +137,30 @@ function touchedByUserCondition(companyId: string, userId: string) {
`;
}
function participatedByAgentCondition(companyId: string, agentId: string) {
return sql<boolean>`
(
${issues.createdByAgentId} = ${agentId}
OR ${issues.assigneeAgentId} = ${agentId}
OR EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.authorAgentId} = ${agentId}
)
OR EXISTS (
SELECT 1
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.agentId} = ${agentId}
)
)
`;
}
function myLastCommentAtExpr(companyId: string, userId: string) {
return sql<Date | null>`
(
@ -196,38 +219,12 @@ function unreadForUserCondition(companyId: string, userId: string) {
`;
}
/** Named entities the rich-text editor may emit in issue bodies; unknown names are left unchanged. */
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
nbsp: "\u00A0",
quot: '"',
ensp: "\u2002",
emsp: "\u2003",
thinsp: "\u2009",
};
function decodeNumericHtmlEntity(digits: string, radix: 16 | 10): string | null {
const n = Number.parseInt(digits, radix);
if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return null;
try {
return String.fromCodePoint(n);
} catch {
return null;
}
}
/** Decodes HTML entities in a raw @mention capture so UI-encoded bodies still match agent names. */
/**
* Decodes HTML character references in a raw @mention capture (WHATWG HTML, strict semicolon form)
* so rich-text / UI-encoded bodies still match agent names.
*/
export function normalizeAgentMentionToken(raw: string): string {
let s = raw.replace(/&#x([0-9a-fA-F]+);/gi, (full, hex: string) => decodeNumericHtmlEntity(hex, 16) ?? full);
s = s.replace(/&#([0-9]+);/g, (full, dec: string) => decodeNumericHtmlEntity(dec, 10) ?? full);
s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name: string) => {
const decoded = WELL_KNOWN_NAMED_HTML_ENTITIES[name.toLowerCase()];
return decoded !== undefined ? decoded : full;
});
return s.trim();
return decodeHTMLStrict(raw).trim();
}
export function deriveIssueUserContext(
@ -354,6 +351,13 @@ function withActiveRuns(
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db
.select({
@ -539,6 +543,9 @@ export function issueService(db: Db) {
if (filters?.assigneeAgentId) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
}
if (filters?.participantAgentId) {
conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
}
if (filters?.assigneeUserId) {
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
}
@ -550,6 +557,8 @@ export function issueService(db: Db) {
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })
@ -568,6 +577,9 @@ export function issueService(db: Db) {
)!,
);
}
if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) {
conditions.push(ne(issues.originKind, "routine_execution"));
}
conditions.push(isNull(issues.hiddenAt));
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
@ -649,6 +661,7 @@ export function issueService(db: Db) {
eq(issues.companyId, companyId),
isNull(issues.hiddenAt),
unreadForUserCondition(companyId, userId),
ne(issues.originKind, "routine_execution"),
];
if (status) {
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
@ -787,6 +800,7 @@ export function issueService(db: Db) {
const values = {
...issueData,
originKind: issueData.originKind ?? "manual",
goalId: resolveIssueGoalId({
projectId: issueData.projectId,
goalId: issueData.goalId,
@ -1249,7 +1263,8 @@ export function issueService(db: Db) {
);
const comments = limit ? await query.limit(limit) : await query;
return comments.map(redactIssueComment);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs));
},
getCommentCursor: async (issueId: string) => {
@ -1281,14 +1296,15 @@ export function issueService(db: Db) {
},
getComment: (commentId: string) =>
db
instanceSettings.getGeneral().then(({ censorUsernameInLogs }) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows) => {
const comment = rows[0] ?? null;
return comment ? redactIssueComment(comment) : null;
}),
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
})),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db
@ -1299,7 +1315,10 @@ export function issueService(db: Db) {
if (!issue) throw notFound("Issue not found");
const redactedBody = redactCurrentUserText(body);
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
const [comment] = await db
.insert(issueComments)
.values({
@ -1317,7 +1336,7 @@ export function issueService(db: Db) {
.set({ updatedAt: new Date() })
.where(eq(issues.id, issueId));
return redactIssueComment(comment);
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
},
createAttachment: async (input: {
@ -1484,10 +1503,18 @@ export function issueService(db: Db) {
const normalized = normalizeAgentMentionToken(m[1]);
if (normalized) tokens.add(normalized.toLowerCase());
}
if (tokens.size === 0) return [];
const explicitAgentMentionIds = extractAgentMentionIds(body);
if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return [];
const rows = await db.select({ id: agents.id, name: agents.name })
.from(agents).where(eq(agents.companyId, companyId));
return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id);
const resolved = new Set<string>(explicitAgentMentionIds);
for (const agent of rows) {
if (tokens.has(agent.name.toLowerCase())) {
resolved.add(agent.id);
}
}
return [...resolved];
},
findMentionedProjectIds: async (issueId: string) => {