Skip to content

Commit 243075d

Browse files
authored
Merge pull request #532 from bcorfman/phase1repairharness
Implemented phase 1 repair harness; cleaned up testBridge
2 parents 5f25a4a + 5dcc041 commit 243075d

13 files changed

Lines changed: 367 additions & 100 deletions

.plans/ci-repair-harness-implementation-plan-2026-07-26.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,16 +221,16 @@ Exit criteria:
221221
Goal: produce a compact, redacted, reproducible failure envelope without a
222222
model call.
223223

224-
- [ ] Refactor the read-only reusable portions of
224+
- [x] Refactor the read-only reusable portions of
225225
`scripts/inspect-gh-actions-failures.ts` into shared helpers while preserving
226226
the existing `ci:checks` and `ci:checks:json` behavior and tests.
227-
- [ ] Add `github.ts` to fetch one resolved Actions run, failed job logs, and
227+
- [x] Add `github.ts` to fetch one resolved Actions run, failed job logs, and
228228
only that run's Playwright artifacts using authenticated `gh` commands.
229-
- [ ] Implement failure-class parsers for Vitest, TypeScript/build, and
229+
- [x] Implement failure-class parsers for Vitest, TypeScript/build, and
230230
Playwright output; classify runner/network/action failures as infrastructure.
231-
- [ ] Implement artifact metadata extraction and redaction tests. Do not unpack
231+
- [x] Implement artifact metadata extraction and redaction tests. Do not unpack
232232
or prompt raw trace/video contents by default.
233-
- [ ] Add `repair:ci:collect` to write the normalized evidence envelope and
233+
- [x] Add `repair:ci:collect` to write the normalized evidence envelope and
234234
human-readable collection summary.
235235

236236
Exit criteria:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"ci:checks": "node --import tsx scripts/inspect-gh-actions-failures.ts",
6767
"ci:checks:json": "node --import tsx scripts/inspect-gh-actions-failures.ts --json",
6868
"repair:ci": "node --import tsx scripts/repair-harness/cli.ts",
69+
"repair:ci:collect": "node --import tsx scripts/repair-harness/cli.ts collect",
6970
"test:all": "npm run test:unit && npm run test:e2e",
7071
"storybook": "storybook dev -p 6006",
7172
"build-storybook": "storybook build",

scripts/inspect-gh-actions-failures.ts

Lines changed: 4 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,9 @@
1-
import { spawnSync } from 'node:child_process';
1+
import { extractFailureSnippet, extractRunIdFromUrl, isFailingCheck, parseAvailableFields } from './repair-harness/ghHelpers';
2+
import { fetchChecks, resolvePr, runGh } from './repair-harness/github';
23

3-
type JsonRecord = Record<string, unknown>;
4-
5-
const FAILURE_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'action_required']);
6-
const FAILURE_STATES = new Set(['failure', 'error', 'cancelled', 'timed_out', 'action_required']);
7-
const FAILURE_BUCKETS = new Set(['fail']);
8-
const FAILURE_MARKERS = ['error', 'fail', 'failed', 'traceback', 'exception', 'assert', 'panic', 'fatal', 'timeout'];
9-
10-
export function parseAvailableFields(text: string): string[] {
11-
const match = text.match(/Available fields:\s*([\s\S]+)/i);
12-
if (!match) return [];
13-
return match[1]
14-
.split('\n')
15-
.map((line) => line.trim())
16-
.filter((line) => /^[a-zA-Z][\w-]*$/.test(line));
17-
}
18-
19-
export function isFailingCheck(check: JsonRecord): boolean {
20-
const conclusion = String(check.conclusion ?? '').toLowerCase();
21-
if (FAILURE_CONCLUSIONS.has(conclusion)) return true;
22-
const state = String((check.state ?? check.status ?? '')).toLowerCase();
23-
if (FAILURE_STATES.has(state)) return true;
24-
const bucket = String(check.bucket ?? '').toLowerCase();
25-
return FAILURE_BUCKETS.has(bucket);
26-
}
27-
28-
export function extractRunIdFromUrl(url: string): string | null {
29-
const match = url.match(/\/actions\/runs\/(\d+)/);
30-
return match?.[1] ?? null;
31-
}
32-
33-
export function extractFailureSnippet(logText: string, maxLines = 120, context = 20): string {
34-
const lines = logText.split(/\r?\n/);
35-
const failureIndex = lines.findIndex((line) => {
36-
const normalized = line.toLowerCase();
37-
return FAILURE_MARKERS.some((marker) => normalized.includes(marker));
38-
});
39-
if (failureIndex < 0) {
40-
return lines.slice(-maxLines).join('\n').trim();
41-
}
42-
const start = Math.max(0, failureIndex - context);
43-
const end = Math.min(lines.length, start + maxLines);
44-
return lines.slice(start, end).join('\n').trim();
45-
}
4+
export { extractFailureSnippet, extractRunIdFromUrl, isFailingCheck, parseAvailableFields } from './repair-harness/ghHelpers';
465

47-
function runGh(args: string[], options: { cwd?: string; allowFailure?: boolean } = {}) {
48-
const result = spawnSync('gh', args, {
49-
cwd: options.cwd,
50-
encoding: 'utf8',
51-
});
52-
if (result.status !== 0 && !options.allowFailure) {
53-
const message = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
54-
throw new Error(message || `gh ${args.join(' ')} failed`);
55-
}
56-
return result;
57-
}
6+
type JsonRecord = Record<string, unknown>;
587

598
function parseArgs(argv: string[]) {
609
const parsed: { pr?: string; json: boolean; repo: string } = {
@@ -71,29 +20,6 @@ function parseArgs(argv: string[]) {
7120
return parsed;
7221
}
7322

74-
function resolvePr(pr: string | undefined, repo: string): string {
75-
if (pr) return pr;
76-
const result = runGh(['pr', 'view', '--json', 'number,url'], { cwd: repo });
77-
const data = JSON.parse(result.stdout) as { number?: number; url?: string };
78-
if (!data.number) throw new Error('Unable to resolve current branch PR.');
79-
return String(data.number);
80-
}
81-
82-
function fetchChecks(pr: string, repo: string): JsonRecord[] {
83-
const primaryFields = ['name', 'state', 'conclusion', 'detailsUrl', 'startedAt', 'completedAt'];
84-
let result = runGh(['pr', 'checks', pr, '--json', primaryFields.join(',')], { cwd: repo, allowFailure: true });
85-
if (result.status !== 0) {
86-
const availableFields = parseAvailableFields(`${result.stderr}\n${result.stdout}`);
87-
const fallbackFields = ['name', 'state', 'bucket', 'link', 'workflow', 'startedAt', 'completedAt'];
88-
const selectedFields = fallbackFields.filter((field) => availableFields.includes(field));
89-
if (selectedFields.length === 0) {
90-
throw new Error([result.stderr, result.stdout].filter(Boolean).join('\n').trim() || 'gh pr checks failed');
91-
}
92-
result = runGh(['pr', 'checks', pr, '--json', selectedFields.join(',')], { cwd: repo });
93-
}
94-
return JSON.parse(result.stdout) as JsonRecord[];
95-
}
96-
9723
function inspectRun(runId: string, repo: string) {
9824
const metadataResult = runGh(
9925
['run', 'view', runId, '--json', 'name,workflowName,conclusion,status,url,event,headBranch,headSha'],
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ArtifactMetadata } from './types';
2+
3+
const REDACTION_PATTERNS: Array<[RegExp, string]> = [
4+
[/(authorization\s*:\s*bearer\s+)[^\s,;]+/gi, '$1[REDACTED]'],
5+
[/(\b(?:token|password|passwd|secret|cookie)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]'],
6+
[/(gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)/g, '[REDACTED]'],
7+
[/(https?:\/\/[^\s/@]+):[^\s/@]+@/gi, '$1:[REDACTED]@'],
8+
];
9+
10+
export function redactSecrets(value: string): string {
11+
return REDACTION_PATTERNS.reduce((result, [pattern, replacement]) => result.replace(pattern, replacement), value);
12+
}
13+
14+
export function extractArtifactMetadata(paths: string[]): ArtifactMetadata {
15+
const normalized = paths.filter((item) => !item.includes('..')).map((item) => item.replaceAll('\\', '/'));
16+
return {
17+
tracePaths: normalized.filter((item) => /(?:^|\/)trace\.(?:zip|json)$/i.test(item)),
18+
screenshotPaths: normalized.filter((item) => /\.(?:png|jpe?g)$/i.test(item)),
19+
reportPath: normalized.find((item) => /(?:^|\/)playwright-report\/.*\.html$/i.test(item)),
20+
};
21+
}

scripts/repair-harness/cli.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,24 @@
11
import path from 'node:path';
22

33
import { formatWorkflowCatalog, getWorkflowCatalog, validateWorkflowCatalog } from './workflowCatalog';
4+
import { collectEvidence } from './collect';
45

56
const repositoryRoot = path.resolve(new URL('../..', import.meta.url).pathname);
7+
const args = process.argv.slice(2);
8+
9+
if (args[0] === 'collect') {
10+
const value = (name: string): string | undefined => {
11+
const index = args.indexOf(name);
12+
return index >= 0 ? args[index + 1] : undefined;
13+
};
14+
try {
15+
const result = collectEvidence({ repo: value('--repo') ?? repositoryRoot, pr: value('--pr'), run: value('--run'), job: value('--job') });
16+
console.log(`Collected ${result.envelope.failure.class} evidence in ${result.runDirectory}`);
17+
} catch (error) {
18+
console.error(error instanceof Error ? error.message : String(error));
19+
process.exitCode = 1;
20+
}
21+
} else {
622
const validation = validateWorkflowCatalog(repositoryRoot);
723

824
if (!validation.valid) {
@@ -11,3 +27,4 @@ if (!validation.valid) {
1127
} else {
1228
console.log(formatWorkflowCatalog(getWorkflowCatalog()));
1329
}
30+
}

scripts/repair-harness/collect.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { mkdirSync, writeFileSync } from 'node:fs';
2+
import path from 'node:path';
3+
4+
import { getWorkflowCatalog } from './workflowCatalog';
5+
import { extractArtifactMetadata, redactSecrets } from './artifacts';
6+
import { resolvePr, resolveRun, resolveRunFromPr } from './github';
7+
import { extractFailure } from './triage';
8+
import type { EvidenceEnvelope } from './types';
9+
10+
export interface CollectionOptions {
11+
repo: string;
12+
pr?: string;
13+
run?: string;
14+
job?: string;
15+
outputRoot?: string;
16+
}
17+
18+
function chooseScope(workflow: string, job: string): string {
19+
const key = `${workflow} ${job}`.toLowerCase();
20+
if (key.includes('e2e pr') || key.includes('pr chromium')) return 'pr-e2e-chromium';
21+
if (key.includes('unit') && key.includes('node')) return 'unit-node';
22+
if (key.includes('unit') && key.includes('jsdom')) return 'unit-jsdom';
23+
if (key.includes('storybook')) return 'storybook';
24+
if (key.includes('build')) return 'build';
25+
return 'unknown';
26+
}
27+
28+
function commandFor(scope: string, job: string): string {
29+
const entry = getWorkflowCatalog().find((item) => item.scope === scope);
30+
const shard = job.match(/shard\s+(\d+)\s*\/\s*(\d+)/i);
31+
return (entry?.reproductionCommand ?? '').replace('{shard}', shard?.[1] ?? '1').replace('{shards}', shard?.[2] ?? '2');
32+
}
33+
34+
export function collectEvidence(options: CollectionOptions): { envelope: EvidenceEnvelope; runDirectory: string } {
35+
const pr = options.pr ? resolvePr(options.pr, options.repo) : undefined;
36+
const resolved = options.run ? { runId: options.run, check: undefined } : resolveRunFromPr(pr!, options.repo);
37+
const run = resolveRun(resolved.runId, options.repo, options.job ?? (resolved.check ? String(resolved.check.name ?? '') : undefined));
38+
const workflow = String(run.metadata.workflowName ?? run.metadata.name ?? 'Unknown workflow');
39+
const job = String(run.job.name ?? options.job ?? 'Unknown job');
40+
const scope = chooseScope(workflow, job);
41+
const rawLog = redactSecrets(run.log);
42+
const failure = extractFailure(rawLog);
43+
const knownPaths = rawLog.match(/(?:playwright-report|test-results)[/\\][^\s)]+/g) ?? [];
44+
const artifacts = extractArtifactMetadata([...knownPaths, ...run.artifacts.map((artifact) => `artifacts/${String(artifact.name ?? '')}`)]);
45+
const envelope: EvidenceEnvelope = {
46+
workflow: redactSecrets(workflow),
47+
job: redactSecrets(job),
48+
runId: resolved.runId,
49+
commit: String(run.metadata.headSha ?? ''),
50+
scope,
51+
reproduction: { command: commandFor(scope, job) || 'unsupported' },
52+
failure,
53+
artifacts,
54+
redactionsApplied: ['authorization', 'token', 'password', 'secret', 'cookie', 'credentialed URLs'],
55+
};
56+
const runDirectory = path.resolve(options.repo, options.outputRoot ?? '.repair-harness/runs', `${Date.now()}-${resolved.runId}`);
57+
mkdirSync(runDirectory, { recursive: true });
58+
writeFileSync(path.join(runDirectory, 'evidence.json'), `${JSON.stringify(envelope, null, 2)}\n`);
59+
writeFileSync(path.join(runDirectory, 'summary.md'), [
60+
`# CI failure collection`, '',
61+
`- Workflow: ${envelope.workflow}`,
62+
`- Job: ${envelope.job}`,
63+
`- Run: ${envelope.runId}`,
64+
`- Scope: ${envelope.scope}`,
65+
`- Failure class: ${envelope.failure.class}`,
66+
`- Reproduction: \`${envelope.reproduction.command}\``,
67+
'', envelope.failure.stackExcerpt,
68+
].join('\n') + '\n');
69+
return { envelope, runDirectory };
70+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export function parseAvailableFields(text: string): string[] {
2+
const match = text.match(/Available fields:\s*([\s\S]+)/i);
3+
if (!match) return [];
4+
return match[1].split('\n').map((line) => line.trim()).filter((line) => /^[a-zA-Z][\w-]*$/.test(line));
5+
}
6+
7+
export function isFailingCheck(check: Record<string, unknown>): boolean {
8+
const failures = new Set(['failure', 'error', 'cancelled', 'timed_out', 'action_required', 'fail']);
9+
return failures.has(String(check.conclusion ?? '').toLowerCase())
10+
|| failures.has(String(check.state ?? check.status ?? '').toLowerCase())
11+
|| failures.has(String(check.bucket ?? '').toLowerCase());
12+
}
13+
14+
export function extractRunIdFromUrl(url: string): string | null {
15+
return url.match(/\/actions\/runs\/(\d+)/)?.[1] ?? null;
16+
}
17+
18+
export function extractFailureSnippet(logText: string, maxLines = 120, context = 20): string {
19+
const lines = logText.split(/\r?\n/);
20+
const failureIndex = lines.findIndex((line) => /error|fail|failed|traceback|exception|assert|panic|fatal|timeout/i.test(line));
21+
if (failureIndex < 0) return lines.slice(-maxLines).join('\n').trim();
22+
const start = Math.max(0, failureIndex - context);
23+
return lines.slice(start, Math.min(lines.length, start + maxLines)).join('\n').trim();
24+
}

scripts/repair-harness/github.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { spawnSync } from 'node:child_process';
2+
3+
import { extractFailureSnippet, extractRunIdFromUrl, isFailingCheck, parseAvailableFields } from './ghHelpers';
4+
5+
export type JsonRecord = Record<string, unknown>;
6+
7+
export interface GhRunResult {
8+
status: number | null;
9+
stdout: string;
10+
stderr: string;
11+
}
12+
13+
export function runGh(args: string[], options: { cwd?: string; allowFailure?: boolean } = {}): GhRunResult {
14+
const result = spawnSync('gh', args, { cwd: options.cwd, encoding: 'utf8' });
15+
const normalized = { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
16+
if (normalized.status !== 0 && !options.allowFailure) {
17+
throw new Error([normalized.stderr, normalized.stdout].filter(Boolean).join('\n').trim() || `gh ${args.join(' ')} failed`);
18+
}
19+
return normalized;
20+
}
21+
22+
export function resolvePr(pr: string | undefined, repo: string): string {
23+
if (pr) return pr;
24+
const result = runGh(['pr', 'view', '--json', 'number'], { cwd: repo });
25+
const data = JSON.parse(result.stdout) as { number?: number };
26+
if (!data.number) throw new Error('Unable to resolve current branch PR.');
27+
return String(data.number);
28+
}
29+
30+
export function fetchChecks(pr: string, repo: string): JsonRecord[] {
31+
const primaryFields = ['name', 'state', 'conclusion', 'detailsUrl', 'startedAt', 'completedAt'];
32+
let result = runGh(['pr', 'checks', pr, '--json', primaryFields.join(',')], { cwd: repo, allowFailure: true });
33+
if (result.status !== 0) {
34+
const availableFields = parseAvailableFields(`${result.stderr}\n${result.stdout}`);
35+
const fallbackFields = ['name', 'state', 'bucket', 'link', 'workflow', 'startedAt', 'completedAt'];
36+
const selectedFields = fallbackFields.filter((field) => availableFields.includes(field));
37+
if (selectedFields.length === 0) throw new Error([result.stderr, result.stdout].filter(Boolean).join('\n').trim() || 'gh pr checks failed');
38+
result = runGh(['pr', 'checks', pr, '--json', selectedFields.join(',')], { cwd: repo });
39+
}
40+
return JSON.parse(result.stdout) as JsonRecord[];
41+
}
42+
43+
export interface ResolvedRun {
44+
metadata: JsonRecord;
45+
job: JsonRecord;
46+
log: string;
47+
artifacts: JsonRecord[];
48+
}
49+
50+
function parseJson(value: string): JsonRecord {
51+
return JSON.parse(value) as JsonRecord;
52+
}
53+
54+
export function resolveRun(runId: string, repo: string, jobName?: string): ResolvedRun {
55+
const metadataResult = runGh(['run', 'view', runId, '--json', 'name,workflowName,conclusion,status,url,event,headBranch,headSha,jobs'], { cwd: repo });
56+
const metadata = parseJson(metadataResult.stdout);
57+
const jobs = Array.isArray(metadata.jobs) ? metadata.jobs as JsonRecord[] : [];
58+
const candidates = jobs.filter((job) => !jobName || String(job.name ?? '').toLowerCase() === jobName.toLowerCase() || String(job.name ?? '').toLowerCase().includes(jobName.toLowerCase()));
59+
const job = candidates.find((item) => isFailingCheck(item)) ?? candidates[0] ?? { name: jobName ?? String(metadata.name ?? 'Actions run') };
60+
const jobId = job.databaseId ?? job.id;
61+
const logArgs = jobId ? ['run', 'view', runId, '--job', String(jobId), '--log'] : ['run', 'view', runId, '--log'];
62+
const logResult = runGh(logArgs, { cwd: repo, allowFailure: true });
63+
const artifactResult = runGh(['api', `repos/{owner}/{repo}/actions/runs/${runId}/artifacts`, '--paginate'], { cwd: repo, allowFailure: true });
64+
let artifacts: JsonRecord[] = [];
65+
if (artifactResult.status === 0 && artifactResult.stdout.trim()) {
66+
try { artifacts = (parseJson(artifactResult.stdout).artifacts as JsonRecord[]) ?? []; } catch { artifacts = []; }
67+
}
68+
return { metadata, job, log: logResult.stdout || logResult.stderr || '', artifacts };
69+
}
70+
71+
export function resolveRunFromPr(pr: string, repo: string): { runId: string; check: JsonRecord } {
72+
const check = fetchChecks(pr, repo).find(isFailingCheck);
73+
if (!check) throw new Error(`PR #${pr}: no failing GitHub Actions checks detected.`);
74+
const url = String(check.detailsUrl ?? check.link ?? '');
75+
const runId = extractRunIdFromUrl(url);
76+
if (!runId) throw new Error(`Unsupported external check: ${String(check.name ?? 'Unnamed check')}`);
77+
return { runId, check };
78+
}
79+
80+
export { extractFailureSnippet, extractRunIdFromUrl, isFailingCheck, parseAvailableFields };

0 commit comments

Comments
 (0)