Skip to content

Commit 798269a

Browse files
AlbinoGeekclaude
andcommitted
feat(ci_diagnosis): why-is-CI-red diagnostic tool
Resolves workflow runs by run ID, PR number, or branch ref. Extracts failed job logs (tail-truncated), trigger commit info, and run URL. Answers CI failure questions without navigating the Actions UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f3196ad commit 798269a

File tree

1 file changed

+191
-0
lines changed

1 file changed

+191
-0
lines changed

src/server/ci-diagnosis-tool.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
import { gateAuth } from "./github-auth.js";
4+
import { getOctokit } from "./github-client.js";
5+
import { jsonRespond } from "./json.js";
6+
import { FormatSchema, RepoRefSchema } from "./schemas.js";
7+
8+
interface FailedStep {
9+
name: string;
10+
log: string;
11+
}
12+
13+
interface FailedJob {
14+
name: string;
15+
conclusion: string;
16+
failedSteps: FailedStep[];
17+
}
18+
19+
interface DiagnosisResult {
20+
runId: number;
21+
workflow: string;
22+
conclusion: string;
23+
branch: string;
24+
url: string;
25+
triggerCommit: { sha7: string; message: string; author: string };
26+
failedJobs: FailedJob[];
27+
}
28+
29+
/** Tail-truncate: keep the LAST maxLines (failures are at the bottom). */
30+
function tailTruncate(text: string, maxLines: number): string {
31+
const lines = text.split("\n");
32+
if (lines.length <= maxLines) return text;
33+
return `... [${lines.length - maxLines} lines above truncated]\n${lines.slice(-maxLines).join("\n")}`;
34+
}
35+
36+
export function registerCiDiagnosisTool(server: FastMCP): void {
37+
server.addTool({
38+
name: "ci_diagnosis",
39+
description:
40+
"Diagnose CI failures: finds the relevant workflow run, extracts failed job " +
41+
"logs (truncated), and shows the trigger commit. Answers 'why is CI red?' " +
42+
"without navigating the Actions UI.",
43+
annotations: { readOnlyHint: true },
44+
parameters: RepoRefSchema.extend({
45+
ref: z.string().optional().describe("Branch name or SHA. Used to find the latest run."),
46+
prNumber: z
47+
.number()
48+
.int()
49+
.positive()
50+
.optional()
51+
.describe("PR number. Finds runs for the PR head."),
52+
runId: z
53+
.number()
54+
.int()
55+
.positive()
56+
.optional()
57+
.describe("Specific workflow run ID to diagnose."),
58+
maxLogLines: z.number().int().min(10).max(500).optional().default(150),
59+
format: FormatSchema,
60+
}),
61+
execute: async (args) => {
62+
const auth = gateAuth();
63+
if (!auth.ok) return jsonRespond(auth.body);
64+
65+
const { owner, repo } = args;
66+
67+
try {
68+
const octokit = getOctokit();
69+
70+
// --- Resolve the workflow run ---
71+
type WorkflowRun = Awaited<ReturnType<typeof octokit.actions.getWorkflowRun>>["data"];
72+
let run: WorkflowRun | undefined;
73+
74+
if (args.runId) {
75+
const res = await octokit.actions.getWorkflowRun({
76+
owner,
77+
repo,
78+
run_id: args.runId,
79+
});
80+
run = res.data;
81+
} else if (args.prNumber) {
82+
const pr = await octokit.pulls.get({ owner, repo, pull_number: args.prNumber });
83+
const res = await octokit.actions.listWorkflowRunsForRepo({
84+
owner,
85+
repo,
86+
head_sha: pr.data.head.sha,
87+
per_page: 1,
88+
});
89+
run = res.data.workflow_runs[0];
90+
} else if (args.ref) {
91+
const res = await octokit.actions.listWorkflowRunsForRepo({
92+
owner,
93+
repo,
94+
branch: args.ref,
95+
per_page: 5,
96+
});
97+
run =
98+
res.data.workflow_runs.find((r) => r.conclusion === "failure") ??
99+
res.data.workflow_runs[0];
100+
} else {
101+
const res = await octokit.actions.listWorkflowRunsForRepo({
102+
owner,
103+
repo,
104+
status: "failure" as const,
105+
per_page: 1,
106+
});
107+
run = res.data.workflow_runs[0];
108+
}
109+
110+
if (!run) return jsonRespond({ error: "no_ci_runs", owner, repo });
111+
112+
// --- Get jobs ---
113+
const jobsRes = await octokit.actions.listJobsForWorkflowRun({
114+
owner,
115+
repo,
116+
run_id: run.id,
117+
filter: "latest",
118+
});
119+
120+
const allJobs = jobsRes.data.jobs;
121+
const failed = allJobs.filter((j) => j.conclusion === "failure");
122+
const jobsToAnalyze = failed.length > 0 ? failed : allJobs;
123+
124+
// --- Fetch logs for each job ---
125+
const failedJobs: FailedJob[] = [];
126+
for (const job of jobsToAnalyze) {
127+
let logText = "[logs unavailable]";
128+
try {
129+
const logRes = await octokit.actions.downloadJobLogsForWorkflowRun({
130+
owner,
131+
repo,
132+
job_id: job.id,
133+
});
134+
logText = tailTruncate(String(logRes.data), args.maxLogLines);
135+
} catch {
136+
// logs expired or unavailable
137+
}
138+
failedJobs.push({
139+
name: job.name,
140+
conclusion: job.conclusion ?? "unknown",
141+
failedSteps: [{ name: "logs", log: logText }],
142+
});
143+
}
144+
145+
const result: DiagnosisResult = {
146+
runId: run.id,
147+
workflow: run.name ?? "unknown",
148+
conclusion: run.conclusion ?? "unknown",
149+
branch: run.head_branch ?? "unknown",
150+
url: run.html_url,
151+
triggerCommit: {
152+
sha7: run.head_sha.substring(0, 7),
153+
message: run.head_commit?.message ?? "unknown",
154+
author: run.head_commit?.author?.name ?? "unknown",
155+
},
156+
failedJobs,
157+
};
158+
159+
if (args.format === "json") return jsonRespond(result);
160+
161+
// Markdown
162+
const lines: string[] = [
163+
`# CI Diagnosis: ${owner}/${repo}`,
164+
"",
165+
`**Run:** #${result.runId} (${result.workflow}) — ${result.conclusion}`,
166+
`**Branch:** ${result.branch}`,
167+
`**Trigger:** \`${result.triggerCommit.sha7}\` ${result.triggerCommit.message}${result.triggerCommit.author}`,
168+
`**URL:** ${result.url}`,
169+
"",
170+
];
171+
172+
if (failedJobs.length === 0) {
173+
lines.push("No failed jobs found.");
174+
} else {
175+
lines.push("## Failed Jobs", "");
176+
for (const job of failedJobs) {
177+
lines.push(`### ${job.name} (${job.conclusion})`, "");
178+
for (const step of job.failedSteps) {
179+
lines.push(`#### ${step.name}`, "", "```", step.log, "```", "");
180+
}
181+
}
182+
}
183+
184+
return lines.join("\n");
185+
} catch (e) {
186+
const msg = e instanceof Error ? e.message : String(e);
187+
return jsonRespond({ error: "ci_diagnosis_failed", owner, repo, message: msg });
188+
}
189+
},
190+
});
191+
}

0 commit comments

Comments
 (0)