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]>
102 lines
3.8 KiB
JavaScript
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`);
|
|
}
|
|
}
|