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.
| ddb25a6 | 1 | /** |
| 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 | ||
| 20 | import { and, eq } from "drizzle-orm"; | |
| 21 | import { db } from "../../db"; | |
| 22 | import { | |
| 23 | issueComments, | |
| 24 | issues, | |
| 25 | prComments, | |
| 26 | pullRequests, | |
| 27 | repositories, | |
| 28 | } from "../../db/schema"; | |
| 29 | import { | |
| 30 | MODEL_HAIKU, | |
| 31 | extractText, | |
| 32 | getAnthropic, | |
| 33 | isAiAvailable, | |
| 34 | parseJsonResponse, | |
| 35 | } from "../ai-client"; | |
| 36 | import { | |
| 37 | executeAgentRun, | |
| 38 | startAgentRun, | |
| 39 | type AgentExecutorContext, | |
| 40 | } from "../agent-runtime"; | |
| 41 | ||
| 42 | // --------------------------------------------------------------------------- | |
| 43 | // Types | |
| 44 | // --------------------------------------------------------------------------- | |
| 45 | ||
| 46 | export type TriageItemKind = "issue" | "pr"; | |
| 47 | ||
| 48 | export type TriageCategory = | |
| 49 | | "bug" | |
| 50 | | "feature" | |
| 51 | | "question" | |
| 52 | | "docs" | |
| 53 | | "chore" | |
| 54 | | "security"; | |
| 55 | ||
| 56 | export type TriageComplexity = "small" | "medium" | "large" | "unknown"; | |
| 57 | ||
| 58 | export type TriagePriority = | |
| 59 | | "low" | |
| 60 | | "medium" | |
| 61 | | "high" | |
| 62 | | "critical" | |
| 63 | | "unknown"; | |
| 64 | ||
| 65 | export 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 | ||
| 75 | export 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 | ||
| 85 | export interface RunTriageAgentResult { | |
| 86 | ok: boolean; | |
| 87 | summary: string; | |
| 88 | runId: string | null; | |
| 89 | } | |
| 90 | ||
| 91 | // --------------------------------------------------------------------------- | |
| 92 | // Pure helpers (fully unit-testable) | |
| 93 | // --------------------------------------------------------------------------- | |
| 94 | ||
| 95 | const ALLOWED_CATEGORIES: ReadonlySet<TriageCategory> = new Set([ | |
| 96 | "bug", | |
| 97 | "feature", | |
| 98 | "question", | |
| 99 | "docs", | |
| 100 | "chore", | |
| 101 | "security", | |
| 102 | ]); | |
| 103 | ||
| 104 | const ALLOWED_COMPLEXITY: ReadonlySet<TriageComplexity> = new Set([ | |
| 105 | "small", | |
| 106 | "medium", | |
| 107 | "large", | |
| 108 | "unknown", | |
| 109 | ]); | |
| 110 | ||
| 111 | const ALLOWED_PRIORITY: ReadonlySet<TriagePriority> = new Set([ | |
| 112 | "low", | |
| 113 | "medium", | |
| 114 | "high", | |
| 115 | "critical", | |
| 116 | "unknown", | |
| 117 | ]); | |
| 118 | ||
| 119 | const 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 | ||
| 129 | const LABEL_MAX_LEN = 32; | |
| 130 | const LABELS_MAX = 6; | |
| 131 | const REVIEWERS_MAX = 3; | |
| 132 | const REASONING_MAX = 1200; | |
| 133 | const RISK_AREA_MAX = 80; | |
| 134 | ||
| 135 | /** Trim + cap a free-form string to `max` chars. */ | |
| 136 | function 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 | */ | |
| 147 | export 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. */ | |
| 197 | export 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 | */ | |
| 225 | export 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. */ | |
| 236 | export 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. */ | |
| 283 | export 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 | ||
| 297 | function 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 | ||
| 302 | Respond 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 | ||
| 313 | Rules: | |
| 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 | ||
| 319 | Title: ${args.title} | |
| 320 | Body: | |
| 321 | ${body} | |
| 322 | `; | |
| 323 | } | |
| 324 | ||
| 325 | async 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 | ||
| 381 | async 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 | */ | |
| 422 | async 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 | */ | |
| 459 | export 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 | } |