393 lines
12 KiB
JavaScript
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 };
|