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

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.

incident-analyzer.tsBlame244 lines · 1 contributor
70e3293Claude1/**
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
11import { getRepoPath } from "../git/repository";
12import {
13 MODEL_SONNET,
14 extractText,
15 getAnthropic,
16 isAiAvailable,
17 parseJsonResponse,
18} from "./ai-client";
19
20export 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
28export 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
40async 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. */
59async 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). */
68async 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. */
89async 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
105interface AiResponse {
106 likelyFiles: Array<{ path: string; reason: string }>;
107 suggestedFix: string;
108 issueTitle: string;
109 issueBody: string;
110 branchName: string;
111}
112
113async 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
133Recent commits (last 24h):
134${commits || "(none)"}
135
136Changed files (last 10 commits — first 100 lines each):
137${fileContents || "(none)"}
138
139Return 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
186export 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}