Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commita9ada5funknown_key

feat(issue-triage): wire triageIssue into issue-create handler

feat(issue-triage): wire triageIssue into issue-create handler

triageIssue() in src/lib/ai-generators.ts has been an exported
helper since the AI block landed but no caller ever invoked it.
Issues opened on Gluecron got zero AI assistance — only PRs did.
This brings issues to parity with the PR triage flow shipped earlier
this session.

UX:
- On every new issue (not draft, API key present, not previously
  triaged) Gluecron posts a "## AI Triage" comment with:
    * One-line summary
    * Priority (critical / high / medium / low)
    * Suggested labels (filtered against the repo's existing labels)
    * Possible-duplicate callout (link to #N) when the AI flags one
- Suggestions only. Nothing applied automatically. The author stays
  in control.

Implementation mirrors the PR-triage shape:
- src/lib/issue-triage.ts — new file. triggerIssueTriage(input)
  fire-and-forget. ISSUE_TRIAGE_MARKER for idempotency. Pure helper
  renderIssueTriageComment(t) for the markdown body. __test bundle
  exposing the DB helpers + renderer.
- src/lib/ai-generators.ts — IssueTriage interface gains `export`
  (was previously module-local, blocked re-use).
- src/routes/issues.tsx — issue-create handler fires
  triggerIssueTriage(...) after the row + count update, with
  .catch(() => {}) so failure can never block the redirect.

Tests: +13 (marker stability, 6 renderIssueTriageComment cases
including duplicate-callout shape + null/0/negative no-op + no
emoji guard, 3 fail-open contracts, 2 triggerIssueTriage no-throw
cases). Total suite 1183 pass / 8 skip / 0 fail (was 1170 / 8 / 0).

https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU
Claude committed on April 30, 2026Parent: 18b210d
4 files changed+3681a9ada5f6149e7b93e51b279d4e523ce6c94a2520
4 changed files+368−1
Addedsrc/__tests__/issue-triage.test.ts+166−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/issue-triage.ts.
3 *
4 * Pure helper renderIssueTriageComment covered exhaustively. The
5 * DB-touching paths (alreadyTriaged, loadAvailableLabels,
6 * loadRecentIssues) are exercised via the fail-open contracts.
7 *
8 * triggerIssueTriage itself is verified to be a no-throw fire-and-forget
9 * even when called with garbage inputs and no API key.
10 */
11
12import { describe, it, expect } from "bun:test";
13import {
14 ISSUE_TRIAGE_MARKER,
15 triggerIssueTriage,
16 __test,
17} from "../lib/issue-triage";
18import type { IssueTriage } from "../lib/ai-generators";
19
20const triageFixture = (overrides: Partial<IssueTriage> = {}): IssueTriage => ({
21 suggestedLabels: ["bug", "ui"],
22 duplicateOfIssueNumber: null,
23 priority: "medium",
24 summary: "Login button is unreadable in dark mode.",
25 ...overrides,
26});
27
28describe("ISSUE_TRIAGE_MARKER", () => {
29 it("is a stable HTML comment so future searches keep working", () => {
30 expect(ISSUE_TRIAGE_MARKER).toBe("<!-- gluecron-issue-triage:summary -->");
31 expect(ISSUE_TRIAGE_MARKER.startsWith("<!--")).toBe(true);
32 expect(ISSUE_TRIAGE_MARKER.endsWith("-->")).toBe(true);
33 });
34});
35
36describe("renderIssueTriageComment", () => {
37 it("includes the marker, summary, priority, labels", () => {
38 const out = __test.renderIssueTriageComment(triageFixture());
39 expect(out).toContain(ISSUE_TRIAGE_MARKER);
40 expect(out).toContain("## AI Triage");
41 expect(out).toContain("Login button is unreadable in dark mode.");
42 expect(out).toContain("**Priority:** medium");
43 expect(out).toContain("`bug`");
44 expect(out).toContain("`ui`");
45 });
46
47 it("uses an italic placeholder when there are no label suggestions", () => {
48 const out = __test.renderIssueTriageComment(
49 triageFixture({ suggestedLabels: [] })
50 );
51 expect(out).toContain("_(no label suggestions)_");
52 });
53
54 it("uses an italic placeholder when summary is missing/whitespace", () => {
55 const a = __test.renderIssueTriageComment(triageFixture({ summary: "" }));
56 const b = __test.renderIssueTriageComment(triageFixture({ summary: " " }));
57 expect(a).toContain("_(no summary)_");
58 expect(b).toContain("_(no summary)_");
59 });
60
61 it("renders a duplicate callout when AI flagged one", () => {
62 const out = __test.renderIssueTriageComment(
63 triageFixture({ duplicateOfIssueNumber: 42 })
64 );
65 expect(out).toContain("**Possible duplicate of:** #42");
66 });
67
68 it("omits the duplicate callout for null / 0 / negative numbers", () => {
69 const a = __test.renderIssueTriageComment(triageFixture({ duplicateOfIssueNumber: null }));
70 const b = __test.renderIssueTriageComment(
71 triageFixture({ duplicateOfIssueNumber: 0 as any })
72 );
73 const c = __test.renderIssueTriageComment(
74 triageFixture({ duplicateOfIssueNumber: -1 as any })
75 );
76 for (const out of [a, b, c]) {
77 expect(out).not.toContain("Possible duplicate of");
78 }
79 });
80
81 it("ends with the suggestions-only disclaimer", () => {
82 const out = __test.renderIssueTriageComment(triageFixture());
83 expect(out.trimEnd().endsWith(
84 "_Suggestions only — nothing has been applied. The author stays in control._"
85 )).toBe(true);
86 });
87
88 it("formats critical priority literally (no pictographic emoji)", () => {
89 const out = __test.renderIssueTriageComment(
90 triageFixture({ priority: "critical" })
91 );
92 expect(out).toContain("**Priority:** critical");
93 expect(/\p{Extended_Pictographic}/u.test(out)).toBe(false);
94 });
95});
96
97describe("__test fail-open contracts", () => {
98 it("alreadyTriaged returns false when no DB / unknown issue", async () => {
99 const out = await __test.alreadyTriaged(
100 "00000000-0000-0000-0000-000000000000"
101 );
102 expect(out).toBe(false);
103 });
104
105 it("loadAvailableLabels returns [] for an unknown repo", async () => {
106 const out = await __test.loadAvailableLabels(
107 "00000000-0000-0000-0000-000000000000"
108 );
109 expect(Array.isArray(out)).toBe(true);
110 });
111
112 it("loadRecentIssues returns [] for an unknown repo", async () => {
113 const out = await __test.loadRecentIssues(
114 "00000000-0000-0000-0000-000000000000",
115 "00000000-0000-0000-0000-000000000000"
116 );
117 expect(Array.isArray(out)).toBe(true);
118 });
119});
120
121describe("triggerIssueTriage — fire-and-forget never throws", () => {
122 const before = process.env.ANTHROPIC_API_KEY;
123
124 it("resolves without throwing when API key is absent", async () => {
125 delete process.env.ANTHROPIC_API_KEY;
126 try {
127 let threw = false;
128 try {
129 await triggerIssueTriage({
130 ownerName: "alice",
131 repoName: "demo",
132 repositoryId: "00000000-0000-0000-0000-000000000000",
133 issueId: "00000000-0000-0000-0000-000000000000",
134 issueNumber: 1,
135 authorId: "00000000-0000-0000-0000-000000000000",
136 title: "Test",
137 body: "",
138 });
139 } catch {
140 threw = true;
141 }
142 expect(threw).toBe(false);
143 } finally {
144 if (before) process.env.ANTHROPIC_API_KEY = before;
145 }
146 });
147
148 it("never throws on garbage inputs", async () => {
149 let threw = false;
150 try {
151 await triggerIssueTriage({
152 ownerName: "",
153 repoName: "",
154 repositoryId: "not-a-uuid",
155 issueId: "not-a-uuid",
156 issueNumber: -1,
157 authorId: "not-a-uuid",
158 title: "",
159 body: "",
160 });
161 } catch {
162 threw = true;
163 }
164 expect(threw).toBe(false);
165 });
166});
Modifiedsrc/lib/ai-generators.ts+1−1View fileUnifiedSplit
102102 return extractText(message).trim();
103103}
104104
105interface IssueTriage {
105export interface IssueTriage {
106106 suggestedLabels: string[];
107107 duplicateOfIssueNumber: number | null;
108108 priority: "critical" | "high" | "medium" | "low";
Addedsrc/lib/issue-triage.ts+186−0View fileUnifiedSplit
1/**
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};
Modifiedsrc/routes/issues.tsx+15−0View fileUnifiedSplit
2020import { loadIssueTemplate } from "../lib/templates";
2121import { renderMarkdown } from "../lib/markdown";
2222import { liveCommentBannerScript } from "../lib/sse-client";
23import { triggerIssueTriage } from "../lib/issue-triage";
2324import { softAuth, requireAuth } from "../middleware/auth";
2425import type { AuthEnv } from "../middleware/auth";
2526import { requireRepoAccess } from "../middleware/repo-access";
264265 .set({ issueCount: resolved.repo.issueCount + 1 })
265266 .where(eq(repositories.id, resolved.repo.id));
266267
268 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
269 // suggested labels, priority, summary, and a possible-duplicate
270 // callout. Suggestions only — nothing applied automatically.
271 triggerIssueTriage({
272 ownerName,
273 repoName,
274 repositoryId: resolved.repo.id,
275 issueId: issue.id,
276 issueNumber: issue.number,
277 authorId: user.id,
278 title,
279 body: issueBody,
280 }).catch(() => {});
281
267282 return c.redirect(
268283 `/${ownerName}/${repoName}/issues/${issue.number}`
269284 );
270285