CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
incident-analyzer.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.
| 70e3293 | 1 | /** |
| 2 | * AI incident analysis — given an alert title/description and a repo, identify | |
| 3 | * the likely guilty commit(s) and produce a fix suggestion. | |
| 4 | * | |
| 5 | * Used by src/routes/incident-hooks.tsx when PagerDuty / Datadog / Opsgenie / | |
| 6 | * generic webhook alerts land. Degrades gracefully: without ANTHROPIC_API_KEY | |
| 7 | * the analysis fields are populated with safe fallback text and the caller | |
| 8 | * still opens the issue, it just skips the draft PR. | |
| 9 | */ | |
| 10 | ||
| 11 | import { getRepoPath } from "../git/repository"; | |
| 12 | import { | |
| 13 | MODEL_SONNET, | |
| 14 | extractText, | |
| 15 | getAnthropic, | |
| 16 | isAiAvailable, | |
| 17 | parseJsonResponse, | |
| 18 | } from "./ai-client"; | |
| 19 | ||
| 20 | export interface IncidentAnalysisResult { | |
| 21 | likelyFiles: Array<{ path: string; reason: string }>; | |
| 22 | suggestedFix: string; // markdown with code blocks | |
| 23 | issueTitle: string; | |
| 24 | issueBody: string; | |
| 25 | branchName: string; | |
| 26 | } | |
| 27 | ||
| 28 | export interface AnalyzeIncidentParams { | |
| 29 | title: string; | |
| 30 | description: string; | |
| 31 | owner: string; | |
| 32 | repo: string; | |
| 33 | repoId: string; | |
| 34 | } | |
| 35 | ||
| 36 | // ───────────────────────────────────────────────────────────────────────────── | |
| 37 | // Git helpers (all fire-and-forget safe — return empty on error) | |
| 38 | // ───────────────────────────────────────────────────────────────────────────── | |
| 39 | ||
| 40 | async function runGit( | |
| 41 | args: string[], | |
| 42 | cwd: string | |
| 43 | ): Promise<string> { | |
| 44 | try { | |
| 45 | const proc = Bun.spawn(["git", ...args], { | |
| 46 | cwd, | |
| 47 | stdout: "pipe", | |
| 48 | stderr: "pipe", | |
| 49 | }); | |
| 50 | const text = await new Response(proc.stdout).text(); | |
| 51 | await proc.exited; | |
| 52 | return text.trim(); | |
| 53 | } catch { | |
| 54 | return ""; | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | /** Get commits from the last 24 hours: "<sha> <subject>" lines. */ | |
| 59 | async function recentCommits(owner: string, repo: string): Promise<string> { | |
| 60 | const cwd = getRepoPath(owner, repo); | |
| 61 | return runGit( | |
| 62 | ["log", "--since=24 hours ago", "--format=%H %s", "HEAD", "--"], | |
| 63 | cwd | |
| 64 | ); | |
| 65 | } | |
| 66 | ||
| 67 | /** Files changed in the last 10 commits (unique). */ | |
| 68 | async function recentChangedFiles( | |
| 69 | owner: string, | |
| 70 | repo: string | |
| 71 | ): Promise<string[]> { | |
| 72 | const cwd = getRepoPath(owner, repo); | |
| 73 | const out = await runGit( | |
| 74 | ["diff", "--name-only", "HEAD~10..HEAD", "--"], | |
| 75 | cwd | |
| 76 | ); | |
| 77 | if (!out) return []; | |
| 78 | return [ | |
| 79 | ...new Set( | |
| 80 | out | |
| 81 | .split("\n") | |
| 82 | .map((l) => l.trim()) | |
| 83 | .filter((l) => l.length > 0) | |
| 84 | ), | |
| 85 | ]; | |
| 86 | } | |
| 87 | ||
| 88 | /** Read the first `lines` lines of a file from the HEAD commit. */ | |
| 89 | async function readFileHead( | |
| 90 | owner: string, | |
| 91 | repo: string, | |
| 92 | path: string, | |
| 93 | lines = 100 | |
| 94 | ): Promise<string> { | |
| 95 | const cwd = getRepoPath(owner, repo); | |
| 96 | const full = await runGit(["show", `HEAD:${path}`], cwd); | |
| 97 | if (!full) return ""; | |
| 98 | return full.split("\n").slice(0, lines).join("\n"); | |
| 99 | } | |
| 100 | ||
| 101 | // ───────────────────────────────────────────────────────────────────────────── | |
| 102 | // Claude prompt | |
| 103 | // ───────────────────────────────────────────────────────────────────────────── | |
| 104 | ||
| 105 | interface AiResponse { | |
| 106 | likelyFiles: Array<{ path: string; reason: string }>; | |
| 107 | suggestedFix: string; | |
| 108 | issueTitle: string; | |
| 109 | issueBody: string; | |
| 110 | branchName: string; | |
| 111 | } | |
| 112 | ||
| 113 | async function callClaude( | |
| 114 | title: string, | |
| 115 | description: string, | |
| 116 | commits: string, | |
| 117 | fileContents: string | |
| 118 | ): Promise<AiResponse | null> { | |
| 119 | try { | |
| 120 | const client = getAnthropic(); | |
| 121 | const message = await client.messages.create({ | |
| 122 | model: MODEL_SONNET, | |
| 123 | max_tokens: 2048, | |
| 124 | system: | |
| 125 | "You are a senior on-call engineer. Given a production incident and recent code changes, identify the likely cause and suggest a fix. Always respond with valid JSON only.", | |
| 126 | messages: [ | |
| 127 | { | |
| 128 | role: "user", | |
| 129 | content: `Incident: ${title} | |
| 130 | ||
| 131 | ${description || "(no additional details)"} | |
| 132 | ||
| 133 | Recent commits (last 24h): | |
| 134 | ${commits || "(none)"} | |
| 135 | ||
| 136 | Changed files (last 10 commits — first 100 lines each): | |
| 137 | ${fileContents || "(none)"} | |
| 138 | ||
| 139 | Return JSON exactly matching this shape: | |
| 140 | { | |
| 141 | "likelyFiles": [{"path": "string", "reason": "string"}], | |
| 142 | "suggestedFix": "markdown code block with the fix", | |
| 143 | "issueTitle": "string", | |
| 144 | "issueBody": "string (markdown, include context + fix steps)", | |
| 145 | "branchName": "string (slugified, e.g. fix/incident-db-spike)" | |
| 146 | }`, | |
| 147 | }, | |
| 148 | ], | |
| 149 | }); | |
| 150 | ||
| 151 | const raw = extractText(message); | |
| 152 | const parsed = parseJsonResponse<AiResponse>(raw); | |
| 153 | if (!parsed) return null; | |
| 154 | ||
| 155 | // Validate + normalise | |
| 156 | return { | |
| 157 | likelyFiles: Array.isArray(parsed.likelyFiles) ? parsed.likelyFiles : [], | |
| 158 | suggestedFix: | |
| 159 | typeof parsed.suggestedFix === "string" ? parsed.suggestedFix : "", | |
| 160 | issueTitle: | |
| 161 | typeof parsed.issueTitle === "string" && parsed.issueTitle.trim() | |
| 162 | ? parsed.issueTitle.trim().slice(0, 200) | |
| 163 | : `Incident: ${title}`, | |
| 164 | issueBody: | |
| 165 | typeof parsed.issueBody === "string" ? parsed.issueBody : "", | |
| 166 | branchName: | |
| 167 | typeof parsed.branchName === "string" && parsed.branchName.trim() | |
| 168 | ? parsed.branchName | |
| 169 | .trim() | |
| 170 | .toLowerCase() | |
| 171 | .replace(/[^a-z0-9/-]/g, "-") | |
| 172 | .replace(/-{2,}/g, "-") | |
| 173 | .slice(0, 80) | |
| 174 | : `fix/incident-${Date.now()}`, | |
| 175 | }; | |
| 176 | } catch (err) { | |
| 177 | console.error("[incident-analyzer] claude call failed:", err); | |
| 178 | return null; | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | // ───────────────────────────────────────────────────────────────────────────── | |
| 183 | // Public API | |
| 184 | // ───────────────────────────────────────────────────────────────────────────── | |
| 185 | ||
| 186 | export async function analyzeIncident( | |
| 187 | params: AnalyzeIncidentParams | |
| 188 | ): Promise<IncidentAnalysisResult> { | |
| 189 | const { title, description, owner, repo } = params; | |
| 190 | const ts = Date.now(); | |
| 191 | ||
| 192 | // Fallback (no AI or git fails): still produces a usable issue. | |
| 193 | const fallback: IncidentAnalysisResult = { | |
| 194 | likelyFiles: [], | |
| 195 | suggestedFix: "", | |
| 196 | issueTitle: `Incident: ${title}`, | |
| 197 | issueBody: [ | |
| 198 | `## Alert`, | |
| 199 | `**${title}**`, | |
| 200 | "", | |
| 201 | description || "(no additional details)", | |
| 202 | "", | |
| 203 | "---", | |
| 204 | "_AI analysis unavailable — ANTHROPIC_API_KEY not set._", | |
| 205 | ].join("\n"), | |
| 206 | branchName: `fix/incident-${ts}`, | |
| 207 | }; | |
| 208 | ||
| 209 | if (!isAiAvailable()) { | |
| 210 | return fallback; | |
| 211 | } | |
| 212 | ||
| 213 | // 1. Gather git context (best-effort — failures return empty strings/arrays) | |
| 214 | let commits = ""; | |
| 215 | let changedFiles: string[] = []; | |
| 216 | try { | |
| 217 | [commits, changedFiles] = await Promise.all([ | |
| 218 | recentCommits(owner, repo), | |
| 219 | recentChangedFiles(owner, repo), | |
| 220 | ]); | |
| 221 | } catch { | |
| 222 | /* swallow */ | |
| 223 | } | |
| 224 | ||
| 225 | // 2. Read file content for recently changed files (up to 10 files, 100 lines each) | |
| 226 | const fileSnippets: string[] = []; | |
| 227 | for (const f of changedFiles.slice(0, 10)) { | |
| 228 | try { | |
| 229 | const content = await readFileHead(owner, repo, f, 100); | |
| 230 | if (content) { | |
| 231 | fileSnippets.push(`### ${f}\n\`\`\`\n${content}\n\`\`\``); | |
| 232 | } | |
| 233 | } catch { | |
| 234 | /* skip */ | |
| 235 | } | |
| 236 | } | |
| 237 | const fileContents = fileSnippets.join("\n\n"); | |
| 238 | ||
| 239 | // 3. Call Claude | |
| 240 | const ai = await callClaude(title, description, commits, fileContents); | |
| 241 | if (!ai) return fallback; | |
| 242 | ||
| 243 | return ai; | |
| 244 | } |