/** Minimal Gitea REST client (no external dependencies). */ export class GiteaError extends Error { constructor(message, { status, body, method, url } = {}) { super(message); this.name = "GiteaError"; this.status = status; this.body = body; this.method = method; this.url = url; } } /** * Normalize a Gitea base URL to the server root. * Accepts `http://host`, `http://host/`, or `http://host/api/v1` and always * returns the root form, because every request path already carries the * `/api/v1` prefix. */ export function normalizeBaseUrl(input) { const trimmed = String(input || "").trim().replace(/\/+$/, ""); if (!trimmed) throw new Error("Gitea base URL is required"); return trimmed.replace(/\/api\/v1$/, ""); } export class GiteaClient { constructor({ baseUrl, token, timeoutMs = 60000, userAgent = "gitea-codereview" }) { this.baseUrl = normalizeBaseUrl(baseUrl); this.token = token; this.timeoutMs = timeoutMs; this.userAgent = userAgent; } async request(method, path, { body, query, raw = false, headers = {}, timeoutMs } = {}) { const url = new URL(this.baseUrl + path); if (query) { for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v)); } } const init = { method, headers: { Accept: raw ? "text/plain, application/json" : "application/json", "User-Agent": this.userAgent, ...headers, }, signal: AbortSignal.timeout(timeoutMs ?? this.timeoutMs), }; if (this.token) init.headers.Authorization = `token ${this.token}`; if (body !== undefined) { init.headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } let res; try { res = await fetch(url, init); } catch (err) { throw new GiteaError(`Gitea request failed: ${method} ${url.pathname}: ${err.message}`, { method, url: url.toString(), }); } const text = await res.text(); if (!res.ok) { let parsed = null; try { parsed = JSON.parse(text); } catch { /* keep raw text */ } const detail = parsed?.message || text.slice(0, 400) || res.statusText; throw new GiteaError( `Gitea ${method} ${url.pathname} -> ${res.status}: ${detail}`, { status: res.status, body: parsed ?? text, method, url: url.toString() }, ); } if (raw) return text; if (!text) return null; try { return JSON.parse(text); } catch { return text; } } get(path, options) { return this.request("GET", path, options); } post(path, body, options) { return this.request("POST", path, { ...options, body }); } patch(path, body, options) { return this.request("PATCH", path, { ...options, body }); } put(path, body, options) { return this.request("PUT", path, { ...options, body }); } del(path, options) { return this.request("DELETE", path, options); } /** Verify the token and return the authenticated user. */ async getCurrentUser() { return this.get("/api/v1/user"); } async getVersion() { return this.get("/api/v1/version"); } async getRepo(owner, repo) { return this.get(`/api/v1/repos/${owner}/${repo}`); } /** Repositories visible to the token, across pages. */ async listAccessibleRepos(limit = 50, maxPages = 40) { const out = []; const seen = new Set(); for (let page = 1; page <= maxPages; page += 1) { let batch; try { batch = await this.get("/api/v1/user/repos", { query: { limit, page } }); } catch (err) { if (page === 1) throw err; break; } if (!Array.isArray(batch) || batch.length === 0) break; for (const r of batch) { if (r?.full_name && !seen.has(r.full_name)) { seen.add(r.full_name); out.push(r); } } if (batch.length < limit) break; } return out; } async listRepoWebhooks(owner, repo) { return this.get(`/api/v1/repos/${owner}/${repo}/hooks`); } /** * Create (or update) the review webhook for a repository. * Returns { created: boolean, id }. */ async ensureRepoWebhook(owner, repo, { url, secret, events = ["push", "pull_request"] }) { const desired = { type: "gitea", active: true, name: "gitea-codereview", events, config: { url, content_type: "json", ...(secret ? { secret } : {}) }, }; let existing = []; try { existing = (await this.listRepoWebhooks(owner, repo)) ?? []; } catch { existing = []; } const match = existing.find((h) => h.config?.url === url) ?? existing.find((h) => h.name === desired.name); if (match) { await this.patch(`/api/v1/repos/${owner}/${repo}/hooks/${match.id}`, desired); return { created: false, id: match.id }; } const created = await this.post(`/api/v1/repos/${owner}/${repo}/hooks`, desired); return { created: true, id: created?.id ?? null }; } /** Create a branch, optionally from another branch/tag/commit. */ async createBranch(owner, repo, { newBranch, fromBranch }) { const payload = { new_branch_name: newBranch }; if (fromBranch) payload.old_ref_name = fromBranch; return this.post(`/api/v1/repos/${owner}/${repo}/branches`, payload); } async listRepoBranches(owner, repo, limit = 100) { const out = []; for (let page = 1; page <= 20; page += 1) { const batch = await this.get(`/api/v1/repos/${owner}/${repo}/branches`, { query: { limit, page }, }); if (!Array.isArray(batch) || batch.length === 0) break; out.push(...batch); if (batch.length < limit) break; } return out; } async getBranch(owner, repo, branch) { return this.get(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`); } async getIssue(owner, repo, index) { return this.get(`/api/v1/repos/${owner}/${repo}/issues/${index}`); } async listIssues(owner, repo, query = {}) { return this.get(`/api/v1/repos/${owner}/${repo}/issues`, { query }); } async createIssue(owner, repo, { title, body, labels, assignees }) { const payload = { title, body }; if (labels?.length) payload.labels = labels; if (assignees?.length) payload.assignees = assignees; return this.post(`/api/v1/repos/${owner}/${repo}/issues`, payload); } async updateIssue(owner, repo, index, patch) { return this.patch(`/api/v1/repos/${owner}/${repo}/issues/${index}`, patch); } async listIssueComments(owner, repo, index, limit = 100) { const out = []; for (let page = 1; page <= 20; page += 1) { const batch = await this.get(`/api/v1/repos/${owner}/${repo}/issues/${index}/comments`, { query: { limit, page }, }); if (!Array.isArray(batch) || batch.length === 0) break; out.push(...batch); if (batch.length < limit) break; } return out; } async createIssueComment(owner, repo, index, body) { return this.post(`/api/v1/repos/${owner}/${repo}/issues/${index}/comments`, { body }); } async updateIssueComment(owner, repo, commentId, body) { return this.patch(`/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`, { body }); } async createCommitStatus(owner, repo, sha, { state, context, description, targetUrl }) { return this.post(`/api/v1/repos/${owner}/${repo}/statuses/${sha}`, { state, context, description: description?.slice(0, 255) ?? "", target_url: targetUrl ?? "", }); } async getCommitStatuses(owner, repo, ref) { return this.get(`/api/v1/repos/${owner}/${repo}/commits/${ref}/statuses`); } async getPullRequest(owner, repo, index) { return this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}`); } /** Open a pull request. */ async createPullRequest(owner, repo, { title, head, base, body = "" }) { return this.post(`/api/v1/repos/${owner}/${repo}/pulls`, { title, head, base, body }); } /** Close a pull request without merging it. */ async closePullRequest(owner, repo, index) { return this.patch(`/api/v1/repos/${owner}/${repo}/pulls/${index}`, { state: "closed" }); } /** Compare two refs; returns the file list, or null when they are identical. */ async compareRefs(owner, repo, base, head) { try { return await this.get(`/api/v1/repos/${owner}/${repo}/compare/${base}...${head}`); } catch (err) { // Gitea answers 404 when the two refs point at the same commit. if (err.status === 404) return null; throw err; } } async listPullRequests(owner, repo, query = {}) { return this.get(`/api/v1/repos/${owner}/${repo}/pulls`, { query }); } async listPullRequestFiles(owner, repo, index) { return this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}/files`); } async listPullRequestCommits(owner, repo, index) { return this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}/commits`); } /** * Create a review with optional inline comments. * `comments` entries use { path, body, newPosition, oldPosition }. */ async createPullReview(owner, repo, index, { event = "COMMENT", body = "", commitId, comments = [] }) { const payload = { event, body }; if (commitId) payload.commit_id = commitId; if (comments.length) { payload.comments = comments.map((c) => { const entry = { path: c.path, body: c.body }; if (c.newPosition) entry.new_position = c.newPosition; else if (c.oldPosition) entry.old_position = c.oldPosition; return entry; }); } return this.post(`/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, payload); } async listPullReviews(owner, repo, index, limit = 50) { const out = []; for (let page = 1; page <= 20; page += 1) { const batch = await this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, { query: { limit, page }, }); if (!Array.isArray(batch) || batch.length === 0) break; out.push(...batch); if (batch.length < limit) break; } return out; } async mergePullRequest(owner, repo, index, { style = "squash", title, message, deleteBranch, headCommitId, mergeWhenChecksSucceed = false } = {}) { const payload = { do: style }; if (title) payload.merge_title_field = title; if (message) payload.merge_message_field = message; if (deleteBranch !== undefined) payload.delete_branch_after_merge = Boolean(deleteBranch); if (headCommitId) payload.head_commit_id = headCommitId; if (mergeWhenChecksSucceed) payload.merge_when_checks_succeed = true; return this.post(`/api/v1/repos/${owner}/${repo}/pulls/${index}/merge`, payload); } async listRepoLabels(owner, repo) { return this.get(`/api/v1/repos/${owner}/${repo}/labels`, { query: { limit: 100 } }); } async createRepoLabel(owner, repo, { name, color = "#1f6feb", description = "" }) { return this.post(`/api/v1/repos/${owner}/${repo}/labels`, { name, color, description }); } /** * Resolve label names to repository label IDs, creating missing labels. * Gitea's issue API takes label IDs, not names. * @returns {Promise} label IDs that could be resolved. */ async ensureLabels(owner, repo, names) { if (!names?.length) return []; let existing = []; try { existing = (await this.listRepoLabels(owner, repo)) ?? []; } catch { existing = []; } const byName = new Map(existing.map((l) => [l.name, l.id])); for (const name of names) { if (byName.has(name)) continue; try { const created = await this.createRepoLabel(owner, repo, { name }); if (created?.id) byName.set(name, created.id); } catch { // Label creation is best-effort; issue creation still proceeds. } } return names.map((n) => byName.get(n)).filter((id) => Number.isInteger(id)); } async getUser(login) { return this.get(`/api/v1/users/${encodeURIComponent(login)}`); } } /** Derive the repository web URL from an API base URL. */ export function webBaseUrl(apiBaseUrl) { return normalizeBaseUrl(apiBaseUrl); }