Add issue ancestors, @-mention wakeups on comments

- issueService.getAncestors() walks parent chain, returning up to 50 ancestors
- GET /issues/:id now includes ancestors array for context delivery to agents
- POST /issues/:id/comments now parses @-mentions and wakes mentioned agents

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-17 20:07:14 -06:00
parent 6232d2397d
commit 04f708c32e
2 changed files with 58 additions and 2 deletions

View File

@@ -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);
});

View File

@@ -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<string, string[]> = {
@@ -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<string>();
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<string>([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