// // HTTP client for one Gitea instance. Handles auth, query building, pagination, // binary downloads, and a small concurrency cap so a refresh across several // repositories does not open dozens of sockets at once. // const log = require("../util/log.js"); const MAX_CONCURRENT = 6; const PAGE_SIZE = 50; class GiteaError extends Error { constructor(message, status, body) { super(message); this.name = "GiteaError"; this.status = status; this.body = body; } get isAuth() { return this.status === 401 || this.status === 403; } get isMissing() { return this.status === 404; } } /** Runs at most `max` tasks at once, queueing the rest. */ class Limiter { constructor(max) { this.max = max; this.active = 0; this.queue = []; } run(task) { return new Promise((resolve, reject) => { this.queue.push({ task, resolve, reject }); this._drain(); }); } _drain() { while (this.active < this.max && this.queue.length) { const entry = this.queue.shift(); this.active += 1; Promise.resolve() .then(entry.task) .then(entry.resolve, entry.reject) .then(() => { this.active -= 1; this._drain(); }); } } } function buildQuery(query) { if (!query) return ""; const parts = []; for (const key of Object.keys(query)) { const value = query[key]; if (value === null || value === undefined || value === "") continue; parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); } return parts.length ? `?${parts.join("&")}` : ""; } class GiteaClient { /** * @param {String} baseUrl Instance root, without a trailing slash. * @param {Function} tokenProvider Returns the token string, or null. */ constructor(baseUrl, tokenProvider) { this.baseUrl = baseUrl; this.tokenProvider = tokenProvider; this.limiter = new Limiter(MAX_CONCURRENT); } get apiRoot() { return `${this.baseUrl}/api/v1`; } get token() { try { return this.tokenProvider(this.baseUrl); } catch (error) { log.error("token lookup failed", String(error)); return null; } } headers(extra) { const headers = Object.assign({ Accept: "application/json" }, extra || {}); const token = this.token; if (token) headers["Authorization"] = `token ${token}`; return headers; } /** Absolute URLs (artifact redirects) pass through untouched. */ resolve(path) { if (/^https?:\/\//i.test(path)) return path; return `${this.apiRoot}${path.startsWith("/") ? path : "/" + path}`; } async request(method, path, options = {}) { const url = this.resolve(path) + buildQuery(options.query); const init = { method: method, headers: this.headers(options.headers) }; if (options.body !== undefined && options.body !== null) { init.body = JSON.stringify(options.body); init.headers["Content-Type"] = "application/json"; } log.debug(method, url); const response = await this.limiter.run(() => fetch(url, init)); if (!response.ok) { let detail = ""; try { detail = await response.text(); } catch (error) { detail = ""; } throw new GiteaError( describeFailure(method, path, response.status, detail), response.status, detail, ); } if (options.responseType === "text") return await response.text(); if (options.responseType === "binary") return await response.arrayBuffer(); if (options.responseType === "response") return response; if (response.status === 204) return null; const text = await response.text(); if (!text) return null; try { return JSON.parse(text); } catch (error) { throw new GiteaError(`Unexpected response from ${url}`, response.status, text); } } get(path, options) { return this.request("GET", path, options); } post(path, body, options) { return this.request("POST", path, Object.assign({ body: body }, options)); } put(path, body, options) { return this.request("PUT", path, Object.assign({ body: body }, options)); } patch(path, body, options) { return this.request("PATCH", path, Object.assign({ body: body }, options)); } delete(path, options) { return this.request("DELETE", path, options); } /** * Walks paged list endpoints until `limit` items are collected or the server * returns a short page. */ async paginate(path, options = {}) { const limit = options.limit || PAGE_SIZE; const pageSize = Math.min(limit, PAGE_SIZE); const collected = []; for (let page = 1; collected.length < limit; page += 1) { const query = Object.assign({}, options.query, { page: page, limit: pageSize }); const body = await this.get(path, { query: query }); const items = options.pick ? options.pick(body) : body; if (!Array.isArray(items) || !items.length) break; collected.push(...items); if (items.length < pageSize) break; } return collected.slice(0, limit); } } function describeFailure(method, path, status, detail) { let message = ""; try { const parsed = JSON.parse(detail); message = parsed.message || parsed.error || ""; } catch (error) { message = (detail || "").slice(0, 200); } if (status === 401) return "Gitea rejected the token (401). Set a valid token."; if (status === 403) { return `Gitea denied the request (403). The token is missing a required scope. ${message}`.trim(); } if (status === 404) return `Not found: ${method} ${path}. ${message}`.trim(); return `Gitea request failed (${status}): ${method} ${path}. ${message}`.trim(); } exports.GiteaClient = GiteaClient; exports.GiteaError = GiteaError; exports.Limiter = Limiter;