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.tsBlame186 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(
137 input: IssueTriageInput
138): Promise<void> {
139 try {
140 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
141 console.log(
142 "[issue-triage] queued",
143 input.ownerName,
144 input.repoName,
145 input.issueNumber
146 );
147 }
148 if (!isAiAvailable()) return;
149 if (await alreadyTriaged(input.issueId)) return;
150
151 const [availableLabels, recent] = await Promise.all([
152 loadAvailableLabels(input.repositoryId),
153 loadRecentIssues(input.repositoryId, input.issueId),
154 ]);
155
156 const triage = await triageIssue(
157 input.title,
158 input.body,
159 availableLabels,
160 recent
161 );
162
163 const body = renderIssueTriageComment(triage);
164
165 await db
166 .insert(issueComments)
167 .values({
168 issueId: input.issueId,
169 authorId: input.authorId,
170 body,
171 })
172 .catch(() => {});
173 } catch (err) {
174 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
175 console.error("[issue-triage] crashed:", err);
176 }
177 }
178}
179
180/** Test-only export of internals so DB-less tests can reach the helpers. */
181export const __test = {
182 alreadyTriaged,
183 loadAvailableLabels,
184 loadRecentIssues,
185 renderIssueTriageComment,
186};