mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
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:
commit
53f0988006
334 changed files with 98279 additions and 9577 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue