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.
| 3ef4c9d | 1 | /** |
| 2 | * AI generators — commit messages, PR descriptions, changelogs, issue triage. | |
| 3 | * All exposed via POST endpoints for CLI hooks + web UI convenience. | |
| 4 | */ | |
| 5 | ||
| 6 | import { | |
| 7 | getAnthropic, | |
| 8 | MODEL_HAIKU, | |
| 9 | MODEL_SONNET, | |
| 10 | extractText, | |
| 11 | parseJsonResponse, | |
| 12 | isAiAvailable, | |
| 13 | } from "./ai-client"; | |
| 14 | ||
| 15 | export 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 | ||
| 28 | Diff: | |
| 29 | \`\`\` | |
| 30 | ${diff.slice(0, 40000)} | |
| 31 | \`\`\``, | |
| 32 | }, | |
| 33 | ], | |
| 34 | }); | |
| 35 | return extractText(message).trim(); | |
| 36 | } | |
| 37 | ||
| 38 | export 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 | ||
| 52 | Title: ${title} | |
| 53 | ||
| 54 | Diff: | |
| 55 | \`\`\` | |
| 56 | ${diff.slice(0, 60000)} | |
| 57 | \`\`\``, | |
| 58 | }, | |
| 59 | ], | |
| 60 | }); | |
| 61 | return extractText(message).trim(); | |
| 62 | } | |
| 63 | ||
| 64 | export 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"); | |
| 84 | const message = await client.messages.create({ | |
| 85 | model: MODEL_SONNET, | |
| 86 | max_tokens: 2048, | |
| 87 | messages: [ | |
| 88 | { | |
| 89 | role: "user", | |
| 90 | content: `Generate a polished release-notes changelog in Markdown for ${repoFullName}. | |
| 91 | ||
| 92 | Release: ${toRef} | |
| 93 | Previous: ${fromRef || "(initial)"} | |
| 94 | ||
| 95 | Group 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. | |
| 96 | ||
| 97 | Commits: | |
| 98 | ${commitBlob}`, | |
| 99 | }, | |
| 100 | ], | |
| 101 | }); | |
| 102 | return extractText(message).trim(); | |
| 103 | } | |
| 104 | ||
| 105 | interface IssueTriage { | |
| 106 | suggestedLabels: string[]; | |
| 107 | duplicateOfIssueNumber: number | null; | |
| 108 | priority: "critical" | "high" | "medium" | "low"; | |
| 109 | summary: string; | |
| 110 | } | |
| 111 | ||
| 112 | export async function triageIssue( | |
| 113 | title: string, | |
| 114 | body: string, | |
| 115 | existingLabels: string[], | |
| 116 | recentIssues: Array<{ number: number; title: string }> | |
| 117 | ): Promise<IssueTriage> { | |
| 118 | const fallback: IssueTriage = { | |
| 119 | suggestedLabels: [], | |
| 120 | duplicateOfIssueNumber: null, | |
| 121 | priority: "medium", | |
| 122 | summary: "", | |
| 123 | }; | |
| 124 | if (!isAiAvailable()) return fallback; | |
| 125 | const client = getAnthropic(); | |
| 126 | const message = await client.messages.create({ | |
| 127 | model: MODEL_HAIKU, | |
| 128 | max_tokens: 512, | |
| 129 | messages: [ | |
| 130 | { | |
| 131 | role: "user", | |
| 132 | content: `Triage this new GitHub-style issue. | |
| 133 | ||
| 134 | Title: ${title} | |
| 135 | Body: | |
| 136 | ${body.slice(0, 4000)} | |
| 137 | ||
| 138 | Available labels: ${existingLabels.join(", ") || "(none)"} | |
| 139 | Recent issues (to check for duplicates): | |
| 140 | ${recentIssues.map((i) => `#${i.number}: ${i.title}`).join("\n")} | |
| 141 | ||
| 142 | Respond ONLY with JSON: | |
| 143 | { | |
| 144 | "suggestedLabels": ["label1", "label2"], | |
| 145 | "duplicateOfIssueNumber": null, | |
| 146 | "priority": "medium", | |
| 147 | "summary": "one sentence" | |
| 148 | } | |
| 149 | Only suggest labels from the available list. Set duplicateOfIssueNumber only when confident.`, | |
| 150 | }, | |
| 151 | ], | |
| 152 | }); | |
| 153 | const parsed = parseJsonResponse<IssueTriage>(extractText(message)); | |
| 154 | if (!parsed) return fallback; | |
| 155 | return { | |
| 156 | suggestedLabels: Array.isArray(parsed.suggestedLabels) | |
| 157 | ? parsed.suggestedLabels.filter((l) => existingLabels.includes(l)) | |
| 158 | : [], | |
| 159 | duplicateOfIssueNumber: | |
| 160 | typeof parsed.duplicateOfIssueNumber === "number" | |
| 161 | ? parsed.duplicateOfIssueNumber | |
| 162 | : null, | |
| 163 | priority: (["critical", "high", "medium", "low"] as const).includes( | |
| 164 | parsed.priority as never | |
| 165 | ) | |
| 166 | ? parsed.priority | |
| 167 | : "medium", | |
| 168 | summary: typeof parsed.summary === "string" ? parsed.summary : "", | |
| 169 | }; | |
| 170 | } | |
| 3cbe3d6 | 171 | |
| 172 | /** | |
| 173 | * D3 — AI PR triage. Reads the PR title/body + optional diff summary and | |
| 174 | * suggests labels, reviewers, and a priority. Never throws; degrades to | |
| 175 | * empty suggestions when the Anthropic key is absent. | |
| 176 | */ | |
| 177 | export interface PrTriage { | |
| 178 | suggestedLabels: string[]; | |
| 179 | suggestedReviewerUsernames: string[]; | |
| 180 | priority: "critical" | "high" | "medium" | "low"; | |
| 181 | riskArea: "frontend" | "backend" | "infra" | "docs" | "tests" | "mixed"; | |
| 182 | summary: string; | |
| 183 | } | |
| 184 | ||
| 185 | export async function triagePullRequest( | |
| 186 | title: string, | |
| 187 | body: string, | |
| 188 | diffSummary: string, | |
| 189 | availableLabels: string[], | |
| 190 | candidateReviewers: string[] | |
| 191 | ): Promise<PrTriage> { | |
| 192 | const fallback: PrTriage = { | |
| 193 | suggestedLabels: [], | |
| 194 | suggestedReviewerUsernames: [], | |
| 195 | priority: "medium", | |
| 196 | riskArea: "mixed", | |
| 197 | summary: "", | |
| 198 | }; | |
| 199 | if (!isAiAvailable()) return fallback; | |
| 200 | try { | |
| 201 | const client = getAnthropic(); | |
| 202 | const message = await client.messages.create({ | |
| 203 | model: MODEL_HAIKU, | |
| 204 | max_tokens: 512, | |
| 205 | messages: [ | |
| 206 | { | |
| 207 | role: "user", | |
| 208 | content: `Triage this new pull request. | |
| 209 | ||
| 210 | Title: ${title} | |
| 211 | Body: | |
| 212 | ${body.slice(0, 4000)} | |
| 213 | ||
| 214 | Diff summary (paths + line counts): | |
| 215 | ${diffSummary.slice(0, 4000)} | |
| 216 | ||
| 217 | Available labels: ${availableLabels.join(", ") || "(none)"} | |
| 218 | Candidate reviewers (usernames): ${candidateReviewers.join(", ") || "(none)"} | |
| 219 | ||
| 220 | Respond ONLY with JSON: | |
| 221 | { | |
| 222 | "suggestedLabels": ["label1"], | |
| 223 | "suggestedReviewerUsernames": ["alice"], | |
| 224 | "priority": "medium", | |
| 225 | "riskArea": "backend", | |
| 226 | "summary": "one-sentence description of the change" | |
| 227 | } | |
| 228 | Only 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.`, | |
| 229 | }, | |
| 230 | ], | |
| 231 | }); | |
| 232 | const parsed = parseJsonResponse<PrTriage>(extractText(message)); | |
| 233 | if (!parsed) return fallback; | |
| 234 | const allowedRisk = ["frontend", "backend", "infra", "docs", "tests", "mixed"] as const; | |
| 235 | const allowedPriority = ["critical", "high", "medium", "low"] as const; | |
| 236 | return { | |
| 237 | suggestedLabels: Array.isArray(parsed.suggestedLabels) | |
| 238 | ? parsed.suggestedLabels.filter((l) => availableLabels.includes(l)) | |
| 239 | : [], | |
| 240 | suggestedReviewerUsernames: Array.isArray(parsed.suggestedReviewerUsernames) | |
| 241 | ? parsed.suggestedReviewerUsernames.filter((u) => | |
| 242 | candidateReviewers.includes(u) | |
| 243 | ) | |
| 244 | : [], | |
| 245 | priority: allowedPriority.includes(parsed.priority as never) | |
| 246 | ? parsed.priority | |
| 247 | : "medium", | |
| 248 | riskArea: allowedRisk.includes(parsed.riskArea as never) | |
| 249 | ? parsed.riskArea | |
| 250 | : "mixed", | |
| 251 | summary: typeof parsed.summary === "string" ? parsed.summary : "", | |
| 252 | }; | |
| 253 | } catch (err) { | |
| 254 | console.error("[triagePullRequest]", err); | |
| 255 | return fallback; | |
| 256 | } | |
| 257 | } |