Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

ai-generators.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.

ai-generators.tsBlame265 lines · 1 contributor
3ef4c9dClaude1/**
2 * AI generators — commit messages, PR descriptions, changelogs, issue triage.
3 * All exposed via POST endpoints for CLI hooks + web UI convenience.
4 */
5
6import {
7 getAnthropic,
8 MODEL_HAIKU,
9 MODEL_SONNET,
10 extractText,
11 parseJsonResponse,
12 isAiAvailable,
13} from "./ai-client";
14
15export async function generateCommitMessage(diff: string): Promise<string> {
16 if (!isAiAvailable() || !diff.trim()) {
17 return "chore: update files";
18 }
19 const client = getAnthropic();
20 const message = await client.messages.create({
21 model: MODEL_HAIKU,
22 max_tokens: 512,
23 messages: [
24 {
25 role: "user",
26 content: `Write a single conventional-commit message for this diff. One line subject under 72 chars, lowercase type (feat/fix/refactor/chore/docs/test/perf/style), then optional body wrapped at 72 chars. No backticks or markdown.
27
28Diff:
29\`\`\`
30${diff.slice(0, 40000)}
31\`\`\``,
32 },
33 ],
34 });
35 return extractText(message).trim();
36}
37
38export async function generatePrSummary(
39 title: string,
40 diff: string
41): Promise<string> {
42 if (!isAiAvailable() || !diff.trim()) return "";
43 const client = getAnthropic();
44 const message = await client.messages.create({
45 model: MODEL_SONNET,
46 max_tokens: 1024,
47 messages: [
48 {
49 role: "user",
50 content: `Generate a Markdown PR description for the following changes. Include: Summary (1-3 sentences focused on why), Key changes (bullets), Test plan (bullets), Risks (bullets — omit if minor).
51
52Title: ${title}
53
54Diff:
55\`\`\`
56${diff.slice(0, 60000)}
57\`\`\``,
58 },
59 ],
60 });
61 return extractText(message).trim();
62}
63
64export async function generateChangelog(
65 repoFullName: string,
66 fromRef: string | null,
67 toRef: string,
68 commits: Array<{ sha: string; message: string; author: string }>
69): Promise<string> {
70 if (!isAiAvailable() || commits.length === 0) {
71 const header = `## ${toRef}${fromRef ? ` (since ${fromRef})` : ""}\n\n`;
72 return (
73 header +
74 commits
75 .map((c) => `- ${c.message.split("\n")[0]} (${c.sha.slice(0, 7)}) — ${c.author}`)
76 .join("\n")
77 );
78 }
79 const client = getAnthropic();
80 const commitBlob = commits
81 .slice(0, 200)
82 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]} — ${c.author}`)
83 .join("\n");
eb3b81aClaude84 const plainFallback = () =>
85 `## ${toRef}${fromRef ? ` (since ${fromRef})` : ""}\n\n` +
86 commits.map((c) => `- ${c.message.split("\n")[0]} (${c.sha.slice(0, 7)}) — ${c.author}`).join("\n");
87 try {
88 const message = await client.messages.create({
89 model: MODEL_SONNET,
90 max_tokens: 2048,
91 messages: [
92 {
93 role: "user",
94 content: `Generate a polished release-notes changelog in Markdown for ${repoFullName}.
3ef4c9dClaude95
96Release: ${toRef}
97Previous: ${fromRef || "(initial)"}
98
99Group commits by category (Features, Fixes, Performance, Refactoring, Docs, Other). Omit empty categories. Use bullet points. Keep it concise — no marketing fluff, just the facts a user of the project needs. Reference SHAs in parentheses.
100
101Commits:
102${commitBlob}`,
eb3b81aClaude103 },
104 ],
105 });
106 return extractText(message).trim() || plainFallback();
107 } catch (err) {
108 console.error("[ai-generators] generateChangelog failed:", err);
109 return plainFallback();
110 }
3ef4c9dClaude111}
112
113interface IssueTriage {
114 suggestedLabels: string[];
115 duplicateOfIssueNumber: number | null;
116 priority: "critical" | "high" | "medium" | "low";
117 summary: string;
118}
119
120export async function triageIssue(
121 title: string,
122 body: string,
123 existingLabels: string[],
124 recentIssues: Array<{ number: number; title: string }>
125): Promise<IssueTriage> {
126 const fallback: IssueTriage = {
127 suggestedLabels: [],
128 duplicateOfIssueNumber: null,
129 priority: "medium",
130 summary: "",
131 };
132 if (!isAiAvailable()) return fallback;
133 const client = getAnthropic();
134 const message = await client.messages.create({
135 model: MODEL_HAIKU,
136 max_tokens: 512,
137 messages: [
138 {
139 role: "user",
140 content: `Triage this new GitHub-style issue.
141
142Title: ${title}
143Body:
144${body.slice(0, 4000)}
145
146Available labels: ${existingLabels.join(", ") || "(none)"}
147Recent issues (to check for duplicates):
148${recentIssues.map((i) => `#${i.number}: ${i.title}`).join("\n")}
149
150Respond ONLY with JSON:
151{
152 "suggestedLabels": ["label1", "label2"],
153 "duplicateOfIssueNumber": null,
154 "priority": "medium",
155 "summary": "one sentence"
156}
157Only suggest labels from the available list. Set duplicateOfIssueNumber only when confident.`,
158 },
159 ],
160 });
161 const parsed = parseJsonResponse<IssueTriage>(extractText(message));
162 if (!parsed) return fallback;
163 return {
164 suggestedLabels: Array.isArray(parsed.suggestedLabels)
165 ? parsed.suggestedLabels.filter((l) => existingLabels.includes(l))
166 : [],
167 duplicateOfIssueNumber:
168 typeof parsed.duplicateOfIssueNumber === "number"
169 ? parsed.duplicateOfIssueNumber
170 : null,
171 priority: (["critical", "high", "medium", "low"] as const).includes(
172 parsed.priority as never
173 )
174 ? parsed.priority
175 : "medium",
176 summary: typeof parsed.summary === "string" ? parsed.summary : "",
177 };
178}
3cbe3d6Claude179
180/**
181 * D3 — AI PR triage. Reads the PR title/body + optional diff summary and
182 * suggests labels, reviewers, and a priority. Never throws; degrades to
183 * empty suggestions when the Anthropic key is absent.
184 */
185export interface PrTriage {
186 suggestedLabels: string[];
187 suggestedReviewerUsernames: string[];
188 priority: "critical" | "high" | "medium" | "low";
189 riskArea: "frontend" | "backend" | "infra" | "docs" | "tests" | "mixed";
190 summary: string;
191}
192
193export async function triagePullRequest(
194 title: string,
195 body: string,
196 diffSummary: string,
197 availableLabels: string[],
198 candidateReviewers: string[]
199): Promise<PrTriage> {
200 const fallback: PrTriage = {
201 suggestedLabels: [],
202 suggestedReviewerUsernames: [],
203 priority: "medium",
204 riskArea: "mixed",
205 summary: "",
206 };
207 if (!isAiAvailable()) return fallback;
208 try {
209 const client = getAnthropic();
210 const message = await client.messages.create({
211 model: MODEL_HAIKU,
212 max_tokens: 512,
213 messages: [
214 {
215 role: "user",
216 content: `Triage this new pull request.
217
218Title: ${title}
219Body:
220${body.slice(0, 4000)}
221
222Diff summary (paths + line counts):
223${diffSummary.slice(0, 4000)}
224
225Available labels: ${availableLabels.join(", ") || "(none)"}
226Candidate reviewers (usernames): ${candidateReviewers.join(", ") || "(none)"}
227
228Respond ONLY with JSON:
229{
230 "suggestedLabels": ["label1"],
231 "suggestedReviewerUsernames": ["alice"],
232 "priority": "medium",
233 "riskArea": "backend",
234 "summary": "one-sentence description of the change"
235}
236Only pick labels from the available list. Only pick reviewers from the candidate list. Priority must be one of critical|high|medium|low. riskArea must be one of frontend|backend|infra|docs|tests|mixed.`,
237 },
238 ],
239 });
240 const parsed = parseJsonResponse<PrTriage>(extractText(message));
241 if (!parsed) return fallback;
242 const allowedRisk = ["frontend", "backend", "infra", "docs", "tests", "mixed"] as const;
243 const allowedPriority = ["critical", "high", "medium", "low"] as const;
244 return {
245 suggestedLabels: Array.isArray(parsed.suggestedLabels)
246 ? parsed.suggestedLabels.filter((l) => availableLabels.includes(l))
247 : [],
248 suggestedReviewerUsernames: Array.isArray(parsed.suggestedReviewerUsernames)
249 ? parsed.suggestedReviewerUsernames.filter((u) =>
250 candidateReviewers.includes(u)
251 )
252 : [],
253 priority: allowedPriority.includes(parsed.priority as never)
254 ? parsed.priority
255 : "medium",
256 riskArea: allowedRisk.includes(parsed.riskArea as never)
257 ? parsed.riskArea
258 : "mixed",
259 summary: typeof parsed.summary === "string" ? parsed.summary : "",
260 };
261 } catch (err) {
262 console.error("[triagePullRequest]", err);
263 return fallback;
264 }
265}