Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

triage-agent.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

triage-agent.tsBlame566 lines · 1 contributor
ddb25a6Claude1/**
2 * Block K4 — Autonomous triage agent.
3 *
4 * Runs on top of the Wave 1 agent-runtime substrate. On issue / PR open we:
5 *
6 * 1. Open an `agent_runs` row (kind = "triage").
7 * 2. Ask Claude Haiku for a structured classification.
8 * 3. Post a **single** comment on the issue or PR summarising the triage.
9 *
10 * Non-destructive: we never apply labels, close, or assign. We only comment.
11 * When the caller's repo hasn't wired up an ANTHROPIC_API_KEY we still record
12 * the run and post a minimal deterministic "manual triage required" comment —
13 * so the audit trail is complete either way.
14 *
15 * Style mirrors src/lib/ai-incident.ts (the D4 cleanest-example) and the pre-
16 * existing `triagePullRequest` in src/lib/ai-generators.ts which this upgrades.
17 * Never throws; all DB / Anthropic errors are caught and become a "failed" run.
18 */
19
20import { and, eq } from "drizzle-orm";
21import { db } from "../../db";
22import {
23 issueComments,
24 issues,
25 prComments,
26 pullRequests,
27 repositories,
28} from "../../db/schema";
29import {
30 MODEL_HAIKU,
31 extractText,
32 getAnthropic,
33 isAiAvailable,
34 parseJsonResponse,
35} from "../ai-client";
36import {
37 executeAgentRun,
38 startAgentRun,
39 type AgentExecutorContext,
40} from "../agent-runtime";
41
42// ---------------------------------------------------------------------------
43// Types
44// ---------------------------------------------------------------------------
45
46export type TriageItemKind = "issue" | "pr";
47
48export type TriageCategory =
49 | "bug"
50 | "feature"
51 | "question"
52 | "docs"
53 | "chore"
54 | "security";
55
56export type TriageComplexity = "small" | "medium" | "large" | "unknown";
57
58export type TriagePriority =
59 | "low"
60 | "medium"
61 | "high"
62 | "critical"
63 | "unknown";
64
65export interface TriageClassification {
66 category: TriageCategory;
67 labels: string[];
68 complexity: TriageComplexity;
69 priority: TriagePriority;
70 riskArea: string;
71 reasoning: string;
72 suggestedReviewers: string[];
73}
74
75export interface RunTriageAgentArgs {
76 kind: TriageItemKind;
77 repositoryId: string;
78 itemId: string;
79 itemNumber: number;
80 title: string;
81 body: string;
82 authorId?: string;
83}
84
85export interface RunTriageAgentResult {
86 ok: boolean;
87 summary: string;
88 runId: string | null;
89}
90
91// ---------------------------------------------------------------------------
92// Pure helpers (fully unit-testable)
93// ---------------------------------------------------------------------------
94
95const ALLOWED_CATEGORIES: ReadonlySet<TriageCategory> = new Set([
96 "bug",
97 "feature",
98 "question",
99 "docs",
100 "chore",
101 "security",
102]);
103
104const ALLOWED_COMPLEXITY: ReadonlySet<TriageComplexity> = new Set([
105 "small",
106 "medium",
107 "large",
108 "unknown",
109]);
110
111const ALLOWED_PRIORITY: ReadonlySet<TriagePriority> = new Set([
112 "low",
113 "medium",
114 "high",
115 "critical",
116 "unknown",
117]);
118
119const DEFAULT_CLASSIFICATION: TriageClassification = {
120 category: "chore",
121 labels: [],
122 complexity: "unknown",
123 priority: "unknown",
124 riskArea: "unknown",
125 reasoning: "No AI backend configured; manual triage required.",
126 suggestedReviewers: [],
127};
128
129const LABEL_MAX_LEN = 32;
130const LABELS_MAX = 6;
131const REVIEWERS_MAX = 3;
132const REASONING_MAX = 1200;
133const RISK_AREA_MAX = 80;
134
135/** Trim + cap a free-form string to `max` chars. */
136function capString(v: unknown, max: number): string {
137 if (typeof v !== "string") return "";
138 const t = v.trim();
139 return t.length <= max ? t : t.slice(0, max);
140}
141
142/**
143 * Coerce an arbitrary JSON blob from Claude into the strict
144 * TriageClassification shape. Any unknown keys are dropped; out-of-vocab
145 * values are replaced with defaults. Pure — no I/O.
146 */
147export function normaliseTriagePayload(
148 raw: unknown
149): TriageClassification {
150 if (!raw || typeof raw !== "object") {
151 return { ...DEFAULT_CLASSIFICATION };
152 }
153 const r = raw as Record<string, unknown>;
154
155 const category = ALLOWED_CATEGORIES.has(r.category as TriageCategory)
156 ? (r.category as TriageCategory)
157 : DEFAULT_CLASSIFICATION.category;
158
159 const complexity = ALLOWED_COMPLEXITY.has(r.complexity as TriageComplexity)
160 ? (r.complexity as TriageComplexity)
161 : "unknown";
162
163 const priority = ALLOWED_PRIORITY.has(r.priority as TriagePriority)
164 ? (r.priority as TriagePriority)
165 : "unknown";
166
167 const labels = Array.isArray(r.labels)
168 ? r.labels
169 .filter((l): l is string => typeof l === "string" && l.trim().length > 0)
170 .map((l) => l.trim().toLowerCase().slice(0, LABEL_MAX_LEN))
171 .filter((l, i, arr) => arr.indexOf(l) === i)
172 .slice(0, LABELS_MAX)
173 : [];
174
175 const suggestedReviewers = Array.isArray(r.suggestedReviewers)
176 ? r.suggestedReviewers
177 .filter((u): u is string => typeof u === "string" && u.trim().length > 0)
178 .map((u) => u.trim().slice(0, 64))
179 .filter((u, i, arr) => arr.indexOf(u) === i)
180 .slice(0, REVIEWERS_MAX)
181 : [];
182
183 return {
184 category,
185 labels,
186 complexity,
187 priority,
188 riskArea: capString(r.riskArea, RISK_AREA_MAX) || "unknown",
189 reasoning:
190 capString(r.reasoning, REASONING_MAX) ||
191 "(no reasoning supplied by model)",
192 suggestedReviewers,
193 };
194}
195
196/** Validate caller args before we touch the DB. */
197export function validateTriageArgs(
198 args: RunTriageAgentArgs
199): { ok: true } | { ok: false; reason: string } {
200 if (!args) return { ok: false, reason: "missing args" };
201 if (args.kind !== "issue" && args.kind !== "pr") {
202 return { ok: false, reason: "invalid kind" };
203 }
204 if (!args.repositoryId || typeof args.repositoryId !== "string") {
205 return { ok: false, reason: "missing repositoryId" };
206 }
207 if (!args.itemId || typeof args.itemId !== "string") {
208 return { ok: false, reason: "missing itemId" };
209 }
210 if (!Number.isFinite(args.itemNumber) || args.itemNumber <= 0) {
211 return { ok: false, reason: "invalid itemNumber" };
212 }
213 if (typeof args.title !== "string" || args.title.trim().length === 0) {
214 return { ok: false, reason: "empty title" };
215 }
216 return { ok: true };
217}
218
219/**
220 * Haiku pricing (per BUILD_BIBLE §5.3):
221 * input ≈ $0.25 / 1M tokens
222 * output ≈ $1.25 / 1M tokens
223 * We round UP to whole cents so a zero-cost run at least records the attempt.
224 */
225export function estimateHaikuCents(
226 inputTokens: number,
227 outputTokens: number
228): number {
229 const dIn = Math.max(0, inputTokens | 0);
230 const dOut = Math.max(0, outputTokens | 0);
231 const dollars = (dIn * 0.25 + dOut * 1.25) / 1_000_000;
232 return Math.ceil(dollars * 100);
233}
234
235/** Render the comment body. Exported so tests can assert on its shape. */
236export function renderTriageComment(
237 kind: TriageItemKind,
238 classification: TriageClassification,
239 aiAvailable: boolean
240): string {
241 const heading = "## Triage";
242 const botLine = aiAvailable
243 ? "_Posted by GlueCron triage agent (suggestion only — no labels applied)._"
244 : "_Posted by GlueCron triage agent — no AI backend configured; manual triage required._";
245
246 const lines: string[] = [
247 heading,
248 "",
249 botLine,
250 "",
251 `- **Category:** \`${classification.category}\``,
252 `- **Complexity:** \`${classification.complexity}\``,
253 `- **Priority:** \`${classification.priority}\``,
254 `- **Risk area:** ${classification.riskArea || "unknown"}`,
255 ];
256 if (classification.labels.length) {
257 lines.push(
258 `- **Suggested labels:** ${classification.labels
259 .map((l) => `\`${l}\``)
260 .join(", ")}`
261 );
262 }
263 if (
264 kind === "pr" &&
265 classification.suggestedReviewers.length > 0
266 ) {
267 lines.push(
268 `- **Suggested reviewers:** ${classification.suggestedReviewers
269 .map((u) => `@${u.replace(/^@/, "")}`)
270 .join(", ")}`
271 );
272 }
273 lines.push("", "### Reasoning", classification.reasoning);
274 lines.push(
275 "",
276 "---",
277 "_This is a non-destructive suggestion. A maintainer must apply any labels, assignments, or state changes._"
278 );
279 return lines.join("\n");
280}
281
282/** Short human sentence for the `agent_runs.summary` column. */
283export function buildRunSummary(
284 classification: TriageClassification,
285 aiAvailable: boolean
286): string {
287 if (!aiAvailable) {
288 return "no AI backend configured; manual triage required";
289 }
290 return `classified as ${classification.category}, complexity ${classification.complexity}`;
291}
292
293// ---------------------------------------------------------------------------
294// Internal: Claude call
295// ---------------------------------------------------------------------------
296
297function buildPrompt(args: RunTriageAgentArgs): string {
298 const kindLabel = args.kind === "pr" ? "pull request" : "issue";
299 const body = (args.body || "(no body)").slice(0, 4000);
300 return `You are GlueCron's triage agent. Classify this newly opened ${kindLabel}.
301
302Respond ONLY with JSON of this exact shape (no prose, no code fences):
303{
304 "category": "bug" | "feature" | "question" | "docs" | "chore" | "security",
305 "labels": ["short-label", ...],
306 "complexity": "small" | "medium" | "large" | "unknown",
307 "priority": "low" | "medium" | "high" | "critical" | "unknown",
308 "riskArea": "short phrase, e.g. 'auth/session handling'",
309 "reasoning": "2-4 sentences explaining your classification.",
310 "suggestedReviewers": ["username", ...]
311}
312
313Rules:
314- "labels" must be 0-6 short kebab-case strings (<= 32 chars each).
315- "suggestedReviewers" is optional; at most 3 usernames. Omit the field if you have no suggestions.
316- Use "unknown" for complexity/priority when the text is too thin to judge.
317- Use category "security" for anything involving auth, credentials, or vulnerability reports.
318
319Title: ${args.title}
320Body:
321${body}
322`;
323}
324
325async function askClaudeForTriage(
326 args: RunTriageAgentArgs,
327 ctx: AgentExecutorContext
328): Promise<{
329 classification: TriageClassification;
330 inputTokens: number;
331 outputTokens: number;
332} | null> {
333 try {
334 const client = getAnthropic();
335 const prompt = buildPrompt(args);
336 const message = await client.messages.create({
337 model: MODEL_HAIKU,
338 max_tokens: 512,
339 messages: [{ role: "user", content: prompt }],
340 });
341 const text = extractText(message);
342 const parsed = parseJsonResponse<unknown>(text);
343 const classification = normaliseTriagePayload(parsed);
344
345 // Anthropic SDK surfaces token usage on `usage`. Fall back to rough
346 // heuristic if the field isn't present so we always record *something*.
347 const usage = (message as { usage?: { input_tokens?: number; output_tokens?: number } }).usage;
348 const inputTokens =
349 typeof usage?.input_tokens === "number"
350 ? usage.input_tokens
351 : Math.ceil(prompt.length / 4);
352 const outputTokens =
353 typeof usage?.output_tokens === "number"
354 ? usage.output_tokens
355 : Math.ceil(text.length / 4);
356
357 await ctx.appendLog(
358 `[triage] haiku returned ${text.length} chars; tokens in=${inputTokens} out=${outputTokens}`
359 );
360 return { classification, inputTokens, outputTokens };
361 } catch (err) {
362 await ctx.appendLog(
363 `[triage] anthropic call failed: ${(err as Error).message}`
364 );
365 console.error("[triage-agent] askClaudeForTriage:", err);
366 return null;
367 }
368}
369
370// ---------------------------------------------------------------------------
371// Comment author resolution
372//
373// issueComments.authorId and prComments.authorId are NOT NULL — we need a real
374// user row. Preference order:
375// 1. the caller-supplied authorId (typically the item's creator),
376// 2. the item's own author (looked up from the DB),
377// 3. the repository owner (last resort).
378// This mirrors ai-incident.ts and the existing ai-review path in pulls.tsx.
379// ---------------------------------------------------------------------------
380
381async function resolveCommentAuthorId(
382 args: RunTriageAgentArgs
383): Promise<string | null> {
384 if (args.authorId) return args.authorId;
385 try {
386 if (args.kind === "issue") {
387 const [row] = await db
388 .select({ authorId: issues.authorId })
389 .from(issues)
390 .where(eq(issues.id, args.itemId))
391 .limit(1);
392 if (row?.authorId) return row.authorId;
393 } else {
394 const [row] = await db
395 .select({ authorId: pullRequests.authorId })
396 .from(pullRequests)
397 .where(eq(pullRequests.id, args.itemId))
398 .limit(1);
399 if (row?.authorId) return row.authorId;
400 }
401 } catch (err) {
402 console.error("[triage-agent] resolveCommentAuthorId item lookup:", err);
403 }
404 try {
405 const [row] = await db
406 .select({ ownerId: repositories.ownerId })
407 .from(repositories)
408 .where(eq(repositories.id, args.repositoryId))
409 .limit(1);
410 if (row?.ownerId) return row.ownerId;
411 } catch (err) {
412 console.error("[triage-agent] resolveCommentAuthorId owner lookup:", err);
413 }
414 return null;
415}
416
417/**
418 * Have we already triaged this item? We rely on the marker line from the
419 * heading — "## Triage" is unique enough. This avoids stacking duplicate
420 * comments if a caller fires the trigger twice (e.g. retry after 500).
421 */
422async function alreadyTriaged(
423 kind: TriageItemKind,
424 itemId: string
425): Promise<boolean> {
426 try {
427 if (kind === "issue") {
428 const rows = await db
429 .select({ body: issueComments.body })
430 .from(issueComments)
431 .where(eq(issueComments.issueId, itemId));
432 return rows.some((r) => (r.body || "").startsWith("## Triage"));
433 }
434 const rows = await db
435 .select({ body: prComments.body })
436 .from(prComments)
437 .where(
438 and(
439 eq(prComments.pullRequestId, itemId),
440 eq(prComments.isAiReview, true)
441 )
442 );
443 return rows.some((r) => (r.body || "").startsWith("## Triage"));
444 } catch (err) {
445 console.error("[triage-agent] alreadyTriaged:", err);
446 return false; // on failure, prefer to post rather than silently skip
447 }
448}
449
450// ---------------------------------------------------------------------------
451// Entry point
452// ---------------------------------------------------------------------------
453
454/**
455 * Kick off a triage run for a newly-opened issue or PR. Non-destructive.
456 * Never throws. Always returns `{ ok, summary, runId }` — even when input
457 * validation fails or the DB is unavailable, in which case `runId` is null.
458 */
459export async function runTriageAgent(
460 args: RunTriageAgentArgs
461): Promise<RunTriageAgentResult> {
462 // 1. Validate eagerly — we don't want to open an agent_runs row for a
463 // clearly-malformed caller (which is almost certainly a dev bug).
464 const check = validateTriageArgs(args);
465 if (!check.ok) {
466 return { ok: false, summary: `invalid args: ${check.reason}`, runId: null };
467 }
468
469 // 2. Open the run. If the DB is down this returns null and we give up —
470 // there's nowhere to record logs without the row.
471 const run = await startAgentRun({
472 repositoryId: args.repositoryId,
473 kind: "triage",
474 trigger: args.kind === "issue" ? "issue.opened" : "pr.opened",
475 triggerRef: String(args.itemNumber),
476 });
477 if (!run) {
478 return {
479 ok: false,
480 summary: "could not open agent_runs row",
481 runId: null,
482 };
483 }
484
485 let finalSummary = "no AI backend configured; manual triage required";
486
487 await executeAgentRun(run.id, async (ctx) => {
488 await ctx.appendLog(
489 `[triage] classifying ${args.kind} #${args.itemNumber} "${args.title.slice(0, 120)}"`
490 );
491
492 // Skip if we've already commented on this item. The run still succeeds —
493 // double-firing is not an error from the caller's perspective.
494 if (await alreadyTriaged(args.kind, args.itemId)) {
495 await ctx.appendLog("[triage] already triaged; skipping comment");
496 finalSummary = "already triaged; skipped";
497 return { ok: true, summary: finalSummary };
498 }
499
500 const aiAvailable = isAiAvailable();
501 let classification: TriageClassification = { ...DEFAULT_CLASSIFICATION };
502
503 if (aiAvailable) {
504 const ai = await askClaudeForTriage(args, ctx);
505 if (ai) {
506 classification = ai.classification;
507 const cents = estimateHaikuCents(ai.inputTokens, ai.outputTokens);
508 await ctx.recordCost(ai.inputTokens, ai.outputTokens, cents);
509 } else {
510 await ctx.appendLog(
511 "[triage] AI call failed — falling back to deterministic comment"
512 );
513 classification = {
514 ...DEFAULT_CLASSIFICATION,
515 reasoning:
516 "AI classifier was unreachable. A maintainer should triage this manually.",
517 };
518 }
519 } else {
520 await ctx.appendLog("[triage] no ANTHROPIC_API_KEY; deterministic path");
521 }
522
523 const commentBody = renderTriageComment(
524 args.kind,
525 classification,
526 aiAvailable
527 );
528 const authorId = await resolveCommentAuthorId(args);
529 if (!authorId) {
530 await ctx.appendLog(
531 "[triage] no viable author_id for comment; aborting insert"
532 );
533 finalSummary = "no author_id available; comment not posted";
534 return { ok: false, summary: finalSummary };
535 }
536
537 try {
538 if (args.kind === "issue") {
539 await db.insert(issueComments).values({
540 issueId: args.itemId,
541 authorId,
542 body: commentBody,
543 });
544 } else {
545 await db.insert(prComments).values({
546 pullRequestId: args.itemId,
547 authorId,
548 body: commentBody,
549 isAiReview: true,
550 });
551 }
552 } catch (err) {
553 await ctx.appendLog(
554 `[triage] comment insert failed: ${(err as Error).message}`
555 );
556 finalSummary = `comment insert failed: ${(err as Error).message}`;
557 return { ok: false, summary: finalSummary };
558 }
559
560 finalSummary = buildRunSummary(classification, aiAvailable);
561 await ctx.appendLog(`[triage] posted comment; ${finalSummary}`);
562 return { ok: true, summary: finalSummary };
563 });
564
565 return { ok: true, summary: finalSummary, runId: run.id };
566}