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

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.tsBlame296 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";
40e7738Claude14import { recordAi } from "./ai-flywheel";
3ef4c9dClaude15
16export async function generateCommitMessage(diff: string): Promise<string> {
17 if (!isAiAvailable() || !diff.trim()) {
18 return "chore: update files";
19 }
20 const client = getAnthropic();
40e7738Claude21 const message = await recordAi(
22 {
23 actionType: "commit-message",
24 model: MODEL_HAIKU,
25 summary: "generate commit message",
26 },
27 () =>
28 client.messages.create({
29 model: MODEL_HAIKU,
30 max_tokens: 512,
31 messages: [
32 {
33 role: "user",
34 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.
3ef4c9dClaude35
36Diff:
37\`\`\`
38${diff.slice(0, 40000)}
39\`\`\``,
40e7738Claude40 },
41 ],
42 })
43 );
3ef4c9dClaude44 return extractText(message).trim();
45}
46
47export async function generatePrSummary(
48 title: string,
49 diff: string
50): Promise<string> {
51 if (!isAiAvailable() || !diff.trim()) return "";
52 const client = getAnthropic();
40e7738Claude53 const message = await recordAi(
54 {
55 actionType: "pr-summary",
56 model: MODEL_SONNET,
57 summary: `pr summary "${title.slice(0, 80)}"`,
58 },
59 () =>
60 client.messages.create({
61 model: MODEL_SONNET,
62 max_tokens: 1024,
63 messages: [
64 {
65 role: "user",
66 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).
3ef4c9dClaude67
68Title: ${title}
69
70Diff:
71\`\`\`
72${diff.slice(0, 60000)}
73\`\`\``,
40e7738Claude74 },
75 ],
76 })
77 );
3ef4c9dClaude78 return extractText(message).trim();
79}
80
81export async function generateChangelog(
82 repoFullName: string,
83 fromRef: string | null,
84 toRef: string,
85 commits: Array<{ sha: string; message: string; author: string }>
86): Promise<string> {
87 if (!isAiAvailable() || commits.length === 0) {
88 const header = `## ${toRef}${fromRef ? ` (since ${fromRef})` : ""}\n\n`;
89 return (
90 header +
91 commits
92 .map((c) => `- ${c.message.split("\n")[0]} (${c.sha.slice(0, 7)}) — ${c.author}`)
93 .join("\n")
94 );
95 }
96 const client = getAnthropic();
97 const commitBlob = commits
98 .slice(0, 200)
99 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]} — ${c.author}`)
100 .join("\n");
40e7738Claude101 const message = await recordAi(
102 {
103 actionType: "changelog",
104 model: MODEL_SONNET,
105 summary: `changelog ${repoFullName} ${fromRef ?? "(initial)"}..${toRef}`,
106 metadata: { commits: commits.length },
107 },
108 () => client.messages.create({
3ef4c9dClaude109 model: MODEL_SONNET,
110 max_tokens: 2048,
111 messages: [
112 {
113 role: "user",
114 content: `Generate a polished release-notes changelog in Markdown for ${repoFullName}.
115
116Release: ${toRef}
117Previous: ${fromRef || "(initial)"}
118
119Group 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.
120
121Commits:
122${commitBlob}`,
123 },
124 ],
40e7738Claude125 })
126 );
3ef4c9dClaude127 return extractText(message).trim();
128}
129
130interface IssueTriage {
131 suggestedLabels: string[];
132 duplicateOfIssueNumber: number | null;
133 priority: "critical" | "high" | "medium" | "low";
134 summary: string;
135}
136
137export async function triageIssue(
138 title: string,
139 body: string,
140 existingLabels: string[],
141 recentIssues: Array<{ number: number; title: string }>
142): Promise<IssueTriage> {
143 const fallback: IssueTriage = {
144 suggestedLabels: [],
145 duplicateOfIssueNumber: null,
146 priority: "medium",
147 summary: "",
148 };
149 if (!isAiAvailable()) return fallback;
150 const client = getAnthropic();
40e7738Claude151 const message = await recordAi(
152 {
153 actionType: "issue-triage",
154 model: MODEL_HAIKU,
155 summary: `triage issue "${title.slice(0, 80)}"`,
156 },
157 () => client.messages.create({
3ef4c9dClaude158 model: MODEL_HAIKU,
159 max_tokens: 512,
160 messages: [
161 {
162 role: "user",
163 content: `Triage this new GitHub-style issue.
164
165Title: ${title}
166Body:
167${body.slice(0, 4000)}
168
169Available labels: ${existingLabels.join(", ") || "(none)"}
170Recent issues (to check for duplicates):
171${recentIssues.map((i) => `#${i.number}: ${i.title}`).join("\n")}
172
173Respond ONLY with JSON:
174{
175 "suggestedLabels": ["label1", "label2"],
176 "duplicateOfIssueNumber": null,
177 "priority": "medium",
178 "summary": "one sentence"
179}
180Only suggest labels from the available list. Set duplicateOfIssueNumber only when confident.`,
181 },
182 ],
40e7738Claude183 })
184 );
3ef4c9dClaude185 const parsed = parseJsonResponse<IssueTriage>(extractText(message));
186 if (!parsed) return fallback;
187 return {
188 suggestedLabels: Array.isArray(parsed.suggestedLabels)
189 ? parsed.suggestedLabels.filter((l) => existingLabels.includes(l))
190 : [],
191 duplicateOfIssueNumber:
192 typeof parsed.duplicateOfIssueNumber === "number"
193 ? parsed.duplicateOfIssueNumber
194 : null,
195 priority: (["critical", "high", "medium", "low"] as const).includes(
196 parsed.priority as never
197 )
198 ? parsed.priority
199 : "medium",
200 summary: typeof parsed.summary === "string" ? parsed.summary : "",
201 };
202}
3cbe3d6Claude203
204/**
205 * D3 — AI PR triage. Reads the PR title/body + optional diff summary and
206 * suggests labels, reviewers, and a priority. Never throws; degrades to
207 * empty suggestions when the Anthropic key is absent.
208 */
209export interface PrTriage {
210 suggestedLabels: string[];
211 suggestedReviewerUsernames: string[];
212 priority: "critical" | "high" | "medium" | "low";
213 riskArea: "frontend" | "backend" | "infra" | "docs" | "tests" | "mixed";
214 summary: string;
215}
216
217export async function triagePullRequest(
218 title: string,
219 body: string,
220 diffSummary: string,
221 availableLabels: string[],
222 candidateReviewers: string[]
223): Promise<PrTriage> {
224 const fallback: PrTriage = {
225 suggestedLabels: [],
226 suggestedReviewerUsernames: [],
227 priority: "medium",
228 riskArea: "mixed",
229 summary: "",
230 };
231 if (!isAiAvailable()) return fallback;
232 try {
233 const client = getAnthropic();
40e7738Claude234 const message = await recordAi(
235 {
236 actionType: "triage",
237 model: MODEL_HAIKU,
238 summary: `triage PR "${title.slice(0, 80)}"`,
239 },
240 () => client.messages.create({
3cbe3d6Claude241 model: MODEL_HAIKU,
242 max_tokens: 512,
243 messages: [
244 {
245 role: "user",
246 content: `Triage this new pull request.
247
248Title: ${title}
249Body:
250${body.slice(0, 4000)}
251
252Diff summary (paths + line counts):
253${diffSummary.slice(0, 4000)}
254
255Available labels: ${availableLabels.join(", ") || "(none)"}
256Candidate reviewers (usernames): ${candidateReviewers.join(", ") || "(none)"}
257
258Respond ONLY with JSON:
259{
260 "suggestedLabels": ["label1"],
261 "suggestedReviewerUsernames": ["alice"],
262 "priority": "medium",
263 "riskArea": "backend",
264 "summary": "one-sentence description of the change"
265}
266Only 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.`,
267 },
268 ],
40e7738Claude269 })
270 );
3cbe3d6Claude271 const parsed = parseJsonResponse<PrTriage>(extractText(message));
272 if (!parsed) return fallback;
273 const allowedRisk = ["frontend", "backend", "infra", "docs", "tests", "mixed"] as const;
274 const allowedPriority = ["critical", "high", "medium", "low"] as const;
275 return {
276 suggestedLabels: Array.isArray(parsed.suggestedLabels)
277 ? parsed.suggestedLabels.filter((l) => availableLabels.includes(l))
278 : [],
279 suggestedReviewerUsernames: Array.isArray(parsed.suggestedReviewerUsernames)
280 ? parsed.suggestedReviewerUsernames.filter((u) =>
281 candidateReviewers.includes(u)
282 )
283 : [],
284 priority: allowedPriority.includes(parsed.priority as never)
285 ? parsed.priority
286 : "medium",
287 riskArea: allowedRisk.includes(parsed.riskArea as never)
288 ? parsed.riskArea
289 : "mixed",
290 summary: typeof parsed.summary === "string" ? parsed.summary : "",
291 };
292 } catch (err) {
293 console.error("[triagePullRequest]", err);
294 return fallback;
295 }
296}