fix: dual-review round-2 — partial diffs cap the verdict, bounded fallback chain, URL-safe paths

Codex adversarial review of the fallback chain found 1 P1 + 3 P2:
- P1: head-only and incomplete per-commit fallbacks could present a SUBSET
  diff as a full passing review. fetchDiff now returns {diff, partial, note};
  review.js caps partial reviews at 'warn' (same contract as truncation) and
  labels the status 'partial review (<reason>)'.
- getRepo default-branch lookup swallows only 404; auth/5xx propagate.
- per-commit reconstruction dedupes + validates shas (40-hex) and hard-caps
  at 20 requests; capped/short coverage marks the result partial; the
  all-sources-failed message is bounded to 1500 chars.
- gitea.js URL-encodes every webhook-supplied path segment (owner/repo/ref/
  sha) — branch names with '/' or '#' can no longer distort request paths.

Tests: 61 pass (2 new: sha cap/dedupe, getRepo 401 propagation).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Claude
2026-08-19 11:58:59 -04:00
parent a531323865
commit ad8c1256d8
3 changed files with 125 additions and 42 deletions
+54 -11
View File
@@ -18,7 +18,8 @@ const BEFORE = 'b'.repeat(40);
test('PR jobs use the PR diff', async () => {
const g = giteaStub();
const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: 5, sha: HEAD, before: null } });
assert.equal(out, 'PRDIFF');
assert.equal(out.diff, 'PRDIFF');
assert.equal(out.partial, false);
assert.deepEqual(g.calls, [['pr', 'o', 'r', 5]]);
});
@@ -32,7 +33,8 @@ test('push uses ONE compare diff over the full before...after range', async () =
commitShas: Array.from({ length: 25 }, (_, i) => String(i).padStart(40, '0'))
}
});
assert.equal(out, 'RANGEDIFF');
assert.equal(out.diff, 'RANGEDIFF');
assert.equal(out.partial, false);
assert.deepEqual(g.calls, [['compare', 'o', 'r', BEFORE, HEAD]]);
});
@@ -42,7 +44,8 @@ test('new-branch push (before = zero sha) falls back to head commit diff', async
gitea: g,
job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: '0'.repeat(40), commitShas: [HEAD] }
});
assert.equal(out, 'HEADDIFF');
assert.equal(out.diff, 'HEADDIFF');
assert.equal(out.partial, false);
assert.deepEqual(g.calls, [['commit', 'o', 'r', HEAD]]);
});
@@ -52,7 +55,8 @@ test('missing before also falls back to head commit diff', async () => {
gitea: g,
job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: null, commitShas: [HEAD] }
});
assert.equal(out, 'HEADDIFF');
assert.equal(out.diff, 'HEADDIFF');
assert.equal(out.partial, false);
assert.deepEqual(g.calls, [['commit', 'o', 'r', HEAD]]);
});
@@ -84,7 +88,8 @@ test('compare 404 falls back to default-branch compare', async () => {
gitea: g,
job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main' }
});
assert.equal(out, 'RANGEDIFF');
assert.equal(out.diff, 'RANGEDIFF');
assert.equal(out.partial, false);
assert.deepEqual(g.calls.at(-1), ['compare', 'o', 'r', 'main', HEAD]);
});
@@ -99,7 +104,8 @@ test('compare 404 without defaultBranch on the job looks it up via getRepo', asy
gitea: g,
job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE }
});
assert.equal(out, 'RANGEDIFF');
assert.equal(out.diff, 'RANGEDIFF');
assert.equal(out.partial, false);
assert.deepEqual(g.calls.at(-1), ['compare', 'o', 'r', 'develop', HEAD]);
});
@@ -120,7 +126,8 @@ test('both compares 404 -> API commit list -> concatenated per-commit diffs', as
gitea: g,
job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main' }
});
assert.equal(out, 'DIFF(c)\nDIFF(d)');
assert.equal(out.diff, 'DIFF(c)\nDIFF(d)');
assert.equal(out.partial, false);
assert.deepEqual(g.calls.filter(c => c[0] === 'commit'), [['commit', 'o', 'r', C1], ['commit', 'o', 'r', C2]]);
});
@@ -142,7 +149,8 @@ test('API compare 404 too -> per-commit diffs from the webhook commitShas', asyn
defaultBranch: 'main', commitShas: [C1, HEAD]
}
});
assert.equal(out, 'DIFF(c)\nDIFF(a)');
assert.equal(out.diff, 'DIFF(c)\nDIFF(a)');
assert.equal(out.partial, false);
});
test('per-commit fallback stops fetching past maxBytes (review truncates after)', async () => {
@@ -159,7 +167,9 @@ test('per-commit fallback stops fetching past maxBytes (review truncates after)'
});
// first two diffs exceed the cap (20 > 15) -> third commit never fetched
assert.equal(fetched.length, 2);
assert.equal(out, 'x'.repeat(10) + '\n' + 'x'.repeat(10));
assert.equal(out.diff, 'x'.repeat(10) + '\n' + 'x'.repeat(10));
// byte-capped reconstruction covered 2/3 commits -> partial, verdict capped
assert.equal(out.partial, true);
});
test('every source 404 -> throws listing every URL tried (then fail-open)', async () => {
@@ -190,7 +200,9 @@ test('PR diff 404 falls back to the head commit diff', async () => {
const g = giteaStub();
g.getPrDiff = (o, r, i) => Promise.reject(nf(`/api/v1/repos/o/r/pulls/${i}.diff`));
const out = await fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: 7, sha: HEAD, before: null } });
assert.equal(out, 'HEADDIFF');
assert.equal(out.diff, 'HEADDIFF');
// head-only PR fallback reviews a subset -> partial
assert.equal(out.partial, true);
assert.deepEqual(g.calls, [['commit', 'o', 'r', HEAD]]);
});
@@ -201,5 +213,36 @@ test('empty compare body is treated as a miss, not a reviewable diff', async ()
gitea: g,
job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main' }
});
assert.equal(out, 'RANGEDIFF');
assert.equal(out.diff, 'RANGEDIFF');
});
test('per-commit fallback dedupes, validates, and caps the sha list at 20', async () => {
const g = giteaStub();
// 25 unique shas + 1 dup + 1 garbage entry: only the first 20 valid uniques fetch
const shas = Array.from({ length: 25 }, (_, i) => String(i).padStart(40, 'f'.charCodeAt ? '0' : '0'));
const payload = [...shas, shas[0], 'not-a-sha'];
g.getCompareDiff = () => Promise.reject(nf('/o/r/compare/x...y.diff'));
g.compare = () => Promise.reject(nf('/api/v1/repos/o/r/compare/x...y'));
const fetched = [];
g.getCommitDiff = (o, r, sha) => { fetched.push(sha); return Promise.resolve('D'); };
const out = await fetchDiff({
gitea: g,
job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE, defaultBranch: 'main', commitShas: payload }
});
assert.equal(fetched.length, 20);
assert.equal(out.partial, true); // 20/25 covered
});
test('getRepo auth failure propagates instead of being swallowed', async () => {
const g = giteaStub();
g.getCompareDiff = (o, r, before) => before === BEFORE
? Promise.reject(nf('/o/r/compare/x...y.diff'))
: Promise.resolve('RANGEDIFF');
const authErr = new Error('gitea GET /api/v1/repos/o/r -> 401: unauthorized');
authErr.status = 401;
g.getRepo = () => Promise.reject(authErr);
await assert.rejects(
() => fetchDiff({ gitea: g, job: { owner: 'o', repo: 'r', prIndex: null, sha: HEAD, before: BEFORE } }),
/401: unauthorized/
);
});