Files
openchamber/packages/web/server/lib/gitlab/client.js
T
bot-hermes 75687dc9b0 fix: return page as object {page, next, total, hasMore, nextUrl} for routes.js compat
routes.js reads resp.page?.hasMore and resp.page?.nextUrl — the old
HTTP client returned page as an object from parsePageInfo. The CLI
pivot accidentally returned page as a number, making hasMore always
undefined in routes.js. Restore the object shape in both gitea and
gitlab clients. Update tests to assert the object shape.
2026-09-05 20:36:46 +00:00

274 lines
11 KiB
JavaScript

import { spawn } from 'child_process';
import { getGitLabAuth, getGitLabDefaultBaseUrl } from './auth.js';
const REQUEST_TIMEOUT_MS = 8000;
// NOTE: ETag conditional-GET cache and rate-limit cooldown have been dropped
// with the pivot to CLI transports. Each call spawns a fresh `glab` process,
// so there is no persistent connection to attach conditional headers to, and
// rate-limit state cannot be shared across invocations. The tradeoff is higher
// latency per call (process spawn overhead) and no 304 short-circuit, but
// simpler state management and no module-level mutable cache. Callers that
// relied on `isGitLabRateLimited()` will always see false (no cooldown active).
/**
* Spawn a CLI binary and return { stdout, stderr, exitCode }.
* Rejects if the process does not finish within REQUEST_TIMEOUT_MS.
*/
function spawnCli(bin, args, env, timeoutMs = REQUEST_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, {
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`CLI ${bin} timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.on('close', (code) => {
clearTimeout(timer);
resolve({ stdout, stderr, exitCode: code ?? 1 });
});
child.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
/**
* Parse the `Link` header value to extract rel="next" and rel="prev" URLs.
* Returns { next, prev } with the raw URL strings (or null).
*/
function parseLinkHeader(linkHeader) {
if (!linkHeader) return { next: null, prev: null };
const result = { next: null, prev: null };
const parts = linkHeader.split(',');
for (const part of parts) {
const match = part.match(/<([^>]+)>;\s*rel="(\w+)"/);
if (match) {
const [, url, rel] = match;
if (rel === 'next') result.next = url;
if (rel === 'prev') result.prev = url;
}
}
return result;
}
/**
* Run a `glab api` call and parse the response envelope.
*
* `glab api --include` outputs everything to **stdout**:
* <status line: HTTP/1.1 200 OK>
* <headers, one per line>
* <empty line>
* <JSON body>
*
* On non-zero exit, stderr gets a human-readable error summary (e.g.,
* `glab: 401 Unauthorized (HTTP 401)`).
*
* GITLAB_TOKEN env var IS respected by glab (unlike tea's GITEA_SERVER_TOKEN).
*
* Pagination: `--paginate` does NOT exist in glab. We parse the `Link` header
* to derive `page` and `hasMore`.
*/
async function glabApiCall(endpoint, { method = 'GET', body, raw, glabBin, token }) {
const args = ['api', '--include'];
if (method !== 'GET') args.push('-X', method);
if (raw) args.push('--header', 'Accept: text/plain');
if (body !== undefined) args.push('--header', 'Content-Type: application/json', '-d', JSON.stringify(body));
args.push(endpoint);
let result;
try {
result = await spawnCli(glabBin, args, { GITLAB_TOKEN: token });
} catch (err) {
return { status: 500, headers: {}, data: null, page: null, error: err.message };
}
const { stdout, stderr, exitCode } = result;
// glab --include puts status+headers+body all on stdout.
// On error, stdout may still have the full response, and stderr has the summary.
if (exitCode !== 0 && !stdout.trim()) {
return { status: 500, headers: {}, data: null, page: null, error: stderr.trim() || `glab exited with code ${exitCode}` };
}
// Parse --include output: status line, headers, blank line, body.
const lines = stdout.split('\n');
let status = 200;
const headers = {};
let bodyStart = 0;
const statusMatch = lines[0]?.match(/HTTP\/\S+\s+(\d+)/);
if (statusMatch) {
status = Number(statusMatch[1]);
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '') {
bodyStart = i + 1;
break;
}
const colonIdx = lines[i].indexOf(':');
if (colonIdx > 0) {
headers[lines[i].slice(0, colonIdx).trim().toLowerCase()] = lines[i].slice(colonIdx + 1).trim();
}
}
}
const bodyText = lines.slice(bodyStart).join('\n').trim();
if (!bodyText) {
return { status, headers, data: null, page: null };
}
if (raw) {
return { status, headers, data: bodyText, page: null };
}
let data;
try {
data = JSON.parse(bodyText);
} catch {
data = bodyText;
}
// Derive pagination info object matching the pre-pivot parsePageInfo shape.
// routes.js reads resp.page?.hasMore, resp.page?.nextUrl, etc.
const { next } = parseLinkHeader(headers.link);
const listIsArray = Array.isArray(data);
const totalRaw = headers['x-total'] || headers['x-total-count'];
const total = totalRaw ? Number(totalRaw) : null;
let currentPage = null;
if (listIsArray && next) {
const pageMatch = next.match(/[?&]page=(\d+)/);
if (pageMatch) {
currentPage = Math.max(1, Number(pageMatch[1]) - 1);
}
}
const pageInfo = {
page: currentPage,
next: next || null,
total: total !== null && Number.isFinite(total) ? total : null,
hasMore: listIsArray ? next !== null : false,
nextUrl: next || undefined,
};
return { status, headers, data, page: pageInfo };
}
// ---- Rate-limit helpers (no-ops with CLI transport) ----
export function noteGitLabRateLimit() { /* no-op: CLI processes are stateless */ }
export function isGitLabRateLimited() { return false; }
// ---- Response helpers ----
const encodeProject = (pathWithNamespace) => encodeURIComponent(String(pathWithNamespace));
// Build the relative API path for glab CLI. glab resolves the base URL from its
// own config, so we pass the path WITHOUT the /api/v4 prefix — glab adds it.
const apiPath = (path) => {
const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : '';
return p;
};
/**
* Create a CLI-backed GitLab REST v4 client. Spawns `glab api` for each
* request. `request` never throws for HTTP error statuses — it returns
* `{ status, headers, data, page, hasMore }` so callers can branch on status
* codes.
*/
export function createGitLabClient({ token, baseUrl }) {
const effectiveBaseUrl = normalizeBaseForClient(baseUrl);
const glabBin = process.env.GLAB_BIN || '/home/user/.local/bin/glab';
const request = async (path, options = {}) => {
const method = (typeof options.method === 'string' ? options.method : 'GET').toUpperCase();
const query = options.query && typeof options.query === 'object' ? options.query : {};
const body = options.body;
// Build the relative endpoint path with query params baked in.
let endpoint = apiPath(path);
const qs = new URLSearchParams();
let hasQuery = false;
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
qs.set(key, String(value));
hasQuery = true;
}
if (hasQuery) {
endpoint += `${endpoint.includes('?') ? '&' : '?'}${qs.toString()}`;
}
return glabApiCall(endpoint, { method, body, glabBin, token });
};
return {
request,
baseUrl: effectiveBaseUrl,
user: () => request('/user'),
project: (pathWithNamespace) => request(`/projects/${encodeProject(pathWithNamespace)}`),
issues: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues`, { query: params }),
issue: (pathWithNamespace, iid) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`),
issueNotes: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { query: params }),
createIssueNote: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { method: 'POST', body: { body } }),
createIssue: (pathWithNamespace, params) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues`, { method: 'POST', body: params }),
updateIssue: (pathWithNamespace, iid, params) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`, { method: 'PUT', body: params }),
mergeRequests: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { query: params }),
mergeRequest: (pathWithNamespace, iid) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}`),
mergeRequestDiffs: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/diffs`, { query: params }),
mergeRequestCommits: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/commits`, { query: params }),
mergeRequestNotes: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/notes`, { query: params }),
createMrNote: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/notes`, { method: 'POST', body: { body } }),
approveMr: (pathWithNamespace, iid) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/approve`, { method: 'POST' }),
milestones: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/milestones`, { query: params }),
createMergeRequest: (pathWithNamespace, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { method: 'POST', body }),
updateMergeRequest: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}`, { method: 'PUT', body }),
mergeMergeRequest: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/merge`, { method: 'PUT', body }),
branches: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/repository/branches`, { query: params }),
members: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/members/all`, { query: params }),
labels: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/labels`, { query: params }),
tags: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/repository/tags`, { query: params }),
};
}
function normalizeBaseForClient(baseUrl) {
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
return getGitLabDefaultBaseUrl();
}
return baseUrl.trim().replace(/\/+$/, '');
}
/** Picks the current account (from auth.js) token + base URL, or null. */
export function getGitLabClientOrNull() {
const auth = getGitLabAuth();
if (!auth?.accessToken) {
return null;
}
return createGitLabClient({ token: auth.accessToken, baseUrl: auth.baseUrl });
}