mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
* ci: audit closed-issue delivery claims — cited PRs must actually be merged Follow-up to #3223: an issue closed as 'fixed in #N' while #N was closed without merging (or does not exist) leaves a completion claim that does not hold. The weekly maintenance workflow now runs a read-only audit over issues closed in the last 30 days, extracts delivery-claim PR references from issue bodies and close-window comments, verifies each cited PR's merge state, and reports findings to the step summary. Design constraints: - Read-only: no comments, no closes, no labels; findings are advisory and the run always stays green so the weekly audit never blocks maintenance. - Unverifiable counts as unmerged: a 404 PR or an API failure fails the verdict, because an unverifiable claim is exactly the failure mode. - Pure decision functions are exported for tests; a first live run against this repository surfaced one real finding (#3408 cites #3060, which does not resolve), included here as the motivating sample. 8 unit/contract tests: extraction, windowing (close comments included), the #3223 unmerged-citation failure mode, the all-clear summary, workflow contract, and a no-write guard. * ci: rework audit evidence contract — claims vs context, four PR states, visible completeness Revision per the #3522 review (reviewer ran the CLI through 19 controlled cases plus a bounded live check of #3408): - Bind findings to asserted delivery claims. Closing wording ('Delivered through #N'), claim verbs (fixed/shipped/landed/resolved/...), and bare 'PR #N' inside closing comments are claims; historical mentions, quoted lines, fenced examples, negated claims, and tracking-only references are reported as contextual references and never produce findings. On the live #3408 the audit now recognizes the closing claim #3500 (merged) instead of flagging the historical #3060 text. - Separate observed states: merged / open-unmerged / closed-unmerged / unknown (404, rate limit, transient). Unknown claims are advisory manual- verification items, not failed claims; each row carries the claim snippet, its source link, and the observation time. Checked claims are listed even when passing, so the report is traceable rather than an opaque all-clear. - Paginate issue and comment collection (--paginate --slurp), bound comment requests (200) and PR checks (100), and surface skipped pages, rate limits, and retrieval failures as an explicit incomplete audit instead of an empty all-clear. A live-run maxBuffer overflow on the paginated issue list was caught and fixed (64MB); the audit reported it as incomplete before the fix. - Scope the job to read access: job-level contents/issues/pull-requests read. 13 contract/unit tests including a CLI fixture (stub gh) that places the relevant issue on page two and forces a comment-fetch failure. * ci: report incomplete audit at PR check cap --------- Co-authored-by: chenhz01 <chenhz01@users.noreply.github.com> Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
259 lines
13 KiB
JavaScript
259 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Contract + unit tests for the closed-issue delivery-claim audit.
|
|
*
|
|
* Contract side: the maintenance workflow must run the audit on the weekly
|
|
* schedule with job-level read-only permissions. Unit side: the evidence
|
|
* contract from the #3522 review — asserted claims vs contextual references,
|
|
* four observed PR states, and visible collection completeness. A CLI fixture
|
|
* (stub gh script) exercises pagination and retrieval failures end to end.
|
|
*/
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
|
|
const audit = require('../../scripts/ci/audit-closed-references.js');
|
|
|
|
const WORKFLOW_PATH = path.join(__dirname, '..', '..', '.github', 'workflows', 'maintenance.yml');
|
|
const SCRIPT_PATH = path.join(__dirname, '..', '..', 'scripts', 'ci', 'audit-closed-references.js');
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
return true;
|
|
} catch (error) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function run() {
|
|
console.log('\n=== Testing closed-issue delivery-claim audit ===\n');
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const check = (name, fn) => (test(name, fn) ? passed++ : failed++);
|
|
|
|
const source = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const script = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
|
|
|
check('maintenance workflow runs the audit on schedule', () => {
|
|
assert.match(source, /closed-reference-audit:/);
|
|
assert.match(source, /node scripts\/ci\/audit-closed-references\.js/);
|
|
assert.match(source, /GH_TOKEN:\s*\$\{\{ github\.token \}\}/);
|
|
});
|
|
|
|
check('audit job is scoped to read permissions', () => {
|
|
const job = source.split('closed-reference-audit:')[1] || '';
|
|
assert.match(job, /permissions:\s*\n\s*contents:\s*read\s*\n\s*issues:\s*read\s*\n\s*pull-requests:\s*read/);
|
|
});
|
|
|
|
check('audit stays read-only: no comment or close calls', () => {
|
|
assert.doesNotMatch(script, /-X\s*(POST|PATCH|PUT)/);
|
|
assert.doesNotMatch(script, /method:\s*['"](POST|PATCH|PUT|DELETE)/);
|
|
});
|
|
|
|
check('asserted delivery claims are extracted with wording variants', () => {
|
|
const text = 'Delivered through #3500; also fixed in #3133 and shipped via #3495 and PR #2100.';
|
|
const claims = audit.extractRefs(text, 'closing-comment', 'http://x/1').filter((r) => r.kind === 'claim');
|
|
assert.deepStrictEqual(claims.map((r) => r.pr), [3500, 3133, 3495, 2100]);
|
|
});
|
|
|
|
check('historical mentions, quotes, fences, and negations are not claims', () => {
|
|
assert.deepStrictEqual(audit.extractRefs('see discussion in #100', 'issue-body', 'u').filter((r) => r.kind === 'claim'), []);
|
|
assert.deepStrictEqual(audit.extractRefs('> fixed in #100', 'issue-body', 'u').filter((r) => r.kind === 'claim'), []);
|
|
assert.deepStrictEqual(audit.extractRefs('```\nfixed in #100\n```', 'issue-body', 'u').filter((r) => r.kind === 'claim'), []);
|
|
assert.deepStrictEqual(audit.extractRefs('was not fixed in #100', 'issue-body', 'u').filter((r) => r.kind === 'claim'), []);
|
|
assert.deepStrictEqual(audit.extractRefs('cannot be closed by #100', 'issue-body', 'u').filter((r) => r.kind === 'claim'), []);
|
|
});
|
|
|
|
const NOW = Date.parse('2026-10-12T12:00:00Z');
|
|
const days = (n) => new Date(NOW - n * 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
check('#3408 fixture: closing claim #3500 is found, historical #3060 stays context', () => {
|
|
const issue = {
|
|
number: 3408, state: 'closed', closed_at: days(1),
|
|
html_url: 'http://x/3408', title: 'detector gap',
|
|
body: 'Same area as #3060, historical context only.',
|
|
};
|
|
const comments = [{
|
|
body: 'Delivered through #3500',
|
|
html_url: 'http://x/3408#c3500',
|
|
created_at: days(1),
|
|
}];
|
|
const refs = audit.extractEvidence(issue, comments, NOW);
|
|
const claims = refs.filter((r) => r.kind === 'claim');
|
|
const context = refs.filter((r) => r.kind === 'reference');
|
|
assert.deepStrictEqual(claims.map((r) => r.pr), [3500]);
|
|
assert.deepStrictEqual(context.map((r) => r.pr), [3060]);
|
|
assert.strictEqual(claims[0].sourceUrl, 'http://x/3408#c3500');
|
|
const entries = [{ issue: 3408, issueUrl: issue.html_url, refs }];
|
|
const verdicts = audit.buildVerdicts(entries, new Map([[3500, { observed: 'merged' }]]));
|
|
const summary = audit.buildSummary(verdicts, audit.collectReferences(entries), []).join('\n');
|
|
assert.match(summary, /#3500/);
|
|
assert.match(summary, /#3408: [^\n]*#3060/);
|
|
assert.doesNotMatch(summary, /#3060[^\n]*not merged/);
|
|
assert.doesNotMatch(summary, /#3060[^\n]*unmerged/);
|
|
});
|
|
|
|
check('four observed states stay distinct', () => {
|
|
const entries = [
|
|
{ issue: 1, issueUrl: 'u', refs: [{ pr: 11, kind: 'claim', source: 'closing-comment', sourceUrl: 'u', snippet: 'delivered through #11' }] },
|
|
{ issue: 2, issueUrl: 'u', refs: [{ pr: 22, kind: 'claim', source: 'closing-comment', sourceUrl: 'u', snippet: 'fixed in #22' }] },
|
|
{ issue: 3, issueUrl: 'u', refs: [{ pr: 33, kind: 'claim', source: 'closing-comment', sourceUrl: 'u', snippet: 'shipped via #33' }] },
|
|
{ issue: 4, issueUrl: 'u', refs: [{ pr: 44, kind: 'claim', source: 'closing-comment', sourceUrl: 'u', snippet: 'landed in #44' }] },
|
|
];
|
|
const states = new Map([
|
|
[11, { observed: 'merged' }],
|
|
[22, { observed: 'open-unmerged' }],
|
|
[33, { observed: 'closed-unmerged' }],
|
|
[44, { observed: 'unknown', reason: 'HTTP 404' }],
|
|
]);
|
|
const verdicts = audit.buildVerdicts(entries, states);
|
|
assert.deepStrictEqual(verdicts.map((v) => v.verdict), ['pass', 'unmerged', 'unmerged', 'unverifiable']);
|
|
const summary = audit.buildSummary(verdicts, [], []).join('\n');
|
|
assert.match(summary, /#22: open-unmerged/);
|
|
assert.match(summary, /#33: closed-unmerged/);
|
|
assert.match(summary, /manual verification required/);
|
|
assert.match(summary, /HTTP 404/);
|
|
assert.match(summary, /does not establish that the claim failed/);
|
|
});
|
|
|
|
check('unknown evidence is advisory, never a failed-claim finding', () => {
|
|
const entries = [{ issue: 5, issueUrl: 'u', refs: [{ pr: 55, kind: 'claim', source: 'issue-body', sourceUrl: 'u', snippet: 'fixed in #55' }] }];
|
|
const verdicts = audit.buildVerdicts(entries, new Map([[55, { observed: 'unknown', reason: 'HTTP 404' }]]));
|
|
assert.strictEqual(verdicts[0].verdict, 'unverifiable');
|
|
assert.match(audit.buildSummary(verdicts, [], []).join('\n'), /Unverifiable — manual verification required/);
|
|
});
|
|
|
|
check('window filter keeps only recent closed issues and drops PRs', () => {
|
|
const issues = [
|
|
{ number: 1, state: 'closed', closed_at: days(3) },
|
|
{ number: 2, state: 'closed', closed_at: days(45) },
|
|
{ number: 3, state: 'open', closed_at: days(1) },
|
|
{ number: 4, state: 'closed', closed_at: days(2), pull_request: { url: 'x' } },
|
|
{ number: 5, state: 'closed', closed_at: null },
|
|
];
|
|
const kept = audit.selectRecentClosedIssues(issues, NOW, 30).map((i) => i.number);
|
|
assert.deepStrictEqual(kept, [1]);
|
|
});
|
|
|
|
check('comment window starts at issue closure (close comments count)', () => {
|
|
const comments = [
|
|
{ body: 'closed as delivered through #3133', html_url: 'c1', created_at: days(10) },
|
|
{ body: 'old pre-close chatter #999', html_url: 'c2', created_at: days(40) },
|
|
{ body: 'post-close follow-up landed in #3500', html_url: 'c3', created_at: days(1) },
|
|
];
|
|
const kept = audit.selectWindowComments(comments, days(11), 30, NOW);
|
|
assert.deepStrictEqual(kept.map((c) => c.html_url), ['c1', 'c3']);
|
|
});
|
|
|
|
check('collection failures surface as incomplete, never as all-clear', () => {
|
|
const fetchIssues = () => ({ items: [{ number: 11, state: 'closed', closed_at: days(2), html_url: 'u', title: 'gap', body: 'fixed in #77' }] });
|
|
const fetchComments = () => ({ items: [], error: 'HTTP 403' });
|
|
const { entries, incomplete } = audit.collectEntries(fetchIssues, fetchComments, NOW, 30);
|
|
assert.strictEqual(entries.length, 1); // issue body evidence survives the comment failure
|
|
assert.match(entries[0].refs.map((r) => r.pr).join(','), /77/);
|
|
assert.match(incomplete[0], /#11 comments unavailable: HTTP 403/);
|
|
const summary = audit.buildSummary([], audit.collectReferences(entries), incomplete).join('\n');
|
|
assert.match(summary, /Audit incomplete/);
|
|
assert.match(summary, /absence of findings below is not an all-clear/);
|
|
});
|
|
|
|
check('issues list failure yields an explicitly empty, incomplete audit', () => {
|
|
const { entries, incomplete } = audit.collectEntries(() => ({ items: [], error: 'HTTP 502' }), () => ({ items: [], error: null }), NOW, 30);
|
|
assert.deepStrictEqual(entries, []);
|
|
assert.match(incomplete[0], /issue list unavailable: HTTP 502/);
|
|
assert.match(audit.buildSummary([], [], incomplete).join('\n'), /Audit incomplete/);
|
|
});
|
|
|
|
// CLI fixture: a stub gh script exercises pagination (issue on page two)
|
|
// and a retrieval failure, end to end through the real main().
|
|
check('CLI fixture: page-two issue is audited and failed comments report incomplete', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-cli-'));
|
|
const stub = path.join(tmp, 'stub-gh.js');
|
|
fs.writeFileSync(stub, `
|
|
const args = process.argv.slice(2);
|
|
const url = args.find((a) => a.includes('/issues?state=closed')) || '';
|
|
if (url) {
|
|
const page1 = [{ number: 1, state: 'closed', closed_at: '2026-10-11T00:00:00Z', html_url: 'u1', title: 'a', body: 'no refs here' }];
|
|
const page2 = [{ number: 88, state: 'closed', closed_at: '2026-10-11T00:00:00Z', html_url: 'u88', title: 'page two', body: 'no refs either' }];
|
|
process.stdout.write(JSON.stringify([page1, page2]));
|
|
return;
|
|
}
|
|
if (args.some((a) => a.includes('/issues/88/comments'))) {
|
|
process.stderr.write('gh: HTTP 500');
|
|
process.exit(1);
|
|
}
|
|
if (args.some((a) => a.includes('/issues/1/comments'))) {
|
|
process.stdout.write(JSON.stringify([{ body: 'Delivered through #3500', html_url: 'c1', created_at: '2026-10-11T00:00:00Z' }]));
|
|
return;
|
|
}
|
|
if (args.some((a) => a.includes('/pulls/'))) {
|
|
process.stdout.write(JSON.stringify({ state: 'closed', merged: true }));
|
|
return;
|
|
}
|
|
process.stderr.write('unexpected args');
|
|
process.exit(2);
|
|
`);
|
|
const summaryPath = path.join(tmp, 'summary.md');
|
|
const env = {
|
|
...process.env,
|
|
GITHUB_REPOSITORY: 'example/repo',
|
|
GITHUB_STEP_SUMMARY: summaryPath,
|
|
AUDIT_GH_SCRIPT: stub,
|
|
};
|
|
execFileSync(process.execPath, [SCRIPT_PATH], { env, encoding: 'utf8' });
|
|
const summary = fs.readFileSync(summaryPath, 'utf8');
|
|
assert.match(summary, /Delivered through #3500/); // page-one issue's claim was audited
|
|
assert.match(summary, /#88 comments unavailable: HTTP 500/);
|
|
assert.match(summary, /Audit incomplete/);
|
|
assert.match(summary, /Observed: /);
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
check('CLI fixture: 101 distinct cited PRs report the unchecked claim and incomplete audit', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-cap-'));
|
|
try {
|
|
const stub = path.join(tmp, 'stub-gh.js');
|
|
fs.writeFileSync(stub, `
|
|
const args = process.argv.slice(2);
|
|
if (args.some((a) => a.includes('/issues?state=closed'))) {
|
|
const body = Array.from({ length: 101 }, (_, i) => 'fixed in #' + (i + 1)).join('; ');
|
|
const issue = { number: 99, state: 'closed', closed_at: new Date().toISOString(), html_url: 'u99', title: 'delivery', body };
|
|
process.stdout.write(JSON.stringify([[issue]]));
|
|
} else if (args.some((a) => a.includes('/issues/99/comments'))) {
|
|
process.stdout.write(JSON.stringify([[]]));
|
|
} else if (args.some((a) => a.includes('/pulls/'))) {
|
|
process.stdout.write(JSON.stringify({ state: 'closed', merged: true }));
|
|
} else {
|
|
process.stderr.write('unexpected args');
|
|
process.exit(2);
|
|
}
|
|
`);
|
|
const summaryPath = path.join(tmp, 'summary.md');
|
|
execFileSync(process.execPath, [SCRIPT_PATH], {
|
|
env: { ...process.env, GITHUB_REPOSITORY: 'example/repo', GITHUB_STEP_SUMMARY: summaryPath, AUDIT_GH_SCRIPT: stub },
|
|
encoding: 'utf8',
|
|
});
|
|
const summary = fs.readFileSync(summaryPath, 'utf8');
|
|
assert.match(summary, /\*\*Audit incomplete\*\*/);
|
|
assert.match(summary, /1 cited PR\(s\) not checked: PR check limit of 100 reached/);
|
|
assert.match(summary, /101 asserted delivery claim\(s\) found: 0 unmerged finding\(s\), 1 unverifiable/);
|
|
assert.match(summary, /#101: unavailable \(not fetched\)/);
|
|
assert.match(summary, /#100: merged/);
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
console.log(`\nPassed: ${passed} Failed: ${failed}`);
|
|
if (failed > 0) process.exit(1);
|
|
}
|
|
|
|
run();
|