ci-triage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ import { fetchRunHistory } from './history.js';
2
+ function testKey(className, testName) {
3
+ return `${className}::${testName}`;
4
+ }
5
+ function normalizeClassName(className) {
6
+ return className ?? '';
7
+ }
8
+ function toPattern(statuses, maxLength) {
9
+ const pattern = statuses
10
+ .slice(0, maxLength)
11
+ .map((status) => (status === 'pass' ? 'pass' : 'fail'));
12
+ while (pattern.length < maxLength) {
13
+ pattern.push('unknown');
14
+ }
15
+ return pattern;
16
+ }
17
+ function computePassRate7d(statuses, runs) {
18
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
19
+ const inWindow = runs
20
+ .map((run, index) => ({ run, status: statuses[index] }))
21
+ .filter(({ run, status }) => Boolean(status) && Date.parse(run.created_at) >= cutoff)
22
+ .map(({ status }) => status);
23
+ if (inWindow.length === 0) {
24
+ return 0;
25
+ }
26
+ const passCount = inWindow.filter((status) => status === 'pass').length;
27
+ return passCount / inWindow.length;
28
+ }
29
+ function hasAlternatingPattern(statuses) {
30
+ if (statuses.length < 4) {
31
+ return false;
32
+ }
33
+ let transitions = 0;
34
+ for (let i = 1; i < statuses.length; i += 1) {
35
+ if (statuses[i] !== statuses[i - 1]) {
36
+ transitions += 1;
37
+ }
38
+ }
39
+ const transitionRate = transitions / (statuses.length - 1);
40
+ const hasPass = statuses.includes('pass');
41
+ const hasFailLike = statuses.some((s) => s === 'fail' || s === 'error');
42
+ return hasPass && hasFailLike && transitionRate >= 0.6;
43
+ }
44
+ function isConsistentFailure(statuses) {
45
+ return statuses.length >= 3 && statuses.every((status) => status === 'fail' || status === 'error');
46
+ }
47
+ function clampConfidence(value) {
48
+ return Math.max(0, Math.min(1, Number(value.toFixed(2))));
49
+ }
50
+ function evaluateFlake(statuses, runs) {
51
+ const passRate = computePassRate7d(statuses, runs);
52
+ const last5 = toPattern(statuses, 5);
53
+ const alternating = hasAlternatingPattern(statuses);
54
+ const consistentFail = isConsistentFailure(statuses);
55
+ const recentPass = statuses.slice(0, 3).includes('pass');
56
+ if (statuses.length === 0) {
57
+ return {
58
+ is_flaky: false,
59
+ confidence: 0.2,
60
+ pass_rate_7d: 0,
61
+ last_5_runs: last5,
62
+ };
63
+ }
64
+ if (consistentFail) {
65
+ return {
66
+ is_flaky: false,
67
+ confidence: 0.9,
68
+ pass_rate_7d: passRate,
69
+ last_5_runs: last5,
70
+ };
71
+ }
72
+ let score = 0.2;
73
+ if (recentPass) {
74
+ score += 0.25;
75
+ }
76
+ if (alternating) {
77
+ score += 0.35;
78
+ }
79
+ if (passRate > 0.2 && passRate < 0.9) {
80
+ score += 0.2;
81
+ }
82
+ if (statuses.length >= 5) {
83
+ score += 0.05;
84
+ }
85
+ return {
86
+ is_flaky: score >= 0.6,
87
+ confidence: clampConfidence(score),
88
+ pass_rate_7d: Number(passRate.toFixed(2)),
89
+ last_5_runs: last5,
90
+ };
91
+ }
92
+ export async function detectFlakes(currentFailures, repoSlug, historyDepth) {
93
+ const history = await fetchRunHistory(repoSlug, historyDepth);
94
+ return currentFailures.map((failure) => {
95
+ const className = normalizeClassName(failure.class_name);
96
+ const key = testKey(className, failure.test_name);
97
+ const statuses = history
98
+ .map((run) => run.tests[key])
99
+ .filter((status) => Boolean(status));
100
+ const flaky = evaluateFlake(statuses, history);
101
+ return {
102
+ test_name: failure.test_name,
103
+ class_name: className,
104
+ flaky,
105
+ };
106
+ });
107
+ }
package/dist/github.js ADDED
@@ -0,0 +1,52 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { CI_TRIAGE_MARKER } from './comment.js';
6
+ function ghJson(args, token) {
7
+ const out = execFileSync('gh', args, {
8
+ encoding: 'utf8',
9
+ env: {
10
+ ...process.env,
11
+ GH_TOKEN: token,
12
+ GITHUB_TOKEN: token,
13
+ },
14
+ });
15
+ return JSON.parse(out);
16
+ }
17
+ function sanitizeFileName(name) {
18
+ return name.replace(/[^a-zA-Z0-9._-]/g, '-');
19
+ }
20
+ export function uploadArtifact(name, content) {
21
+ const fileName = `${sanitizeFileName(name)}.json`;
22
+ const filePath = join(tmpdir(), fileName);
23
+ writeFileSync(filePath, content, 'utf8');
24
+ return filePath;
25
+ }
26
+ export function getPRForRun(repo, runId, token) {
27
+ const prs = ghJson(['api', `repos/${repo}/actions/runs/${runId}/pull_requests`], token);
28
+ return prs[0]?.number ?? null;
29
+ }
30
+ export function postOrUpdateComment(repo, prNumber, body, token) {
31
+ const comments = ghJson(['api', `repos/${repo}/issues/${prNumber}/comments`, '-f', 'per_page=100'], token);
32
+ const existing = comments.find((comment) => comment.body?.includes(CI_TRIAGE_MARKER));
33
+ if (existing) {
34
+ execFileSync('gh', ['api', `repos/${repo}/issues/comments/${existing.id}`, '-X', 'PATCH', '-f', `body=${body}`], {
35
+ encoding: 'utf8',
36
+ env: {
37
+ ...process.env,
38
+ GH_TOKEN: token,
39
+ GITHUB_TOKEN: token,
40
+ },
41
+ });
42
+ return;
43
+ }
44
+ execFileSync('gh', ['api', `repos/${repo}/issues/${prNumber}/comments`, '-f', `body=${body}`], {
45
+ encoding: 'utf8',
46
+ env: {
47
+ ...process.env,
48
+ GH_TOKEN: token,
49
+ GITHUB_TOKEN: token,
50
+ },
51
+ });
52
+ }
@@ -0,0 +1,133 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { parseJUnitXmlFile } from './junit.js';
6
+ const historyCache = new Map();
7
+ function cacheKey(repoSlug, historyDepth) {
8
+ return `${repoSlug}:${historyDepth}`;
9
+ }
10
+ function runJson(args) {
11
+ const out = execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
12
+ return JSON.parse(out);
13
+ }
14
+ function runText(args) {
15
+ return execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
16
+ }
17
+ function walkFiles(root) {
18
+ const out = [];
19
+ const entries = readdirSync(root);
20
+ for (const entry of entries) {
21
+ const full = join(root, entry);
22
+ const stats = statSync(full);
23
+ if (stats.isDirectory()) {
24
+ out.push(...walkFiles(full));
25
+ continue;
26
+ }
27
+ out.push(full);
28
+ }
29
+ return out;
30
+ }
31
+ function testKey(className, testName) {
32
+ return `${className}::${testName}`;
33
+ }
34
+ function parseLogForTests(log) {
35
+ const tests = {};
36
+ const lines = log.split('\n');
37
+ for (const line of lines) {
38
+ const pytestMatch = line.match(/^FAILED\s+(.+?::.+?)(?:\s+-\s+.+)?$/);
39
+ if (pytestMatch) {
40
+ const pathAndName = pytestMatch[1];
41
+ const parts = pathAndName.split('::');
42
+ const testName = parts.pop() ?? pathAndName;
43
+ const className = parts.join('::');
44
+ tests[testKey(className, testName)] = 'fail';
45
+ continue;
46
+ }
47
+ const jestMatch = line.match(/^\s*●\s+(.+?)\s+›\s+(.+?)\s*$/);
48
+ if (jestMatch) {
49
+ tests[testKey(jestMatch[1], jestMatch[2])] = 'fail';
50
+ continue;
51
+ }
52
+ const genericMatch = line.match(/^(FAIL|FAILED|ERROR)\s+([A-Za-z0-9_.:/-]+)\s+(.+)$/);
53
+ if (genericMatch) {
54
+ const status = genericMatch[1] === 'ERROR' ? 'error' : 'fail';
55
+ tests[testKey(genericMatch[2], genericMatch[3])] = status;
56
+ }
57
+ }
58
+ return tests;
59
+ }
60
+ function tryReadJUnitResults(repoSlug, runId) {
61
+ const tempDir = mkdtempSync(join(tmpdir(), 'ci-triage-run-'));
62
+ try {
63
+ try {
64
+ runText(['run', 'download', String(runId), '--repo', repoSlug, '--dir', tempDir]);
65
+ }
66
+ catch {
67
+ return {};
68
+ }
69
+ const xmlFiles = walkFiles(tempDir).filter((file) => file.toLowerCase().endsWith('.xml'));
70
+ if (xmlFiles.length === 0) {
71
+ return {};
72
+ }
73
+ const tests = {};
74
+ for (const xmlFile of xmlFiles) {
75
+ const parsed = parseJUnitXmlFile(xmlFile);
76
+ for (const result of parsed) {
77
+ tests[testKey(result.class_name, result.test_name)] = result.status;
78
+ }
79
+ }
80
+ return tests;
81
+ }
82
+ finally {
83
+ rmSync(tempDir, { recursive: true, force: true });
84
+ }
85
+ }
86
+ function tryReadLogResults(repoSlug, runId) {
87
+ try {
88
+ const logs = runText(['run', 'view', String(runId), '--repo', repoSlug, '--log-failed']);
89
+ return parseLogForTests(logs);
90
+ }
91
+ catch {
92
+ return {};
93
+ }
94
+ }
95
+ export function clearHistoryCache() {
96
+ historyCache.clear();
97
+ }
98
+ export async function fetchRunHistory(repoSlug, historyDepth) {
99
+ const key = cacheKey(repoSlug, historyDepth);
100
+ const cached = historyCache.get(key);
101
+ if (cached) {
102
+ return cached;
103
+ }
104
+ let runs = [];
105
+ try {
106
+ runs = runJson([
107
+ 'run',
108
+ 'list',
109
+ '--repo',
110
+ repoSlug,
111
+ '--limit',
112
+ String(historyDepth),
113
+ '--json',
114
+ 'databaseId,conclusion,createdAt',
115
+ ]);
116
+ }
117
+ catch {
118
+ historyCache.set(key, []);
119
+ return [];
120
+ }
121
+ const history = runs.map((run) => {
122
+ const junitTests = tryReadJUnitResults(repoSlug, run.databaseId);
123
+ const tests = Object.keys(junitTests).length > 0 ? junitTests : tryReadLogResults(repoSlug, run.databaseId);
124
+ return {
125
+ run_id: run.databaseId,
126
+ created_at: run.createdAt,
127
+ conclusion: run.conclusion,
128
+ tests,
129
+ };
130
+ });
131
+ historyCache.set(key, history);
132
+ return history;
133
+ }
package/dist/index.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync } from 'node:fs';
3
+ import { classify } from './classifier.js';
4
+ import { fetchFailedLog, fetchRunById, fetchRunMetadata, fetchRuns } from './fetcher.js';
5
+ import { parseFailures } from './parser.js';
6
+ import { buildJsonReport, toConsoleText, toJson, toMarkdown } from './reporter.js';
7
+ function usage() {
8
+ console.error('Usage: ci-failure-triager owner/repo [limit] [--run <id>] [--md report.md] [--json]');
9
+ process.exit(1);
10
+ }
11
+ function parseArgs(argv) {
12
+ const repo = argv[2];
13
+ const args = argv.slice(3);
14
+ if (!repo || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
15
+ usage();
16
+ }
17
+ const firstPositional = args.find((arg) => !arg.startsWith('--'));
18
+ const parsedLimit = firstPositional ? Number(firstPositional) : 10;
19
+ const limit = Math.min(Math.max(Number.isFinite(parsedLimit) ? parsedLimit : 10, 1), 100);
20
+ const runIdx = args.indexOf('--run');
21
+ const runIdRaw = runIdx >= 0 ? args[runIdx + 1] : undefined;
22
+ const runId = runIdRaw ? Number(runIdRaw) : undefined;
23
+ const mdIdx = args.indexOf('--md');
24
+ const markdownPath = mdIdx >= 0 ? args[mdIdx + 1] ?? '' : undefined;
25
+ return {
26
+ repo,
27
+ runId: Number.isFinite(runId) ? runId : undefined,
28
+ limit,
29
+ markdownPath,
30
+ outputJson: args.includes('--json'),
31
+ };
32
+ }
33
+ function pickRun(options) {
34
+ if (options.runId) {
35
+ return fetchRunById(options.repo, options.runId);
36
+ }
37
+ const runs = fetchRuns(options.repo, options.limit);
38
+ return runs.find((r) => r.conclusion === 'failure') ?? null;
39
+ }
40
+ function main() {
41
+ const options = parseArgs(process.argv);
42
+ const run = pickRun(options);
43
+ if (!run) {
44
+ console.error('No failed runs found.');
45
+ process.exit(2);
46
+ }
47
+ const rawLog = fetchFailedLog(options.repo, run.databaseId);
48
+ const failures = parseFailures(rawLog);
49
+ const classified = failures.map((failure) => ({
50
+ ...failure,
51
+ classification: classify(failure),
52
+ }));
53
+ const report = buildJsonReport({
54
+ repo: options.repo,
55
+ run,
56
+ failures: classified,
57
+ metadata: fetchRunMetadata(options.repo, run.databaseId),
58
+ });
59
+ if (options.outputJson) {
60
+ process.stdout.write(toJson(report));
61
+ }
62
+ else {
63
+ process.stdout.write(toConsoleText(report));
64
+ }
65
+ if (options.markdownPath) {
66
+ writeFileSync(options.markdownPath, toMarkdown(report), 'utf8');
67
+ if (!options.outputJson) {
68
+ process.stdout.write(`Wrote triage report: ${options.markdownPath}\n`);
69
+ }
70
+ }
71
+ }
72
+ main();
@@ -0,0 +1,6 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ describe('smoke', () => {
3
+ it('works', () => {
4
+ expect(true).toBe(true);
5
+ });
6
+ });
package/dist/junit.js ADDED
@@ -0,0 +1,94 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { XMLParser, XMLValidator } from 'fast-xml-parser';
3
+ const parser = new XMLParser({
4
+ ignoreAttributes: false,
5
+ parseAttributeValue: true,
6
+ trimValues: true,
7
+ });
8
+ function asArray(value) {
9
+ if (value === undefined) {
10
+ return [];
11
+ }
12
+ return Array.isArray(value) ? value : [value];
13
+ }
14
+ function extractFailure(node) {
15
+ if (!node) {
16
+ return {};
17
+ }
18
+ const first = Array.isArray(node) ? node[0] : node;
19
+ return {
20
+ message: typeof first?.['@_message'] === 'string' ? first['@_message'] : undefined,
21
+ stack: typeof first?.['#text'] === 'string' ? first['#text'] : undefined,
22
+ };
23
+ }
24
+ function parseTestCase(testCase) {
25
+ let status = 'pass';
26
+ let failure_message;
27
+ let stack_trace;
28
+ if (testCase.error) {
29
+ status = 'error';
30
+ const extracted = extractFailure(testCase.error);
31
+ failure_message = extracted.message;
32
+ stack_trace = extracted.stack;
33
+ }
34
+ else if (testCase.failure) {
35
+ status = 'fail';
36
+ const extracted = extractFailure(testCase.failure);
37
+ failure_message = extracted.message;
38
+ stack_trace = extracted.stack;
39
+ }
40
+ else if (testCase.skipped !== undefined) {
41
+ status = 'skipped';
42
+ }
43
+ return {
44
+ test_name: String(testCase['@_name'] ?? ''),
45
+ class_name: String(testCase['@_classname'] ?? ''),
46
+ duration: Number(testCase['@_time'] ?? 0),
47
+ status,
48
+ failure_message,
49
+ stack_trace,
50
+ };
51
+ }
52
+ function collectSuites(root) {
53
+ const suites = [];
54
+ if (root.testsuite && typeof root.testsuite === 'object') {
55
+ const directSuites = asArray(root.testsuite);
56
+ suites.push(...directSuites);
57
+ }
58
+ if (root.testsuites && typeof root.testsuites === 'object') {
59
+ const testsuitesNode = root.testsuites;
60
+ if (testsuitesNode.testsuite && typeof testsuitesNode.testsuite === 'object') {
61
+ const nestedSuites = asArray(testsuitesNode.testsuite);
62
+ suites.push(...nestedSuites);
63
+ }
64
+ }
65
+ return suites;
66
+ }
67
+ export function parseJUnitXml(xmlContent) {
68
+ if (!xmlContent.trim()) {
69
+ return [];
70
+ }
71
+ if (XMLValidator.validate(xmlContent) !== true) {
72
+ return [];
73
+ }
74
+ let parsed;
75
+ try {
76
+ parsed = parser.parse(xmlContent);
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ const suites = collectSuites(parsed);
82
+ const results = [];
83
+ for (const suite of suites) {
84
+ const testcases = asArray(suite.testcase);
85
+ for (const testCase of testcases) {
86
+ results.push(parseTestCase(testCase));
87
+ }
88
+ }
89
+ return results;
90
+ }
91
+ export function parseJUnitXmlFile(filePath) {
92
+ const content = readFileSync(filePath, 'utf8');
93
+ return parseJUnitXml(content);
94
+ }
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { classify } from './classifier.js';
7
+ import { fetchFailedLog, fetchRunById, fetchRunMetadata, fetchRuns } from './fetcher.js';
8
+ import { detectFlakes } from './flake-detector.js';
9
+ import { parseFailures } from './parser.js';
10
+ import { buildJsonReport } from './reporter.js';
11
+ export const MCP_TOOL_NAMES = ['triage_run', 'list_failures', 'is_flaky', 'suggest_fix'];
12
+ const defaultDeps = {
13
+ fetchRuns,
14
+ fetchRunById,
15
+ fetchFailedLog,
16
+ fetchRunMetadata,
17
+ parseFailures,
18
+ classify,
19
+ buildJsonReport,
20
+ detectFlakes,
21
+ };
22
+ function findRun(repo, runId, deps) {
23
+ if (runId !== undefined) {
24
+ return deps.fetchRunById(repo, runId);
25
+ }
26
+ const runs = deps.fetchRuns(repo, 50);
27
+ const failed = runs.find((run) => run.conclusion === 'failure');
28
+ if (!failed) {
29
+ throw new Error('No failed runs found.');
30
+ }
31
+ return failed;
32
+ }
33
+ function flattenFailures(report) {
34
+ const failures = [];
35
+ for (const job of report.jobs) {
36
+ for (const step of job.steps ?? []) {
37
+ for (const failure of step.failures ?? []) {
38
+ failures.push({
39
+ step: step.name,
40
+ category: failure.category,
41
+ severity: failure.severity,
42
+ error: failure.error,
43
+ suggested_fix: failure.suggested_fix,
44
+ });
45
+ }
46
+ }
47
+ }
48
+ return failures;
49
+ }
50
+ function formatToolResult(data) {
51
+ return {
52
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
53
+ };
54
+ }
55
+ export async function triageRun(args, deps = defaultDeps) {
56
+ const run = findRun(args.repo, args.run_id, deps);
57
+ const rawLog = deps.fetchFailedLog(args.repo, run.databaseId);
58
+ const failures = deps.parseFailures(rawLog);
59
+ const classified = failures.map((failure) => ({
60
+ ...failure,
61
+ classification: deps.classify(failure),
62
+ }));
63
+ return deps.buildJsonReport({
64
+ repo: args.repo,
65
+ run,
66
+ failures: classified,
67
+ metadata: deps.fetchRunMetadata(args.repo, run.databaseId),
68
+ });
69
+ }
70
+ export async function listFailures(args, deps = defaultDeps) {
71
+ const limit = args.limit ?? 10;
72
+ const runs = deps.fetchRuns(args.repo, Math.max(limit, 10));
73
+ return runs
74
+ .filter((run) => run.conclusion === 'failure')
75
+ .slice(0, limit)
76
+ .map((run) => ({
77
+ run_id: run.databaseId,
78
+ workflow: run.workflowName,
79
+ title: run.displayTitle,
80
+ url: run.url,
81
+ conclusion: run.conclusion,
82
+ }));
83
+ }
84
+ function splitTestName(rawName) {
85
+ if (rawName.includes('::')) {
86
+ const parts = rawName.split('::');
87
+ const testName = parts.pop() ?? rawName;
88
+ const className = parts.join('::');
89
+ return className ? { class_name: className, test_name: testName } : { test_name: testName };
90
+ }
91
+ if (rawName.includes(' > ')) {
92
+ const parts = rawName.split(' > ');
93
+ const testName = parts.pop() ?? rawName;
94
+ const className = parts.join(' > ');
95
+ return className ? { class_name: className, test_name: testName } : { test_name: testName };
96
+ }
97
+ return { test_name: rawName };
98
+ }
99
+ export async function isFlaky(args, deps = defaultDeps) {
100
+ const historyDepth = args.history_depth ?? 20;
101
+ const parsed = splitTestName(args.test_name);
102
+ const result = await deps.detectFlakes([parsed], args.repo, historyDepth);
103
+ const first = result[0];
104
+ if (!first) {
105
+ return {
106
+ test_name: parsed.test_name,
107
+ class_name: parsed.class_name ?? '',
108
+ is_flaky: false,
109
+ confidence: 0,
110
+ pass_rate: 0,
111
+ last_5_runs: ['unknown', 'unknown', 'unknown', 'unknown', 'unknown'],
112
+ };
113
+ }
114
+ return {
115
+ test_name: first.test_name,
116
+ class_name: first.class_name,
117
+ is_flaky: first.flaky.is_flaky,
118
+ confidence: first.flaky.confidence,
119
+ pass_rate: first.flaky.pass_rate_7d,
120
+ last_5_runs: first.flaky.last_5_runs,
121
+ };
122
+ }
123
+ export async function suggestFix(args, deps = defaultDeps) {
124
+ const report = await triageRun({ repo: args.repo, run_id: args.run_id }, deps);
125
+ return {
126
+ run_id: report.run_id,
127
+ failures: flattenFailures(report),
128
+ };
129
+ }
130
+ export function createMcpServer(deps = defaultDeps) {
131
+ const server = new McpServer({ name: 'ci-triage-mcp', version: '0.1.0' });
132
+ server.registerTool('triage_run', {
133
+ description: 'Triage a CI run and return the full TriageReport JSON.',
134
+ inputSchema: z.object({
135
+ repo: z.string().min(1),
136
+ run_id: z.number().int().positive().optional(),
137
+ }),
138
+ }, async (args) => formatToolResult(await triageRun(args, deps)));
139
+ server.registerTool('list_failures', {
140
+ description: 'List recent failed CI runs for a repository.',
141
+ inputSchema: z.object({
142
+ repo: z.string().min(1),
143
+ limit: z.number().int().min(1).max(100).default(10).optional(),
144
+ }),
145
+ }, async (args) => formatToolResult(await listFailures(args, deps)));
146
+ server.registerTool('is_flaky', {
147
+ description: 'Check if a test appears flaky based on recent run history.',
148
+ inputSchema: z.object({
149
+ repo: z.string().min(1),
150
+ test_name: z.string().min(1),
151
+ history_depth: z.number().int().min(1).max(200).default(20).optional(),
152
+ }),
153
+ }, async (args) => formatToolResult(await isFlaky(args, deps)));
154
+ server.registerTool('suggest_fix', {
155
+ description: 'Return simplified failure + suggested fix entries for a CI run.',
156
+ inputSchema: z.object({
157
+ repo: z.string().min(1),
158
+ run_id: z.number().int().positive(),
159
+ }),
160
+ }, async (args) => formatToolResult(await suggestFix(args, deps)));
161
+ return server;
162
+ }
163
+ export async function startMcpServer() {
164
+ const server = createMcpServer();
165
+ const transport = new StdioServerTransport();
166
+ await server.connect(transport);
167
+ }
168
+ const isMain = process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url;
169
+ if (isMain) {
170
+ startMcpServer().catch((error) => {
171
+ const message = error instanceof Error ? error.message : String(error);
172
+ process.stderr.write(`Failed to start MCP server: ${message}\n`);
173
+ process.exit(1);
174
+ });
175
+ }