Files
granthi-review/src/lib/gitea.js
T
Claude ad8c1256d8 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]>
2026-08-19 11:58:59 -04:00

102 lines
3.8 KiB
JavaScript

// 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(/\/$/, '');
this.token = token;
this.timeoutMs = timeoutMs;
}
async req(method, path, { body, raw = false, ok = [200, 201] } = {}) {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
Authorization: `token ${this.token}`,
...(body ? { 'Content-Type': 'application/json' } : {})
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(this.timeoutMs)
});
if (!ok.includes(res.status)) {
const text = await res.text().catch(() => '');
const err = new Error(`gitea ${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`);
err.status = res.status;
throw err;
}
return raw ? res.text() : res.json().catch(() => ({}));
}
// PR diff
getPrDiff(owner, repo, index) {
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/${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/${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
// has no compare .diff variant, but the web compare route serves .diff.
// CAVEAT (probed live on 1.27.1): the web route does NOT honor
// `Authorization: token` for private repos — it resolves as anonymous and
// 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', `/${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/${seg(owner)}/${seg(repo)}`);
}
// Add a collaborator (requires owner/admin token). 204 on success.
addCollaborator(owner, repo, username, permission = 'write') {
return this.req('PUT', `/api/v1/repos/${owner}/${repo}/collaborators/${username}`, {
body: { permission }, ok: [204]
});
}
// File content at HEAD (returns null when absent)
async getFileHead(owner, repo, path, maxBytes = 8192) {
try {
const txt = await this.req('GET',
`/api/v1/repos/${owner}/${repo}/raw/${encodeURIComponent(path)}`, { raw: true });
return txt.slice(0, maxBytes);
} catch (e) {
if (e.status === 404) return null;
throw e;
}
}
postStatus(owner, repo, sha, { state, context, description, target_url }) {
return this.req('POST', `/api/v1/repos/${owner}/${repo}/statuses/${sha}`, {
body: { state, context, description: (description || '').slice(0, 255), target_url }
});
}
postIssueComment(owner, repo, index, body) {
return this.req('POST', `/api/v1/repos/${owner}/${repo}/issues/${index}/comments`, {
body: { body }
});
}
getPrCommits(owner, repo, index) {
return this.req('GET', `/api/v1/repos/${owner}/${repo}/pulls/${index}/commits?limit=50`);
}
getCommit(owner, repo, sha) {
return this.req('GET', `/api/v1/repos/${owner}/${repo}/git/commits/${sha}?stat=false&verification=false&files=false`);
}
}