stoke/src/api.js
kimi-reviewer-andresmgsl 8e6907a11e Add pr show, comment and review commands
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.
2026-07-22 21:20:46 +00:00

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