Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

issue-triage.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.

issue-triage.tsBlame192 lines · 1 contributor
a9ada5fClaude1/**
2 * Issue triage — fire-and-forget hook on issue create. Mirrors the
3 * PR-triage pattern in src/lib/pr-triage.ts:
4 *
5 * 1. Idempotency check via ISSUE_TRIAGE_MARKER (HTML comment).
6 * 2. Loads available labels + recent issues for context.
7 * 3. Calls triageIssue() from ai-generators.ts.
8 * 4. Posts a "## AI Triage" comment with suggested labels, priority,
9 * one-line summary, and an "Possible duplicate of #N" callout
10 * when the AI flags one with confidence.
11 *
12 * Suggestions only — nothing is auto-applied. The author stays in
13 * control. Wired from the issue-create handler in src/routes/issues.tsx.
14 *
15 * Failure modes (DB hiccup, missing API key, AI parse error) all
16 * funnel through fallbacks / try-catch so this never throws into the
17 * caller.
18 */
19
20import { and, desc, eq, like } from "drizzle-orm";
21import { db } from "../db";
22import {
23 issueComments,
24 issues,
25 labels,
26} from "../db/schema";
27import { triageIssue, type IssueTriage } from "./ai-generators";
28import { isAiAvailable } from "./ai-client";
29
30export const ISSUE_TRIAGE_MARKER = "<!-- gluecron-issue-triage:summary -->";
31
32const RECENT_ISSUES_LIMIT = 30;
33
34export interface IssueTriageInput {
35 ownerName: string;
36 repoName: string;
37 repositoryId: string;
38 issueId: string;
39 issueNumber: number;
40 authorId: string;
41 title: string;
42 body: string;
43}
44
45async function alreadyTriaged(issueId: string): Promise<boolean> {
46 try {
47 const [row] = await db
48 .select({ id: issueComments.id })
49 .from(issueComments)
50 .where(
51 and(
52 eq(issueComments.issueId, issueId),
53 like(issueComments.body, `%${ISSUE_TRIAGE_MARKER}%`)
54 )
55 )
56 .limit(1);
57 return !!row;
58 } catch {
59 return false;
60 }
61}
62
63async function loadAvailableLabels(repositoryId: string): Promise<string[]> {
64 try {
65 const rows = await db
66 .select({ name: labels.name })
67 .from(labels)
68 .where(eq(labels.repositoryId, repositoryId));
69 return rows.map((r) => r.name);
70 } catch {
71 return [];
72 }
73}
74
75async function loadRecentIssues(
76 repositoryId: string,
77 excludeIssueId: string
78): Promise<Array<{ number: number; title: string }>> {
79 try {
80 const rows = await db
81 .select({
82 id: issues.id,
83 number: issues.number,
84 title: issues.title,
85 })
86 .from(issues)
87 .where(eq(issues.repositoryId, repositoryId))
88 .orderBy(desc(issues.createdAt))
89 .limit(RECENT_ISSUES_LIMIT + 1);
90 return rows
91 .filter((r) => r.id !== excludeIssueId)
92 .slice(0, RECENT_ISSUES_LIMIT)
93 .map((r) => ({ number: r.number, title: r.title }));
94 } catch {
95 return [];
96 }
97}
98
99/**
100 * Pure helper: render the "## AI Triage" markdown body. Exported for
101 * unit tests so the format can be pinned without an Anthropic call.
102 */
103export function renderIssueTriageComment(t: IssueTriage): string {
104 const labels = t.suggestedLabels.length
105 ? t.suggestedLabels.map((l) => `\`${l}\``).join(", ")
106 : "_(no label suggestions)_";
107 const summary = t.summary?.trim() || "_(no summary)_";
108
109 const lines: string[] = [
110 ISSUE_TRIAGE_MARKER,
111 "## AI Triage",
112 "",
113 `> ${summary}`,
114 "",
115 `**Priority:** ${t.priority}`,
116 `**Suggested labels:** ${labels}`,
117 ];
118
119 if (
120 typeof t.duplicateOfIssueNumber === "number" &&
121 t.duplicateOfIssueNumber > 0
122 ) {
123 lines.push("");
124 lines.push(
125 `**Possible duplicate of:** #${t.duplicateOfIssueNumber}`
126 );
127 }
128
129 lines.push("");
130 lines.push(
131 "_Suggestions only — nothing has been applied. The author stays in control._"
132 );
133 return lines.join("\n");
134}
135
136export async function triggerIssueTriage(
58915a9Claude137 input: IssueTriageInput,
138 options: { force?: boolean } = {}
a9ada5fClaude139): Promise<void> {
140 try {
141 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
142 console.log(
143 "[issue-triage] queued",
144 input.ownerName,
145 input.repoName,
146 input.issueNumber
147 );
148 }
149 if (!isAiAvailable()) return;
58915a9Claude150 if (!options.force && (await alreadyTriaged(input.issueId))) return;
a9ada5fClaude151
152 const [availableLabels, recent] = await Promise.all([
153 loadAvailableLabels(input.repositoryId),
154 loadRecentIssues(input.repositoryId, input.issueId),
155 ]);
156
157 const triage = await triageIssue(
158 input.title,
159 input.body,
160 availableLabels,
161 recent
162 );
163
164 const body = renderIssueTriageComment(triage);
165
166 await db
167 .insert(issueComments)
168 .values({
169 issueId: input.issueId,
170 authorId: input.authorId,
171 body,
172 })
976d7f7Claude173 .catch((err) => {
174 console.error(
175 `[issue-triage] failed to insert triage comment for issue ${input.issueId}:`,
176 err instanceof Error ? err.message : err
177 );
178 });
a9ada5fClaude179 } catch (err) {
180 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
181 console.error("[issue-triage] crashed:", err);
182 }
183 }
184}
185
186/** Test-only export of internals so DB-less tests can reach the helpers. */
187export const __test = {
188 alreadyTriaged,
189 loadAvailableLabels,
190 loadRecentIssues,
191 renderIssueTriageComment,
192};