Adds CLI commands for inspecting a pull request, posting a comment, and submitting an APPROVE/REQUEST_CHANGES/COMMENT review. Includes API client methods, CLI wiring, and tests.
269 lines
8 KiB
JavaScript
269 lines
8 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 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 };
|