/** * Forgejo API client. * * A thin wrapper around the Forgejo REST API (`/api/v1`). It supports two * authentication modes: * - Basic auth (username/password) — required by Forgejo for the personal * access token endpoints (create/delete). * - Token auth — used for every other call. * * Each public method maps to a single endpoint; see the README for the * command-to-endpoint mapping. */ const pkg = require('../package.json'); const fs = require('node:fs'); const REQUEST_TIMEOUT_MS = 30000; // Repository migrations clone the full source repository and can legitimately // take minutes, so they get a much longer budget. const MIGRATE_TIMEOUT_MS = 10 * 60 * 1000; // Release assets can be much larger than JSON API payloads, so uploads get a // separate budget while retaining the standard timeout for ordinary calls. const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000; class ForgejoClient { constructor(baseUrl, token = null) { this.baseUrl = baseUrl.replace(/\/$/, ''); this.token = token; } static fromConfig(config) { if (!config || !config.url || !config.token) { throw new Error('Not authenticated. Run: stoke auth login'); } return new ForgejoClient(config.url, config.token); } withBasicAuth(username, password) { const clone = new ForgejoClient(this.baseUrl); clone.basicAuth = Buffer.from(`${username}:${password}`).toString('base64'); return clone; } headers(extra = {}) { const h = { Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': `stoke/${pkg.version}`, ...extra, }; if (this.token) { h.Authorization = `token ${this.token}`; } else if (this.basicAuth) { h.Authorization = `Basic ${this.basicAuth}`; } return h; } async request(method, endpoint, body = null, { timeout = REQUEST_TIMEOUT_MS } = {}) { const url = `${this.baseUrl}/api/v1${endpoint}`; const opts = { method, headers: this.headers(), signal: AbortSignal.timeout(timeout), }; if (body !== null) { opts.body = JSON.stringify(body); } let res; try { res = await fetch(url, opts); } catch (err) { if (err.name === 'TimeoutError') { throw new Error(`Request to ${this.baseUrl} timed out after ${timeout / 1000}s`); } throw new Error(`Network error reaching ${this.baseUrl}: ${err.message}`); } const text = await res.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } } if (!res.ok) { const msg = data?.message || data?.raw || `HTTP ${res.status}`; const err = new Error(msg); err.status = res.status; err.body = data; throw err; } return data; } async uploadRequest(endpoint, form, { timeout = UPLOAD_TIMEOUT_MS } = {}) { const url = `${this.baseUrl}/api/v1${endpoint}`; const headers = this.headers(); delete headers['Content-Type']; let res; try { res = await fetch(url, { method: 'POST', headers, body: form, signal: AbortSignal.timeout(timeout), }); } catch (err) { if (err.name === 'TimeoutError') { throw new Error(`Upload to ${this.baseUrl} timed out after ${timeout / 1000}s`); } throw new Error(`Network error reaching ${this.baseUrl}: ${err.message}`); } const text = await res.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } } if (!res.ok) { const msg = data?.message || data?.raw || `HTTP ${res.status}`; const err = new Error(msg); err.status = res.status; err.body = data; throw err; } return data; } get(endpoint) { return this.request('GET', endpoint); } post(endpoint, body, opts) { return this.request('POST', endpoint, body, opts); } del(endpoint) { return this.request('DELETE', endpoint); } async verifyBasicAuth(username, password) { const client = this.withBasicAuth(username, password); return client.get('/user'); } async createToken(username, password, name, scopes) { const client = this.withBasicAuth(username, password); return client.post(`/users/${encodeURIComponent(username)}/tokens`, { name, scopes }); } // Forgejo requires Basic auth for token management endpoints; a token // cannot be used to revoke itself (the API answers 401). async deleteToken(username, password, login, id) { const client = this.withBasicAuth(username, password); return client.del(`/users/${encodeURIComponent(login)}/tokens/${id}`); } async createRepo(payload, owner) { if (!owner) return this.post('/user/repos', payload); const authenticatedUser = await this.get('/user'); if (owner.toLowerCase() === authenticatedUser.login.toLowerCase()) { return this.post('/user/repos', payload); } return this.post(`/orgs/${encodeURIComponent(owner)}/repos`, payload); } async migrateRepo(payload) { return this.post('/repos/migrate', payload, { timeout: MIGRATE_TIMEOUT_MS }); } async getAll(endpoint, params = {}) { const pageSize = 50; const all = []; // The pager owns limit/page: a caller-supplied pair must be overridden, // not duplicated — a duplicated limit pins the page size the server // honors first and can truncate or loop the walk. const queryIndex = endpoint.indexOf('?'); const path = queryIndex === -1 ? endpoint : endpoint.slice(0, queryIndex); const baseQuery = new URLSearchParams(queryIndex === -1 ? '' : endpoint.slice(queryIndex + 1)); baseQuery.delete('limit'); baseQuery.delete('page'); for (let page = 1; page <= 1000; page += 1) { const query = new URLSearchParams(baseQuery); for (const [key, value] of Object.entries(params)) query.set(key, value); query.set('limit', String(pageSize)); query.set('page', String(page)); const items = await this.get(`${path}?${query.toString()}`); if (!Array.isArray(items) || items.length === 0) break; all.push(...items); if (items.length < pageSize) break; } return all; } async listRepos(opts = {}) { return this.getAll('/user/repos', opts); } async listIssues(owner, repo, opts = {}) { const params = {}; if (opts.state) params.state = opts.state; if (opts.type) params.type = opts.type; return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, params); } async createIssue(owner, repo, payload) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, payload); } async getIssue(owner, repo, index) { return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}`); } async createIssueComment(owner, repo, index, body) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body }); } async listPullRequests(owner, repo, opts = {}) { return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, opts); } async createPullRequest(owner, repo, payload) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, payload); } async getPullRequest(owner, repo, index) { return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}`); } async createPullRequestComment(owner, repo, index, body) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body }); } async createPullRequestReview(owner, repo, index, event, body, { commitId } = {}) { const payload = { event, body: body || '', }; if (commitId) payload.commit_id = commitId; return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, payload); } async mergePullRequest(owner, repo, index, payload) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/merge`, payload); } async listBranches(owner, repo, opts = {}) { return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, opts); } async listReleases(owner, repo, opts = {}) { return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, opts); } async getReleaseByTag(owner, repo, tag) { return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`); } async createRelease(owner, repo, payload) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, payload); } async uploadReleaseAsset(owner, repo, releaseId, filePath, name) { const form = new FormData(); let file; try { file = await fs.openAsBlob(filePath); } catch (err) { throw new Error(`Could not read asset file ${filePath}: ${err.message}`); } form.append('attachment', file, name); const query = new URLSearchParams({ name }); return this.uploadRequest( `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${encodeURIComponent(releaseId)}/assets?${query}`, form, ); } async listLabels(owner, repo, opts = {}) { return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, opts); } async createLabel(owner, repo, payload) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, payload); } async deleteLabel(owner, repo, id) { return this.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels/${id}`); } // Pull requests are issues as far as labels are concerned, so these two // serve both surfaces. async addIssueLabels(owner, repo, index, labelIds) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/labels`, { labels: labelIds, }); } async removeIssueLabel(owner, repo, index, labelId) { return this.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/labels/${labelId}`); } async addCollaborator(owner, repo, username, permission) { return this.request('PUT', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`, { permission, }); } async renameRepo(owner, repo, newName) { return this.request('PATCH', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { name: newName, }); } async transferRepo(owner, repo, newOwner) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/transfer`, { new_owner: newOwner, }); } async createOrg(payload) { return this.post('/orgs', payload); } async listOrgRepos(org, opts = {}) { return this.getAll(`/orgs/${encodeURIComponent(org)}/repos`, opts); } async updateOrgAvatar(org, base64Image) { return this.post(`/orgs/${encodeURIComponent(org)}/avatar`, { image: base64Image, }); } async searchUsers(query = '', opts = {}) { const pageSize = 50; const all = []; for (let page = 1; page <= 1000; page += 1) { const queryString = new URLSearchParams({ q: query, ...opts, limit: String(pageSize), page: String(page), }).toString(); const res = await this.get(`/users/search?${queryString}`); const items = Array.isArray(res?.data) ? res.data : []; if (items.length === 0) break; all.push(...items); if (items.length < pageSize) break; } return all; } async listOrgTeams(org, opts = {}) { return this.getAll(`/orgs/${encodeURIComponent(org)}/teams`, opts); } async createTeam(org, payload) { return this.post(`/orgs/${encodeURIComponent(org)}/teams`, payload); } async addTeamMember(teamId, username) { return this.request('PUT', `/teams/${teamId}/members/${encodeURIComponent(username)}`); } async listTeamMembers(teamId, opts = {}) { return this.getAll(`/teams/${teamId}/members`, opts); } async removeTeamMember(teamId, username) { return this.del(`/teams/${teamId}/members/${encodeURIComponent(username)}`); } async getUser(username) { return this.get(`/users/${encodeURIComponent(username)}`); } } module.exports = { ForgejoClient };