// 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`); } }