/** * 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 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; 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; } 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) { return this.post('/user/repos', payload); } async migrateRepo(payload) { return this.post('/repos/migrate', payload, { timeout: MIGRATE_TIMEOUT_MS }); } async getAll(endpoint, params = {}) { const pageSize = 50; const all = []; for (let page = 1; page <= 1000; page += 1) { const query = new URLSearchParams({ ...params, limit: String(pageSize), page: String(page) }).toString(); const items = await this.get(`${endpoint}?${query}`); 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 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) { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, { event, body: body || '', }); } 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 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 };