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"; | |
| 40e7738 | 14 | import { recordAi } from "./ai-flywheel"; |
| 3ef4c9d | 15 | |
| 16 | export async function generateCommitMessage(diff: string): Promise<string> { | |
| 17 | if (!isAiAvailable() || !diff.trim()) { | |
| 18 | return "chore: update files"; | |
| 19 | } | |
| 20 | const client = getAnthropic(); | |
| 40e7738 | 21 | 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. | |
| 3ef4c9d | 35 | |
| 36 | Diff: | |
| 37 | \`\`\` | |
| 38 | ${diff.slice(0, 40000)} | |
| 39 | \`\`\``, | |
| 40e7738 | 40 | }, |
| 41 | ], | |
| 42 | }) | |
| 43 | ); | |
| 3ef4c9d | 44 | return extractText(message).trim(); |
| 45 | } | |
| 46 | ||
| 47 | export async function generatePrSummary( | |
| 48 | title: string, | |
| 49 | diff: string | |
| 50 | ): Promise<string> { | |
| 51 | if (!isAiAvailable() || !diff.trim()) return ""; | |
| 52 | const client = getAnthropic(); | |
| 40e7738 | 53 | 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). | |
| 3ef4c9d | 67 | |
| 68 | Title: ${title} | |
| 69 | ||
| 70 | Diff: | |
| 71 | \`\`\` | |
| 72 | ${diff.slice(0, 60000)} | |
| 73 | \`\`\``, | |
| 40e7738 | 74 | }, |
| 75 | ], | |
| 76 | }) | |
| 77 | ); | |
| 3ef4c9d | 78 | return extractText(message).trim(); |
| 79 | } | |
| 80 | ||
| 81 | export 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"); | |
| 40e7738 | 101 | 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({ | |
| 3ef4c9d | 109 | 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 | ||
| 116 | Release: ${toRef} | |
| 117 | Previous: ${fromRef || "(initial)"} | |
| 118 | ||
| 119 | 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. | |
| 120 | ||
| 121 | Commits: | |
| 122 | ${commitBlob}`, | |
| 123 | }, | |
| 124 | ], | |
| 40e7738 | 125 | }) |
| 126 | ); | |
| 3ef4c9d | 127 | return extractText(message).trim(); |
| 128 | } | |
| 129 | ||
| 130 | interface IssueTriage { | |
| 131 | suggestedLabels: string[]; | |
| 132 | duplicateOfIssueNumber: number | null; | |
| 133 | priority: "critical" | "high" | "medium" | "low"; | |
| 134 | summary: string; | |
| 135 | } | |
| 136 | ||
| 137 | export 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(); | |
| 40e7738 | 151 | 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({ | |
| 3ef4c9d | 158 | model: MODEL_HAIKU, |
| 159 | max_tokens: 512, | |
| 160 | messages: [ | |
| 161 | { | |
| 162 | role: "user", | |
| 163 | content: `Triage this new GitHub-style issue. | |
| 164 | ||
| 165 | Title: ${title} | |
| 166 | Body: | |
| 167 | ${body.slice(0, 4000)} | |
| 168 | ||
| 169 | Available labels: ${existingLabels.join(", ") || "(none)"} | |
| 170 | Recent issues (to check for duplicates): | |
| 171 | ${recentIssues.map((i) => `#${i.number}: ${i.title}`).join("\n")} | |
| 172 | ||
| 173 | Respond ONLY with JSON: | |
| 174 | { | |
| 175 | "suggestedLabels": ["label1", "label2"], | |
| 176 | "duplicateOfIssueNumber": null, | |
| 177 | "priority": "medium", | |
| 178 | "summary": "one sentence" | |
| 179 | } | |
| 180 | Only suggest labels from the available list. Set duplicateOfIssueNumber only when confident.`, | |
| 181 | }, | |
| 182 | ], | |
| 40e7738 | 183 | }) |
| 184 | ); | |
| 3ef4c9d | 185 | 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 | } | |
| 3cbe3d6 | 203 | |
| 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 | */ | |
| 209 | export 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 | ||
| 217 | export 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(); | |
| 40e7738 | 234 | 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({ | |
| 3cbe3d6 | 241 | model: MODEL_HAIKU, |
| 242 | max_tokens: 512, | |
| 243 | messages: [ | |
| 244 | { | |
| 245 | role: "user", | |
| 246 | content: `Triage this new pull request. | |
| 247 | ||
| 248 | Title: ${title} | |
| 249 | Body: | |
| 250 | ${body.slice(0, 4000)} | |
| 251 | ||
| 252 | Diff summary (paths + line counts): | |
| 253 | ${diffSummary.slice(0, 4000)} | |
| 254 | ||
| 255 | Available labels: ${availableLabels.join(", ") || "(none)"} | |
| 256 | Candidate reviewers (usernames): ${candidateReviewers.join(", ") || "(none)"} | |
| 257 | ||
| 258 | Respond 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 | } | |
| 266 | 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.`, | |
| 267 | }, | |
| 268 | ], | |
| 40e7738 | 269 | }) |
| 270 | ); | |
| 3cbe3d6 | 271 | 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 | } |