stoke/src/api.js
codex-bot-andresmgsl 3c0709189e
All checks were successful
labels / labels (pull_request) Successful in 8s
ci / test (pull_request) Successful in 14s
fix: validate release asset uploads
2026-08-30 11:08:01 +00:00

393 lines
12 KiB
JavaScript

/**
* 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 };