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.tsBlame202 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";
479dcd9Claude29import {
30 getAutomationSettings,
31 type AutomationSettingsLoader,
32} from "./automation-settings";
a9ada5fClaude33
34export const ISSUE_TRIAGE_MARKER = "<!-- gluecron-issue-triage:summary -->";
35
36const RECENT_ISSUES_LIMIT = 30;
37
38export interface IssueTriageInput {
39 ownerName: string;
40 repoName: string;
41 repositoryId: string;
42 issueId: string;
43 issueNumber: number;
44 authorId: string;
45 title: string;
46 body: string;
47}
48
49async function alreadyTriaged(issueId: string): Promise<boolean> {
50 try {
51 const [row] = await db
52 .select({ id: issueComments.id })
53 .from(issueComments)
54 .where(
55 and(
56 eq(issueComments.issueId, issueId),
57 like(issueComments.body, `%${ISSUE_TRIAGE_MARKER}%`)
58 )
59 )
60 .limit(1);
61 return !!row;
62 } catch {
63 return false;
64 }
65}
66
67async function loadAvailableLabels(repositoryId: string): Promise<string[]> {
68 try {
69 const rows = await db
70 .select({ name: labels.name })
71 .from(labels)
72 .where(eq(labels.repositoryId, repositoryId));
73 return rows.map((r) => r.name);
74 } catch {
75 return [];
76 }
77}
78
79async function loadRecentIssues(
80 repositoryId: string,
81 excludeIssueId: string
82): Promise<Array<{ number: number; title: string }>> {
83 try {
84 const rows = await db
85 .select({
86 id: issues.id,
87 number: issues.number,
88 title: issues.title,
89 })
90 .from(issues)
91 .where(eq(issues.repositoryId, repositoryId))
92 .orderBy(desc(issues.createdAt))
93 .limit(RECENT_ISSUES_LIMIT + 1);
94 return rows
95 .filter((r) => r.id !== excludeIssueId)
96 .slice(0, RECENT_ISSUES_LIMIT)
97 .map((r) => ({ number: r.number, title: r.title }));
98 } catch {
99 return [];
100 }
101}
102
103/**
104 * Pure helper: render the "## AI Triage" markdown body. Exported for
105 * unit tests so the format can be pinned without an Anthropic call.
106 */
107export function renderIssueTriageComment(t: IssueTriage): string {
108 const labels = t.suggestedLabels.length
109 ? t.suggestedLabels.map((l) => `\`${l}\``).join(", ")
110 : "_(no label suggestions)_";
111 const summary = t.summary?.trim() || "_(no summary)_";
112
113 const lines: string[] = [
114 ISSUE_TRIAGE_MARKER,
115 "## AI Triage",
116 "",
117 `> ${summary}`,
118 "",
119 `**Priority:** ${t.priority}`,
120 `**Suggested labels:** ${labels}`,
121 ];
122
123 if (
124 typeof t.duplicateOfIssueNumber === "number" &&
125 t.duplicateOfIssueNumber > 0
126 ) {
127 lines.push("");
128 lines.push(
129 `**Possible duplicate of:** #${t.duplicateOfIssueNumber}`
130 );
131 }
132
133 lines.push("");
134 lines.push(
135 "_Suggestions only — nothing has been applied. The author stays in control._"
136 );
137 return lines.join("\n");
138}
139
140export async function triggerIssueTriage(
58915a9Claude141 input: IssueTriageInput,
479dcd9Claude142 options: { force?: boolean; loadSettings?: AutomationSettingsLoader } = {}
a9ada5fClaude143): Promise<void> {
144 try {
145 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
146 console.log(
147 "[issue-triage] queued",
148 input.ownerName,
149 input.repoName,
150 input.issueNumber
151 );
152 }
153 if (!isAiAvailable()) return;
479dcd9Claude154 // Per-repo automation gate — 'off' skips triage; loader fails open to
155 // the default ('suggest' = current behavior). Env stays supreme above.
156 const automation = await (options.loadSettings ?? getAutomationSettings)(
157 input.repositoryId
158 );
159 if (automation.issueTriageMode === "off") return;
58915a9Claude160 if (!options.force && (await alreadyTriaged(input.issueId))) return;
a9ada5fClaude161
162 const [availableLabels, recent] = await Promise.all([
163 loadAvailableLabels(input.repositoryId),
164 loadRecentIssues(input.repositoryId, input.issueId),
165 ]);
166
167 const triage = await triageIssue(
168 input.title,
169 input.body,
170 availableLabels,
171 recent
172 );
173
174 const body = renderIssueTriageComment(triage);
175
176 await db
177 .insert(issueComments)
178 .values({
179 issueId: input.issueId,
180 authorId: input.authorId,
181 body,
182 })
976d7f7Claude183 .catch((err) => {
184 console.error(
185 `[issue-triage] failed to insert triage comment for issue ${input.issueId}:`,
186 err instanceof Error ? err.message : err
187 );
188 });
a9ada5fClaude189 } catch (err) {
190 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
191 console.error("[issue-triage] crashed:", err);
192 }
193 }
194}
195
196/** Test-only export of internals so DB-less tests can reach the helpers. */
197export const __test = {
198 alreadyTriaged,
199 loadAvailableLabels,
200 loadRecentIssues,
201 renderIssueTriageComment,
202};