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
+9 -5
View File
@@ -1,5 +1,9 @@
// Minimal Gitea API client (fetch-based, no deps).
// URL path segment from webhook/API-supplied values (owner, repo, refs,
// shas): never trust them as URL-safe — branch names can carry '/', '#', '?'.
const seg = (s) => encodeURIComponent(String(s));
export class GiteaClient {
constructor({ baseUrl, token, timeoutMs = 30000 }) {
this.baseUrl = baseUrl.replace(/\/$/, '');
@@ -28,17 +32,17 @@ export class GiteaClient {
// PR diff
getPrDiff(owner, repo, index) {
return this.req('GET', `/api/v1/repos/${owner}/${repo}/pulls/${index}.diff`, { raw: true });
return this.req('GET', `/api/v1/repos/${seg(owner)}/${seg(repo)}/pulls/${seg(index)}.diff`, { raw: true });
}
// Single-commit diff
getCommitDiff(owner, repo, sha) {
return this.req('GET', `/api/v1/repos/${owner}/${repo}/git/commits/${sha}.diff`, { raw: true });
return this.req('GET', `/api/v1/repos/${seg(owner)}/${seg(repo)}/git/commits/${seg(sha)}.diff`, { raw: true });
}
// Compare (commit list between two shas)
compare(owner, repo, before, after) {
return this.req('GET', `/api/v1/repos/${owner}/${repo}/compare/${before}...${after}`);
return this.req('GET', `/api/v1/repos/${seg(owner)}/${seg(repo)}/compare/${seg(before)}...${seg(after)}`);
}
// Full-range diff for a push (base...head) in ONE request. Gitea 1.27's API
@@ -48,12 +52,12 @@ export class GiteaClient {
// answers 404 even for valid refs. Callers must be ready to fall back to
// the API-based sources below (fetchDiff in review.js does).
getCompareDiff(owner, repo, before, after) {
return this.req('GET', `/${owner}/${repo}/compare/${before}...${after}.diff`, { raw: true });
return this.req('GET', `/${seg(owner)}/${seg(repo)}/compare/${seg(before)}...${seg(after)}.diff`, { raw: true });
}
// Repo metadata (used for default_branch when the job doesn't carry it)
getRepo(owner, repo) {
return this.req('GET', `/api/v1/repos/${owner}/${repo}`);
return this.req('GET', `/api/v1/repos/${seg(owner)}/${seg(repo)}`);
}
// Add a collaborator (requires owner/admin token). 204 on success.