diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0d3ba6f7..22ae4f3f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -35,7 +35,8 @@ export function issueRoutes(db: Db) { return; } assertCompanyAccess(req, issue.companyId); - res.json(issue); + const ancestors = await svc.getAncestors(id); + res.json({ ...issue, ancestors }); }); router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => { @@ -262,6 +263,21 @@ export function issueRoutes(db: Db) { details: { commentId: comment.id }, }); + // @-mention wakeups + svc.findMentionedAgents(issue.companyId, req.body.body).then((ids) => { + for (const mentionedId of ids) { + heartbeat.wakeup(mentionedId, { + source: "automation", + triggerDetail: "system", + reason: `Mentioned in comment on issue ${id}`, + payload: { issueId: id, commentId: comment.id }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { issueId: id, commentId: comment.id, source: "comment.mention" }, + }).catch((err) => logger.warn({ err, agentId: mentionedId }, "failed to wake mentioned agent")); + } + }).catch((err) => logger.warn({ err, issueId: id }, "failed to resolve @-mentions")); + res.status(201).json(comment); }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e67d8229..5b742025 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,6 +1,6 @@ import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclip/db"; -import { issues, issueComments } from "@paperclip/db"; +import { agents, issues, issueComments } from "@paperclip/db"; import { conflict, notFound, unprocessable } from "../errors.js"; const ISSUE_TRANSITIONS: Record = { @@ -216,6 +216,46 @@ export function issueService(db: Db) { .then((rows) => rows[0]); }, + findMentionedAgents: async (companyId: string, body: string) => { + const re = /\B@([^\s@,!?.]+)/g; + const tokens = new Set(); + let m: RegExpExecArray | null; + while ((m = re.exec(body)) !== null) tokens.add(m[1].toLowerCase()); + if (tokens.size === 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); + }, + + getAncestors: async (issueId: string) => { + const ancestors: Array<{ + id: string; title: string; description: string | null; + status: string; priority: string; + assigneeAgentId: string | null; projectId: string | null; goalId: string | null; + }> = []; + const visited = new Set([issueId]); + const start = await db.select().from(issues).where(eq(issues.id, issueId)).then(r => r[0] ?? null); + let currentId = start?.parentId ?? null; + while (currentId && !visited.has(currentId) && ancestors.length < 50) { + visited.add(currentId); + const parent = await db.select({ + id: issues.id, title: issues.title, description: issues.description, + status: issues.status, priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, projectId: issues.projectId, + goalId: issues.goalId, parentId: issues.parentId, + }).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null); + if (!parent) break; + ancestors.push({ + id: parent.id, title: parent.title, description: parent.description ?? null, + status: parent.status, priority: parent.priority, + assigneeAgentId: parent.assigneeAgentId ?? null, + projectId: parent.projectId ?? null, goalId: parent.goalId ?? null, + }); + currentId = parent.parentId ?? null; + } + return ancestors; + }, + staleCount: async (companyId: string, minutes = 60) => { const cutoff = new Date(Date.now() - minutes * 60 * 1000); const result = await db