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.tsBlame263 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,
bc519aeClaude8 modelForTask,
3ef4c9dClaude9 extractText,
10 parseJsonResponse,
11 isAiAvailable,
12} from "./ai-client";
13
14export async function generateCommitMessage(diff: string): Promise<string> {
15 if (!isAiAvailable() || !diff.trim()) {
16 return "chore: update files";
17 }
18 const client = getAnthropic();
19 const message = await client.messages.create({
bc519aeClaude20 // Human-reviewed one-line draft → Haiku-eligible.
21 model: modelForTask("commit-message"),
3ef4c9dClaude22 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({
bc519aeClaude45 // User-facing PR description → stays on Sonnet (routed for the kill-switch).
46 model: modelForTask("pr-summary"),
3ef4c9dClaude47 max_tokens: 1024,
48 messages: [
49 {
50 role: "user",
51 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).
52
53Title: ${title}
54
55Diff:
56\`\`\`
57${diff.slice(0, 60000)}
58\`\`\``,
59 },
60 ],
61 });
62 return extractText(message).trim();
63}
64
65export async function generateChangelog(
66 repoFullName: string,
67 fromRef: string | null,
68 toRef: string,
69 commits: Array<{ sha: string; message: string; author: string }>
70): Promise<string> {
71 if (!isAiAvailable() || commits.length === 0) {
72 const header = `## ${toRef}${fromRef ? ` (since ${fromRef})` : ""}\n\n`;
73 return (
74 header +
75 commits
76 .map((c) => `- ${c.message.split("\n")[0]} (${c.sha.slice(0, 7)}) — ${c.author}`)
77 .join("\n")
78 );
79 }
80 const client = getAnthropic();
81 const commitBlob = commits
82 .slice(0, 200)
83 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]} — ${c.author}`)
84 .join("\n");
85 const message = await client.messages.create({
bc519aeClaude86 // User-facing release notes → stays on Sonnet (routed for the kill-switch).
87 model: modelForTask("changelog"),
3ef4c9dClaude88 max_tokens: 2048,
89 messages: [
90 {
91 role: "user",
92 content: `Generate a polished release-notes changelog in Markdown for ${repoFullName}.
93
94Release: ${toRef}
95Previous: ${fromRef || "(initial)"}
96
97Group 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.
98
99Commits:
100${commitBlob}`,
101 },
102 ],
103 });
104 return extractText(message).trim();
105}
106
a9ada5fClaude107export interface IssueTriage {
3ef4c9dClaude108 suggestedLabels: string[];
109 duplicateOfIssueNumber: number | null;
110 priority: "critical" | "high" | "medium" | "low";
111 summary: string;
112}
113
114export async function triageIssue(
115 title: string,
116 body: string,
117 existingLabels: string[],
118 recentIssues: Array<{ number: number; title: string }>
119): Promise<IssueTriage> {
120 const fallback: IssueTriage = {
121 suggestedLabels: [],
122 duplicateOfIssueNumber: null,
123 priority: "medium",
124 summary: "",
125 };
126 if (!isAiAvailable()) return fallback;
127 const client = getAnthropic();
128 const message = await client.messages.create({
bc519aeClaude129 // Label/priority/duplicate suggestions, validated against allowlists and
130 // trivially human-overridden → Haiku-eligible.
131 model: modelForTask("issue-triage"),
3ef4c9dClaude132 max_tokens: 512,
133 messages: [
134 {
135 role: "user",
136 content: `Triage this new GitHub-style issue.
137
138Title: ${title}
139Body:
140${body.slice(0, 4000)}
141
142Available labels: ${existingLabels.join(", ") || "(none)"}
143Recent issues (to check for duplicates):
144${recentIssues.map((i) => `#${i.number}: ${i.title}`).join("\n")}
145
146Respond ONLY with JSON:
147{
148 "suggestedLabels": ["label1", "label2"],
149 "duplicateOfIssueNumber": null,
150 "priority": "medium",
151 "summary": "one sentence"
152}
153Only suggest labels from the available list. Set duplicateOfIssueNumber only when confident.`,
154 },
155 ],
156 });
157 const parsed = parseJsonResponse<IssueTriage>(extractText(message));
158 if (!parsed) return fallback;
159 return {
160 suggestedLabels: Array.isArray(parsed.suggestedLabels)
161 ? parsed.suggestedLabels.filter((l) => existingLabels.includes(l))
162 : [],
163 duplicateOfIssueNumber:
164 typeof parsed.duplicateOfIssueNumber === "number"
165 ? parsed.duplicateOfIssueNumber
166 : null,
167 priority: (["critical", "high", "medium", "low"] as const).includes(
168 parsed.priority as never
169 )
170 ? parsed.priority
171 : "medium",
172 summary: typeof parsed.summary === "string" ? parsed.summary : "",
173 };
174}
3cbe3d6Claude175
176/**
177 * D3 — AI PR triage. Reads the PR title/body + optional diff summary and
178 * suggests labels, reviewers, and a priority. Never throws; degrades to
179 * empty suggestions when the Anthropic key is absent.
180 */
181export interface PrTriage {
182 suggestedLabels: string[];
183 suggestedReviewerUsernames: string[];
184 priority: "critical" | "high" | "medium" | "low";
185 riskArea: "frontend" | "backend" | "infra" | "docs" | "tests" | "mixed";
186 summary: string;
187}
188
189export async function triagePullRequest(
190 title: string,
191 body: string,
192 diffSummary: string,
193 availableLabels: string[],
194 candidateReviewers: string[]
195): Promise<PrTriage> {
196 const fallback: PrTriage = {
197 suggestedLabels: [],
198 suggestedReviewerUsernames: [],
199 priority: "medium",
200 riskArea: "mixed",
201 summary: "",
202 };
203 if (!isAiAvailable()) return fallback;
204 try {
205 const client = getAnthropic();
206 const message = await client.messages.create({
bc519aeClaude207 // Label/reviewer/priority suggestions, validated against allowlists and
208 // trivially human-overridden → Haiku-eligible.
209 model: modelForTask("pr-triage"),
3cbe3d6Claude210 max_tokens: 512,
211 messages: [
212 {
213 role: "user",
214 content: `Triage this new pull request.
215
216Title: ${title}
217Body:
218${body.slice(0, 4000)}
219
220Diff summary (paths + line counts):
221${diffSummary.slice(0, 4000)}
222
223Available labels: ${availableLabels.join(", ") || "(none)"}
224Candidate reviewers (usernames): ${candidateReviewers.join(", ") || "(none)"}
225
226Respond ONLY with JSON:
227{
228 "suggestedLabels": ["label1"],
229 "suggestedReviewerUsernames": ["alice"],
230 "priority": "medium",
231 "riskArea": "backend",
232 "summary": "one-sentence description of the change"
233}
234Only 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.`,
235 },
236 ],
237 });
238 const parsed = parseJsonResponse<PrTriage>(extractText(message));
239 if (!parsed) return fallback;
240 const allowedRisk = ["frontend", "backend", "infra", "docs", "tests", "mixed"] as const;
241 const allowedPriority = ["critical", "high", "medium", "low"] as const;
242 return {
243 suggestedLabels: Array.isArray(parsed.suggestedLabels)
244 ? parsed.suggestedLabels.filter((l) => availableLabels.includes(l))
245 : [],
246 suggestedReviewerUsernames: Array.isArray(parsed.suggestedReviewerUsernames)
247 ? parsed.suggestedReviewerUsernames.filter((u) =>
248 candidateReviewers.includes(u)
249 )
250 : [],
251 priority: allowedPriority.includes(parsed.priority as never)
252 ? parsed.priority
253 : "medium",
254 riskArea: allowedRisk.includes(parsed.riskArea as never)
255 ? parsed.riskArea
256 : "mixed",
257 summary: typeof parsed.summary === "string" ? parsed.summary : "",
258 };
259 } catch (err) {
260 console.error("[triagePullRequest]", err);
261 return fallback;
262 }
263}