feat(web): add GitLab issues/MRs server module

Add a server-side GitLab integration mirroring the GitHub module:
- auth.js: PAT auth storage with multi-account support and configurable
  base URL (gitlab.com default, self-hosted instances supported)
- client.js: raw-fetch GitLab REST v4 client with per-request timeout,
  ETag conditional-GET cache, own rate-limit cooldown, pagination, and
  single-follow redirect handling
- repo.js: GitLab remote URL parser + directory resolution
- routes.js: read-only /api/gitlab/* routes (auth, issues, MRs, branches)
- index.js + DOCUMENTATION.md + unit tests for auth, client, repo, routes
- Wire registerGitLabRoutes into feature-routes-runtime
This commit is contained in:
2026-08-16 15:42:26 +00:00
parent b5ddb6f3a1
commit d798b3f479
11 changed files with 2609 additions and 0 deletions
@@ -0,0 +1,127 @@
# GitLab Module Documentation
## Purpose
- This module owns GitLab auth (Personal Access Token), raw REST v4 client access, remote-URL repo resolution, and read-only GitLab issue / merge-request (MR) APIs for OpenChamber.
- From a user perspective, this is the layer that lets the app show GitLab issues and merge requests for a local project, including comments and per-file diffs.
- The module mirrors `packages/web/server/lib/github/` but uses a **Personal Access Token (PAT)** with a configurable base URL (gitlab.com by default, or a self-hosted instance), and talks to GitLab's REST v4 API directly via `fetch` — no new dependencies.
## Entrypoints and structure
- `packages/web/server/lib/gitlab/index.js`: public server entrypoint re-exports.
- `packages/web/server/lib/gitlab/routes.js`: Express route registration for `/api/gitlab/*` endpoints.
- `packages/web/server/lib/gitlab/auth.js`: PAT auth storage, multi-account support, base URL normalization.
- `packages/web/server/lib/gitlab/client.js`: raw `fetch` GitLab REST v4 client (timeout, ETag conditional GET, rate-limit cooldown, pagination, redirect handling).
- `packages/web/server/lib/gitlab/repo.js`: GitLab remote URL parsing and directory-to-repo resolution.
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: API route layer that calls this module (via `registerGitLabRoutes`).
- `packages/web/src/api/gitlab.ts`: web client wrapper for GitLab endpoints.
- `packages/ui/src/lib/api/types.ts`: shared response types consumed by web, desktop, VS Code, and mobile.
## Public exports
### Auth (`auth.js`)
- `getGitLabAuth()`: current auth entry.
- `getGitLabAuthAccounts()`: all configured accounts (`{ id, user, baseUrl, current }`).
- `setGitLabAuth({ accessToken, baseUrl, user })`: save or update an account (validating `user` comes from `GET /user`).
- `activateGitLabAuth(accountId)`: switch active account.
- `clearGitLabAuth()`: remove the current account.
- `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input.
- `GITLAB_AUTH_FILE`: auth file path.
- `DEFAULT_GITLAB_BASE_URL`: `https://gitlab.com`.
### Client (`client.js`)
- `createGitLabClient({ token, baseUrl })`: raw-fetch REST v4 client with `request(path, { method, query, body })` plus convenience methods `user()`, `project(path)`, `issues(path, params)`, `issue(path, iid)`, `issueNotes(path, iid, params)`, `mergeRequests(path, params)`, `mergeRequest(path, iid)`, `mergeRequestDiffs(path, iid, params)`, `branches(path, params)`.
- `getGitLabClientOrNull()`: client for the current account, or `null`.
- `isGitLabRateLimited()` / `noteGitLabRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub module's `rate-limit.js`).
### Repo (`repo.js`)
- `parseGitLabRemoteUrl(raw, knownHosts?)`: parse SSH/HTTPS remote URL into `{ namespace, project, host, baseUrl, url }` (multi-segment namespaces supported; never matches `github.com`).
- `resolveGitLabRepoFromDirectory(directory, remoteName?)`: resolve a GitLab repo from a local git remote.
## Auth storage and config
- Auth storage: `~/.config/openchamber/gitlab-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
- Writes are atomic (tmp file + rename) and file mode is `0o600`.
- Base URL resolution: caller-supplied `baseUrl` (normalized) -> `DEFAULT_GITLAB_BASE_URL`.
- Account id: `` `${host}:${username}` `` (e.g. `gitlab.com:alice`), falling back to `token:<first8>` when the username is missing.
- Auth header on every request: `PRIVATE-TOKEN: <pat>`.
## OAuth readiness
The stored entry shape (`accessToken`, `baseUrl`, `username`, `name`, `avatarUrl`, `webUrl`, `email`, `createdAt`, `current`) is intentionally generic. OAuth flows would slot in at two points:
1. `routes.js` — add `POST /api/gitlab/auth/start` / `auth/complete` endpoints next to the existing `auth/connect` (mirroring the GitHub device-flow routes), exchanging the OAuth grant for an access token.
2. `setGitLabAuth` — persists whatever `accessToken` + `user` shape the OAuth callback produces; no storage changes needed.
Nothing in the client or repo layers assumes the token came from a PAT.
## Client behavior
- Base URL joining: `{baseUrl}/api/v4{path}`. Project `:id` segments are URL-encoded with `encodeURIComponent` (e.g. `group/sub` -> `group%2Fsub`) and never double-encoded.
- Per-request timeout: 8000 ms via `AbortSignal.timeout`, unless the caller passes its own signal.
- ETag conditional-GET cache: keyed `token\nurl`, max 300 LRU entries; a `304` is replayed from cache as a `200`. GET only.
- Pagination: `x-page`, `x-next-page`, `x-total-pages`, and the `Link` header (`rel="next"`) are parsed into the returned `page` object (`hasMore` = a next page exists).
- Redirects: `301`/`302`/`308` with a `Location` header are followed exactly once (project moves) with `redirect: 'manual'`, preserving `PRIVATE-TOKEN` across the hop.
- Rate limits: a `429` records a module-level cooldown (honoring `Retry-After` / `RateLimit-Reset` when present) and surfaces `{ status: 429, error: 'GitLab rate limited' }`. While the cooldown is active, requests short-circuit without hitting the network.
- `request` never throws for HTTP error statuses — callers branch on `status`.
## API integration overview
- Issues/MRs are addressed project-scoped by **iid**.
- Issue list: `GET /projects/:id/issues?state=opened&scope=all&per_page=50&page=N&search=<query>`.
- Issue detail: `GET /projects/:id/issues/:issue_iid`.
- Issue notes: `GET /projects/:id/issues/:issue_iid/notes?per_page=100` (system notes are skipped; each note links as `{issue_web_url}#note_{id}`).
- MR list: `GET /projects/:id/merge_requests?state=opened&scope=all&per_page=50&page=N&search=<query>`.
- MR detail: `GET /projects/:id/merge_requests/:merge_request_iid`.
- MR diffs: `GET /projects/:id/merge_requests/:merge_request_iid/diffs?per_page=100&page=N` (paginated; the route caps at 10 pages / 3000 files).
- MR notes: `GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100`.
- Branches: `GET /projects/:id/repository/branches?per_page=100&page=N`.
- User: `GET /user` -> `{ id, username, name, state, avatar_url, web_url, email, ... }`.
## Route contract (`/api/gitlab/*`)
| Method | Path | Shape |
|---|---|---|
| GET | `/api/gitlab/auth/status` | `{ connected, user?, accounts[], defaultBaseUrl }` |
| POST | `/api/gitlab/auth/connect` | body `{ accessToken, baseUrl? }` -> `{ connected, user, accounts, defaultBaseUrl }`; `400` for missing/invalid token |
| POST | `/api/gitlab/auth/activate` | body `{ accountId }` -> `{ connected, user, accounts, defaultBaseUrl }`; `404` unknown account |
| DELETE | `/api/gitlab/auth` | `{ removed }` |
| GET | `/api/gitlab/me` | `{ username, id, name, avatarUrl, webUrl, email? }`; `401` when not connected |
| GET | `/api/gitlab/issues/list` | `?directory&page&query` -> `{ connected, repo?, issues[], page, hasMore }` |
| GET | `/api/gitlab/issues/get` | `?directory&number&namespace&project` -> `{ connected, repo?, issue }` |
| GET | `/api/gitlab/issues/comments` | `?directory&number&namespace&project` -> `{ connected, repo?, comments[] }` |
| GET | `/api/gitlab/mrs/list` | `?directory&page&query` -> `{ connected, repo?, mrs[], page, hasMore }` |
| GET | `/api/gitlab/mrs/context` | `?directory&number&diff&namespace&project` -> `{ connected, repo?, mr, comments[], files[], diff? }` |
| GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[] }` |
Conventions mirror `github/routes.js`:
- Not authenticated -> `connected: false` (or `401` for `/me`).
- Missing/invalid params -> `400` with `{ error }`.
- Hard failures -> `4xx`/`5xx` with `{ error }`.
- A GitLab `429` -> `503 { error: 'GitLab rate limited' }`.
- Lazy-import pattern: route handlers import `./index.js` on first use, so the module never loads unless GitLab endpoints are hit.
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout.
## Consumers
- `packages/web/src/api/gitlab.ts` calls every `/api/gitlab/*` endpoint and maps them to the shared types.
- `packages/ui/src/lib/api/types.ts` defines the shared `GitLab*` response types used across web, desktop, VS Code, and mobile.
## Failure handling
- If GitLab is disconnected, read routes return `connected: false`.
- A repo that does not resolve from the local git remote yields `repo: null` with empty lists, matching the GitHub behavior.
- Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected.
- Rate-limit and timeout failures surface explicit `503` responses so clients keep last-known state rather than clearing UI.
## Notes for contributors
- Keep the response shapes in lockstep with `GitLab*` types in `packages/ui/src/lib/api/types.ts`.
- Never log tokens. Error messages must not include the access token.
- Do not double-encode project paths; convenience methods already call `encodeURIComponent` on the `pathWithNamespace`.
- The ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub module.
- To add GitLab write operations (comment, assign, merge), add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the GitHub PR write routes.
+317
View File
@@ -0,0 +1,317 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
const STORAGE_FILE = path.join(STORAGE_DIR, 'gitlab-auth.json');
export const DEFAULT_GITLAB_BASE_URL = 'https://gitlab.com';
function ensureStorageDir() {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
}
function readJsonFile() {
ensureStorageDir();
if (!fs.existsSync(STORAGE_FILE)) {
return null;
}
try {
const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed;
} catch (error) {
console.error('Failed to read GitLab auth file:', error);
return null;
}
}
function writeJsonFile(payload) {
ensureStorageDir();
// Atomic write so multiple OpenChamber instances can safely share the same file.
const tmpFile = `${STORAGE_FILE}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
try {
fs.chmodSync(tmpFile, 0o600);
} catch {
// best-effort
}
fs.renameSync(tmpFile, STORAGE_FILE);
try {
fs.chmodSync(STORAGE_FILE, 0o600);
} catch {
// best-effort
}
}
/**
* Normalize a user-provided GitLab base URL. Adds `https://` when no scheme is
* present, strips a trailing slash, and returns null for anything unparseable.
*/
export function normalizeBaseUrl(raw) {
if (typeof raw !== 'string') {
return null;
}
let value = raw.trim();
if (!value) {
return null;
}
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value)) {
value = `https://${value}`;
}
let parsed;
try {
parsed = new URL(value);
} catch {
return null;
}
if (!parsed.hostname) {
return null;
}
parsed.hash = '';
parsed.search = '';
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
return parsed.href.replace(/\/+$/, '');
}
function hostFromBaseUrl(baseUrl) {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) {
return null;
}
try {
return new URL(normalized).hostname || null;
} catch {
return null;
}
}
function resolveAccountId({ username, accessToken, baseUrl, accountId }) {
if (typeof accountId === 'string' && accountId.trim()) {
return accountId.trim();
}
const host = hostFromBaseUrl(baseUrl);
if (typeof username === 'string' && username.trim()) {
return host ? `${host}:${username.trim()}` : username.trim();
}
if (typeof accessToken === 'string' && accessToken.trim()) {
return `token:${accessToken.slice(0, 8)}`;
}
return '';
}
function normalizeAuthEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
if (!accessToken) return null;
const baseUrl = normalizeBaseUrl(entry.baseUrl) || DEFAULT_GITLAB_BASE_URL;
const username = typeof entry.username === 'string' ? entry.username : '';
const accountId = resolveAccountId({
username,
accessToken,
baseUrl,
accountId: typeof entry.accountId === 'string' ? entry.accountId : '',
});
return {
accessToken,
baseUrl,
username: username || null,
name: typeof entry.name === 'string' ? entry.name : null,
avatarUrl: typeof entry.avatarUrl === 'string' ? entry.avatarUrl : null,
webUrl: typeof entry.webUrl === 'string' ? entry.webUrl : null,
email: typeof entry.email === 'string' ? entry.email : null,
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
current: Boolean(entry.current),
accountId,
};
}
function normalizeAuthList(raw) {
const list = (Array.isArray(raw) ? raw : [raw])
.map((entry) => normalizeAuthEntry(entry))
.filter(Boolean);
if (!list.length) {
return { list: [], changed: false };
}
let changed = false;
let currentFound = false;
list.forEach((entry) => {
if (entry.current && !currentFound) {
currentFound = true;
} else if (entry.current && currentFound) {
entry.current = false;
changed = true;
}
});
if (!currentFound && list[0]) {
list[0].current = true;
changed = true;
}
list.forEach((entry) => {
if (!entry.accountId) {
entry.accountId = resolveAccountId(entry);
changed = true;
}
});
return { list, changed };
}
function readAuthList() {
const data = readJsonFile();
if (!data) {
return [];
}
const { list, changed } = normalizeAuthList(data);
if (changed) {
writeJsonFile(list);
}
return list;
}
function writeAuthList(list) {
writeJsonFile(list);
}
export function getGitLabAuth() {
const list = readAuthList();
if (!list.length) {
return null;
}
const current = list.find((entry) => entry.current) || list[0];
if (!current?.accessToken) {
return null;
}
return current;
}
export function getGitLabAuthAccounts() {
const list = readAuthList();
return list
.filter((entry) => entry?.accountId)
.map((entry) => ({
id: entry.accountId,
user: {
username: entry.username || null,
name: entry.name || null,
avatarUrl: entry.avatarUrl || null,
webUrl: entry.webUrl || null,
},
baseUrl: entry.baseUrl || DEFAULT_GITLAB_BASE_URL,
current: Boolean(entry.current),
}));
}
export function setGitLabAuth({ accessToken, baseUrl, user }) {
if (!accessToken || typeof accessToken !== 'string') {
throw new Error('accessToken is required');
}
const normalizedBaseUrl = normalizeBaseUrl(baseUrl) || DEFAULT_GITLAB_BASE_URL;
const normalizedUser = user && typeof user === 'object'
? {
username: typeof user.username === 'string' ? user.username : undefined,
name: typeof user.name === 'string' ? user.name : undefined,
avatarUrl: typeof user.avatar_url === 'string' ? user.avatar_url : undefined,
webUrl: typeof user.web_url === 'string' ? user.web_url : undefined,
email: typeof user.email === 'string' ? user.email : undefined,
}
: undefined;
const username = normalizedUser?.username || '';
const resolvedAccountId = resolveAccountId({
username,
accessToken,
baseUrl: normalizedBaseUrl,
accountId: '',
});
const list = readAuthList();
const existingIndex = list.findIndex((entry) => entry.accountId === resolvedAccountId);
const nextEntry = {
accessToken,
baseUrl: normalizedBaseUrl,
username: username || null,
name: normalizedUser?.name ?? null,
avatarUrl: normalizedUser?.avatarUrl ?? null,
webUrl: normalizedUser?.webUrl ?? null,
email: normalizedUser?.email ?? null,
createdAt: Date.now(),
current: true,
accountId: resolvedAccountId,
};
if (existingIndex >= 0) {
list[existingIndex] = nextEntry;
} else {
list.push(nextEntry);
}
list.forEach((entry, index) => {
entry.current = index === (existingIndex >= 0 ? existingIndex : list.length - 1);
});
writeAuthList(list);
return nextEntry;
}
export function activateGitLabAuth(accountId) {
if (typeof accountId !== 'string' || !accountId.trim()) {
return false;
}
const list = readAuthList();
const index = list.findIndex((entry) => entry.accountId === accountId.trim());
if (index === -1) {
return false;
}
list.forEach((entry, idx) => {
entry.current = idx === index;
});
writeAuthList(list);
return true;
}
export function clearGitLabAuth() {
try {
const list = readAuthList();
if (!list.length) {
return true;
}
const remaining = list.filter((entry) => !entry.current);
if (!remaining.length) {
if (fs.existsSync(STORAGE_FILE)) {
fs.unlinkSync(STORAGE_FILE);
}
return true;
}
remaining.forEach((entry, index) => {
entry.current = index === 0;
});
writeAuthList(remaining);
return true;
} catch (error) {
console.error('Failed to clear GitLab auth file:', error);
return false;
}
}
export const GITLAB_AUTH_FILE = STORAGE_FILE;
+161
View File
@@ -0,0 +1,161 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, afterEach, describe, expect, test } from 'vitest';
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitlab-auth-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
const {
getGitLabAuth,
getGitLabAuthAccounts,
setGitLabAuth,
activateGitLabAuth,
clearGitLabAuth,
normalizeBaseUrl,
GITLAB_AUTH_FILE,
DEFAULT_GITLAB_BASE_URL,
} = await import('./auth.js');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
afterEach(() => {
if (fs.existsSync(GITLAB_AUTH_FILE)) {
fs.unlinkSync(GITLAB_AUTH_FILE);
}
});
const aliceUser = {
id: 42,
username: 'alice',
name: 'Alice Example',
state: 'active',
avatar_url: 'https://gitlab.com/uploads/-/avatar.png',
web_url: 'https://gitlab.com/alice',
email: 'alice@example.com',
};
describe('normalizeBaseUrl', () => {
test('adds https scheme when missing', () => {
expect(normalizeBaseUrl('gitlab.example.com')).toBe('https://gitlab.example.com');
});
test('strips trailing slash', () => {
expect(normalizeBaseUrl('https://gitlab.com/')).toBe('https://gitlab.com');
expect(normalizeBaseUrl('https://gitlab.example.com/gitlab/')).toBe('https://gitlab.example.com/gitlab');
});
test('keeps an explicit scheme', () => {
expect(normalizeBaseUrl('http://localhost:8080')).toBe('http://localhost:8080');
});
test('returns null for invalid input', () => {
expect(normalizeBaseUrl('')).toBeNull();
expect(normalizeBaseUrl('not a url')).toBeNull();
expect(normalizeBaseUrl('://bad')).toBeNull();
expect(normalizeBaseUrl(null)).toBeNull();
expect(normalizeBaseUrl(undefined)).toBeNull();
});
});
describe('setGitLabAuth', () => {
test('stores an account with a host-prefixed accountId', () => {
setGitLabAuth({ accessToken: 'glpat-secret', baseUrl: 'gitlab.com', user: aliceUser });
const auth = getGitLabAuth();
expect(auth).not.toBeNull();
expect(auth.accountId).toBe('gitlab.com:alice');
expect(auth.baseUrl).toBe('https://gitlab.com');
expect(auth.username).toBe('alice');
expect(auth.name).toBe('Alice Example');
expect(auth.avatarUrl).toBe('https://gitlab.com/uploads/-/avatar.png');
expect(auth.webUrl).toBe('https://gitlab.com/alice');
expect(auth.email).toBe('alice@example.com');
expect(auth.current).toBe(true);
expect(auth.createdAt).toEqual(expect.any(Number));
});
test('writes the auth file with 0600 permissions', () => {
setGitLabAuth({ accessToken: 'glpat-secret', baseUrl: DEFAULT_GITLAB_BASE_URL, user: aliceUser });
const stats = fs.statSync(GITLAB_AUTH_FILE);
// 0o600 mask
expect(stats.mode & 0o777).toBe(0o600);
});
test('replaces the same account instead of duplicating it', () => {
setGitLabAuth({ accessToken: 'glpat-old', baseUrl: 'gitlab.com', user: aliceUser });
setGitLabAuth({
accessToken: 'glpat-new',
baseUrl: 'https://gitlab.com',
user: { ...aliceUser, name: 'Alice Renamed' },
});
const accounts = getGitLabAuthAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].user.name).toBe('Alice Renamed');
expect(getGitLabAuth().accessToken).toBe('glpat-new');
});
test('falls back to a token prefix accountId when username is missing', () => {
setGitLabAuth({ accessToken: 'glpat-prefixtest', baseUrl: 'gitlab.com', user: { id: 1 } });
const accounts = getGitLabAuthAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].id).toBe('token:glpat-pr');
});
test('requires an access token', () => {
expect(() => setGitLabAuth({ baseUrl: 'gitlab.com', user: aliceUser })).toThrow('accessToken is required');
});
});
describe('multi-account switching', () => {
test('tracks a single current account and can switch it', () => {
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
setGitLabAuth({
accessToken: 'glpat-b',
baseUrl: 'https://gitlab.example.com',
user: { ...aliceUser, username: 'bob', name: 'Bob' },
});
expect(getGitLabAuth().accountId).toBe('gitlab.example.com:bob');
const switched = activateGitLabAuth('gitlab.com:alice');
expect(switched).toBe(true);
expect(getGitLabAuth().accountId).toBe('gitlab.com:alice');
expect(getGitLabAuthAccounts().find((a) => a.id === 'gitlab.example.com:bob')?.current).toBe(false);
});
test('activate returns false for an unknown account', () => {
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
expect(activateGitLabAuth('gitlab.com:nobody')).toBe(false);
expect(activateGitLabAuth('')).toBe(false);
expect(activateGitLabAuth(undefined)).toBe(false);
});
});
describe('clearGitLabAuth', () => {
test('removes the current account and deletes the file when empty', () => {
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
const removed = clearGitLabAuth();
expect(removed).toBe(true);
expect(getGitLabAuth()).toBeNull();
expect(fs.existsSync(GITLAB_AUTH_FILE)).toBe(false);
});
test('keeps other accounts and promotes the first remaining', () => {
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
setGitLabAuth({
accessToken: 'glpat-b',
baseUrl: 'https://gitlab.example.com',
user: { ...aliceUser, username: 'bob' },
});
clearGitLabAuth();
const accounts = getGitLabAuthAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].id).toBe('gitlab.com:alice');
expect(accounts[0].current).toBe(true);
});
});
+289
View File
@@ -0,0 +1,289 @@
import { getGitLabAuth, DEFAULT_GITLAB_BASE_URL } from './auth.js';
// Per-request timeout for every GitLab call. GitLab REST can hang under load
// (especially self-hosted instances); bounding each request lets the caller
// fail fast and serve cached/last-known state instead of holding a socket open.
const REQUEST_TIMEOUT_MS = 8000;
const timeoutFetch = (url, options = {}) => {
// Respect a caller-provided signal if present; otherwise attach our timeout.
if (options.signal) {
return fetch(url, options);
}
return fetch(url, { ...options, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
};
// Conditional-request cache for GET calls: GitLab serves 304 Not Modified for
// matching If-None-Match without consuming a fresh rate-limit token, so
// polling unchanged issues/MRs stays cheap. Keyed by token+URL so different
// identities never share responses. GitLab (unlike GitHub) does not attach
// `ETag` to every endpoint, but when it does we revalidate exactly like
// github/octokit.js.
const ETAG_CACHE_MAX_ENTRIES = 300;
const etagCache = new Map();
const rememberEtag = (key, etag, body, headers) => {
etagCache.delete(key);
etagCache.set(key, { etag, body, headers });
if (etagCache.size > ETAG_CACHE_MAX_ENTRIES) {
const oldest = etagCache.keys().next().value;
if (oldest !== undefined) {
etagCache.delete(oldest);
}
}
};
const createConditionalFetch = (token) => async (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase();
if (method !== 'GET') {
return timeoutFetch(url, options);
}
const cacheKey = `${token}\n${url}`;
const cached = etagCache.get(cacheKey);
const headers = { ...(options.headers || {}) };
if (cached?.etag) {
headers['if-none-match'] = cached.etag;
}
const response = await timeoutFetch(url, { ...options, headers });
if (response.status === 304 && cached) {
// Touch for LRU and replay the cached success response.
rememberEtag(cacheKey, cached.etag, cached.body, cached.headers);
return new Response(cached.body, { status: 200, headers: cached.headers });
}
if (response.ok) {
const etag = response.headers.get('etag');
if (etag) {
const body = await response.arrayBuffer();
rememberEtag(cacheKey, etag, body, response.headers);
return new Response(body, { status: response.status, headers: response.headers });
}
}
return response;
};
// ---- Own rate-limit cooldown (deliberately NOT shared with github/rate-limit.js) ----
const MAX_COOLDOWN_MS = 15 * 60 * 1000;
const DEFAULT_COOLDOWN_MS = 60 * 1000;
let rateLimitedUntil = 0;
const headerValue = (headers, name) => {
if (!headers) return undefined;
if (typeof headers.get === 'function') return headers.get(name);
return headers[name];
};
/**
* Record a cooldown after a GitLab 429. Accepts a fetch Response or any object
* carrying headers (response, `retry-after` seconds, or `RateLimit-Reset`
* Unix seconds).
*/
export function noteGitLabRateLimit(error) {
const headers = error?.headers;
let retryMs = null;
const retryAfter = headerValue(headers, 'retry-after');
if (retryAfter !== undefined && retryAfter !== null) {
const secs = Number(retryAfter);
if (Number.isFinite(secs) && secs > 0) retryMs = secs * 1000;
}
if (retryMs === null) {
const reset = headerValue(headers, 'ratelimit-reset');
if (reset !== undefined && reset !== null) {
const delta = Number(reset) * 1000 - Date.now();
if (Number.isFinite(delta) && delta > 0) retryMs = delta;
}
}
if (retryMs === null) retryMs = DEFAULT_COOLDOWN_MS;
retryMs = Math.min(retryMs, MAX_COOLDOWN_MS);
const until = Date.now() + retryMs;
if (until > rateLimitedUntil) {
rateLimitedUntil = until;
console.warn(`[gitlab] rate limited — pausing GitLab calls for ~${Math.round(retryMs / 1000)}s`);
}
}
export function isGitLabRateLimited() {
return Date.now() < rateLimitedUntil;
}
// ---- Response helpers ----
const joinApiUrl = (baseUrl, path) => {
const base = String(baseUrl || DEFAULT_GITLAB_BASE_URL).replace(/\/+$/, '');
const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : '';
return `${base}/api/v4${p}`;
};
const headersToObject = (headers) => {
const out = {};
if (!headers) return out;
if (typeof headers.forEach === 'function') {
headers.forEach((value, key) => {
out[key] = value;
});
} else if (typeof headers === 'object') {
for (const [key, value] of Object.entries(headers)) {
out[key] = value;
}
}
return out;
};
const parsePageInfo = (headers) => {
const get = (name) => {
const value = headerValue(headers, name);
return typeof value === 'string' ? value : '';
};
const pageHeader = get('x-page');
const nextPage = get('x-next-page');
const totalPages = get('x-total-pages');
const linkHeader = get('link');
const relNextMatch = linkHeader.match(/<([^>]+)>\s*;\s*rel="next"/);
const page = pageHeader ? Number(pageHeader) : null;
const next = nextPage ? Number(nextPage) : null;
const total = totalPages ? Number(totalPages) : null;
const hasMore = next != null ? next > 0 : Boolean(relNextMatch);
const parsed = { page, next, total, hasMore };
if (relNextMatch) {
parsed.nextUrl = relNextMatch[1];
}
return parsed;
};
const parseData = async (response) => {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return null;
}
};
const encodeProject = (pathWithNamespace) => encodeURIComponent(String(pathWithNamespace));
/**
* Create a raw-fetch GitLab REST v4 client. `request` never throws for HTTP
* error statuses — it returns `{ status, headers, data, page }` so callers can
* branch on status codes. On 429 it also sets `error: 'GitLab rate limited'`
* and records a module-level cooldown.
*/
export function createGitLabClient({ token, baseUrl }) {
const effectiveBaseUrl = normalizeBaseForClient(baseUrl);
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;
const callerSignal = options.signal;
if (isGitLabRateLimited()) {
return { status: 429, headers: {}, data: null, page: null, error: 'GitLab rate limited' };
}
let url = joinApiUrl(effectiveBaseUrl, 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) {
url += `${url.includes('?') ? '&' : '?'}${qs.toString()}`;
}
const headers = {
'PRIVATE-TOKEN': token,
accept: 'application/json',
};
const fetchOptions = {
method,
headers,
redirect: 'manual',
};
if (body !== undefined) {
headers['content-type'] = 'application/json';
fetchOptions.body = JSON.stringify(body);
}
if (callerSignal) {
fetchOptions.signal = callerSignal;
}
const conditionalFetch = createConditionalFetch(token);
let response = await conditionalFetch(url, fetchOptions);
// Follow a project-move redirect exactly once. GitLab redirects
// (301/302/308) come with a `Location` for the new project URL; a manual
// redirect keeps our PRIVATE-TOKEN header across the hop.
let redirects = 0;
while (
(response.status === 301 || response.status === 302 || response.status === 308)
&& headerValue(response.headers, 'location')
&& redirects < 1
) {
const location = headerValue(response.headers, 'location');
const nextUrl = new URL(location, url).toString();
response = await conditionalFetch(nextUrl, fetchOptions);
redirects += 1;
}
const result = {
status: response.status,
headers: headersToObject(response.headers),
data: await parseData(response),
page: parsePageInfo(response.headers),
};
if (response.status === 429) {
noteGitLabRateLimit(response);
result.error = 'GitLab rate limited';
}
return result;
};
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 }),
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 }),
branches: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/repository/branches`, { query: params }),
};
}
function normalizeBaseForClient(baseUrl) {
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
return DEFAULT_GITLAB_BASE_URL;
}
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 });
}
@@ -0,0 +1,251 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, afterEach, describe, expect, test, vi } from 'vitest';
// Isolate auth storage so getGitLabClientOrNull never reads a real account.
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitlab-client-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
const {
createGitLabClient,
getGitLabClientOrNull,
isGitLabRateLimited,
noteGitLabRateLimit,
} = await import('./client.js');
const jsonResponse = (data, { status = 200, headers = {} } = {}) =>
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } });
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('createGitLabClient request basics', () => {
test('calls {baseUrl}/api/v4{path} and sends PRIVATE-TOKEN', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 42, username: 'alice' }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-token', baseUrl: 'https://gitlab.com' });
const result = await client.user();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/user');
expect(options.headers['PRIVATE-TOKEN']).toBe('glpat-token');
expect(result).toMatchObject({ status: 200, data: { id: 42, username: 'alice' } });
expect(result.error).toBeUndefined();
});
test('joins a custom base URL without duplicating /api/v4', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.example.com/gitlab/' });
await client.issues('group/sub', { state: 'opened' });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.example.com/gitlab/api/v4/projects/group%2Fsub/issues?state=opened');
});
test('encodes project path namespaces exactly once', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.mergeRequest('a/b/c', 5);
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/a%2Fb%2Fc/merge_requests/5');
expect(String(url)).not.toContain('%252F');
});
test('serializes query params and omits empty ones', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.mergeRequests('g/p', { state: 'opened', per_page: 50, page: 2, search: '', sort: null });
const [url] = fetchMock.mock.calls[0];
const query = String(url).split('?')[1];
expect(query).toContain('state=opened');
expect(query).toContain('per_page=50');
expect(query).toContain('page=2');
expect(query).not.toContain('search');
expect(query).not.toContain('sort');
});
test('POST requests send a JSON body', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ ok: true }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.request('/some/action', { method: 'POST', body: { hello: 'world' } });
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(options.headers['content-type']).toBe('application/json');
expect(options.body).toBe(JSON.stringify({ hello: 'world' }));
});
test('surfaces error statuses without throwing', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ message: 'nope' }, { status: 401 }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.user();
expect(result.status).toBe(401);
expect(result.data).toEqual({ message: 'nope' });
});
test('attaches a caller signal when provided, else a timeout signal', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const controller = new AbortController();
await client.branches('g/p', { per_page: 100 });
await client.request('/user', { signal: controller.signal });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][1].signal).toEqual(expect.any(AbortSignal));
expect(fetchMock.mock.calls[1][1].signal).toBe(controller.signal);
});
});
describe('pagination', () => {
test('parses x-page/x-next-page headers into the page object', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], {
headers: { 'x-page': '2', 'x-next-page': '3', 'x-total-pages': '5' },
}));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.issues('g/p', { page: 2 });
expect(result.page).toEqual({ page: 2, next: 3, total: 5, hasMore: true });
});
test('falls back to the Link rel=next header when x-next-page is absent', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], {
headers: { link: '<https://gitlab.com/api/v4/projects/g%2Fp/issues?page=3>; rel="next", <...>; rel="last"' },
}));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.issues('g/p', { page: 2 });
expect(result.page.hasMore).toBe(true);
expect(result.page.nextUrl).toBe('https://gitlab.com/api/v4/projects/g%2Fp/issues?page=3');
});
test('reports hasMore=false on the last page', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], {
headers: { 'x-page': '5', 'x-next-page': '', 'x-total-pages': '5' },
}));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.issues('g/p', { page: 5 });
expect(result.page.hasMore).toBe(false);
});
});
describe('redirect handling', () => {
test('follows a project-move redirect exactly once, preserving auth headers', async () => {
const movedUrl = 'https://gitlab.com/api/v4/projects/new%2Fhome/issues';
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/projects/g%2Fp/issues')) {
return jsonResponse({}, { status: 301, headers: { location: '/api/v4/projects/new%2Fhome/issues' } });
}
if (String(url) === movedUrl) {
return jsonResponse([{ iid: 1 }]);
}
return jsonResponse({}, { status: 404 });
});
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' });
const result = await client.issues('g/p');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result.status).toBe(200);
expect(result.data).toEqual([{ iid: 1 }]);
const [, secondOptions] = fetchMock.mock.calls[1];
expect(secondOptions.headers['PRIVATE-TOKEN']).toBe('glpat-t');
});
});
describe('etag conditional cache', () => {
test('sends if-none-match and replays a 304 as a 200 with cached body', async () => {
const fetchMock = vi.fn(async (_url, options) => {
if (options.headers['if-none-match'] === '"v1"') {
return new Response(null, { status: 304 });
}
return jsonResponse({ ok: true }, { headers: { etag: '"v1"' } });
});
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' });
const first = await client.user();
expect(first.status).toBe(200);
expect(first.data).toEqual({ ok: true });
const second = await client.user();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][1].headers['if-none-match']).toBe('"v1"');
expect(second.status).toBe(200);
expect(second.data).toEqual({ ok: true });
});
test('does not cache POST responses', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ ok: true }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' });
await client.request('/thing', { method: 'POST', body: {} });
await client.request('/thing', { method: 'POST', body: {} });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('rate limiting', () => {
// NOTE: these tests run last in this file. The rate-limit cooldown is
// module-level and has no reset export, so earlier tests must not set one.
test('429 surfaces error and records a cooldown', async () => {
const fetchMock = vi.fn(async () => jsonResponse({}, { status: 429, headers: { 'retry-after': '5' } }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' });
const result = await client.user();
expect(result.status).toBe(429);
expect(result.error).toBe('GitLab rate limited');
expect(isGitLabRateLimited()).toBe(true);
});
test('short-circuits while the cooldown is active without calling fetch', async () => {
noteGitLabRateLimit({ headers: new Headers({ 'retry-after': '120' }) });
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' });
const gated = await client.issues('g/p');
expect(gated.status).toBe(429);
expect(gated.error).toBe('GitLab rate limited');
expect(fetchMock).not.toHaveBeenCalled();
});
test('parses Retry-After seconds into the cooldown', () => {
noteGitLabRateLimit({ headers: new Headers({ 'retry-after': '120' }) });
expect(isGitLabRateLimited()).toBe(true);
});
test('getGitLabClientOrNull returns null without stored auth', () => {
expect(getGitLabClientOrNull()).toBeNull();
});
});
+22
View File
@@ -0,0 +1,22 @@
export {
getGitLabAuth,
getGitLabAuthAccounts,
setGitLabAuth,
activateGitLabAuth,
clearGitLabAuth,
normalizeBaseUrl,
GITLAB_AUTH_FILE,
DEFAULT_GITLAB_BASE_URL,
} from './auth.js';
export {
createGitLabClient,
getGitLabClientOrNull,
isGitLabRateLimited,
noteGitLabRateLimit,
} from './client.js';
export {
parseGitLabRemoteUrl,
resolveGitLabRepoFromDirectory,
} from './repo.js';
+122
View File
@@ -0,0 +1,122 @@
import { getRemoteUrl } from '../git/index.js';
import { getGitLabAuthAccounts, normalizeBaseUrl } from './auth.js';
// When no explicit host allowlist is provided, accept gitlab.com or any host
// that matches the base URL of a stored GitLab account. Never github.com.
function acceptedHosts(knownHosts) {
const hosts = new Set();
if (knownHosts instanceof Set) {
for (const host of knownHosts) {
if (typeof host === 'string' && host.trim()) {
hosts.add(host.trim().toLowerCase());
}
}
return hosts;
}
if (Array.isArray(knownHosts)) {
for (const host of knownHosts) {
if (typeof host === 'string' && host.trim()) {
hosts.add(host.trim().toLowerCase());
}
}
return hosts;
}
hosts.add('gitlab.com');
for (const account of getGitLabAuthAccounts()) {
try {
const host = new URL(normalizeBaseUrl(account.baseUrl) || account.baseUrl).hostname.toLowerCase();
if (host) {
hosts.add(host);
}
} catch {
// ignore malformed stored account base URLs
}
}
return hosts;
}
/**
* Parse a GitLab remote URL into `{ namespace, project, host, baseUrl, url }`.
*
* Supports:
* - `git@HOST:NS/PROJ.git` (NS may be multi-segment, e.g. `a/b/c`)
* - `ssh://git@HOST/NS/PROJ.git`
* - `https://HOST/NS/PROJ(.git)`
*
* `knownHosts` (optional Set of hostnames) restricts which hosts are accepted.
* When omitted, `gitlab.com` and hosts from stored auth accounts are accepted.
* github.com is never accepted.
*/
export const parseGitLabRemoteUrl = (raw, knownHosts) => {
if (typeof raw !== 'string') {
return null;
}
const value = raw.trim();
if (!value) {
return null;
}
let host = '';
let path = '';
// git@HOST:NS/PROJ.git
const scpLike = value.match(/^git@([^:]+):(.+)$/);
if (scpLike) {
host = scpLike[1].toLowerCase();
path = scpLike[2];
} else if (value.startsWith('ssh://') || /^https?:\/\//.test(value)) {
try {
const url = new URL(value);
host = url.hostname.toLowerCase();
path = url.pathname.replace(/^\/+/, '');
} catch {
return null;
}
} else {
return null;
}
if (!host) {
return null;
}
if (host === 'github.com') {
return null;
}
if (!acceptedHosts(knownHosts).has(host)) {
return null;
}
path = path.replace(/\/+$/, '');
if (path.endsWith('.git')) {
path = path.slice(0, -4);
}
const segments = path.split('/').filter(Boolean);
if (segments.length < 2) {
return null;
}
const project = segments[segments.length - 1];
const namespace = segments.slice(0, -1).join('/');
if (!project || !namespace) {
return null;
}
return {
namespace,
project,
host,
baseUrl: `https://${host}`,
url: `https://${host}/${namespace}/${project}`,
};
};
export async function resolveGitLabRepoFromDirectory(directory, remoteName = 'origin') {
const remoteUrl = await getRemoteUrl(directory, remoteName).catch(() => null);
if (!remoteUrl) {
return { repo: null, remoteUrl: null };
}
return {
repo: parseGitLabRemoteUrl(remoteUrl),
remoteUrl,
};
}
+122
View File
@@ -0,0 +1,122 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, describe, expect, test, vi } from 'vitest';
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitlab-repo-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
vi.mock('../git/index.js', () => ({
getRemoteUrl: vi.fn(async () => null),
}));
const { parseGitLabRemoteUrl, resolveGitLabRepoFromDirectory } = await import('./repo.js');
const { getRemoteUrl } = await import('../git/index.js');
const { setGitLabAuth, clearGitLabAuth } = await import('./auth.js');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
clearGitLabAuth();
});
describe('parseGitLabRemoteUrl', () => {
test('parses scp-like git@host:ns/proj.git with a single segment', () => {
expect(parseGitLabRemoteUrl('git@gitlab.com:group/project.git')).toEqual({
namespace: 'group',
project: 'project',
host: 'gitlab.com',
baseUrl: 'https://gitlab.com',
url: 'https://gitlab.com/group/project',
});
});
test('parses multi-segment namespaces', () => {
expect(parseGitLabRemoteUrl('git@gitlab.com:a/b/c/proj.git')).toMatchObject({
namespace: 'a/b/c',
project: 'proj',
host: 'gitlab.com',
url: 'https://gitlab.com/a/b/c/proj',
});
});
test('parses ssh:// URLs', () => {
expect(parseGitLabRemoteUrl('ssh://git@gitlab.com/group/sub/proj.git')).toMatchObject({
namespace: 'group/sub',
project: 'proj',
host: 'gitlab.com',
});
});
test('parses https URLs with and without .git suffix', () => {
expect(parseGitLabRemoteUrl('https://gitlab.com/group/proj.git')).toMatchObject({
namespace: 'group',
project: 'proj',
host: 'gitlab.com',
});
expect(parseGitLabRemoteUrl('https://gitlab.com/group/proj')).toMatchObject({
namespace: 'group',
project: 'proj',
});
});
test('accepts self-hosted hosts via knownHosts', () => {
const result = parseGitLabRemoteUrl('git@git.example.com:team/app.git', new Set(['git.example.com']));
expect(result).toMatchObject({ namespace: 'team', project: 'app', host: 'git.example.com' });
});
test('rejects hosts not in knownHosts', () => {
expect(parseGitLabRemoteUrl('git@git.example.com:team/app.git', new Set(['other.example.com']))).toBeNull();
});
test('accepts hosts stored in auth accounts when knownHosts is omitted', () => {
setGitLabAuth({
accessToken: 'glpat-account-test',
baseUrl: 'https://git.internal.example',
user: { id: 1, username: 'worker' },
});
const result = parseGitLabRemoteUrl('git@git.internal.example:team/app.git');
expect(result).toMatchObject({ host: 'git.internal.example', project: 'app' });
});
test('never accepts github.com', () => {
expect(parseGitLabRemoteUrl('git@github.com:owner/repo.git')).toBeNull();
expect(parseGitLabRemoteUrl('https://github.com/owner/repo.git', new Set(['github.com']))).toBeNull();
});
test('returns null for malformed input', () => {
expect(parseGitLabRemoteUrl('')).toBeNull();
expect(parseGitLabRemoteUrl('not a remote')).toBeNull();
expect(parseGitLabRemoteUrl('git@gitlab.com:onlyone')).toBeNull();
expect(parseGitLabRemoteUrl(null)).toBeNull();
expect(parseGitLabRemoteUrl(undefined)).toBeNull();
});
});
describe('resolveGitLabRepoFromDirectory', () => {
test('resolves the repo from the origin remote', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitlab.com:acme/widgets.git');
const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/some/project');
expect(remoteUrl).toBe('git@gitlab.com:acme/widgets.git');
expect(repo).toMatchObject({ namespace: 'acme', project: 'widgets', host: 'gitlab.com' });
});
test('uses a custom remote name', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('https://gitlab.com/acme/widgets.git');
await resolveGitLabRepoFromDirectory('/some/project', 'upstream');
expect(getRemoteUrl).toHaveBeenCalledWith('/some/project', 'upstream');
});
test('returns null repo when the remote is not GitLab', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@github.com:owner/repo.git');
const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/some/project');
expect(repo).toBeNull();
expect(remoteUrl).toBe('git@github.com:owner/repo.git');
});
test('returns null when there is no remote URL', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue(null);
const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/some/project');
expect(repo).toBeNull();
expect(remoteUrl).toBeNull();
});
});
+697
View File
@@ -0,0 +1,697 @@
// Route-level budget for composite GitLab calls (lists, comments, MR context).
// The client bounds each individual request at 8s; this caps the whole route
// so a slow self-hosted instance cannot hold a response (and a client socket)
// open indefinitely. The client keeps its last-known state on error.
const ROUTE_TIMEOUT_MS = 15_000;
// MR diff pagination caps: never loop more than 10 pages / 3000 files.
const MR_DIFFS_MAX_PAGES = 10;
const MR_DIFFS_MAX_FILES = 3000;
function withTimeout(promise, timeoutMs, label) {
let timer;
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
error.code = 'ETIMEDOUT';
reject(error);
}, timeoutMs);
if (typeof timer.unref === 'function') timer.unref();
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
const asString = (value) => (typeof value === 'string' ? value.trim() : '');
const getRequestedProject = (req) => {
const namespace = asString(req.query?.namespace);
const project = asString(req.query?.project);
return namespace && project ? `${namespace}/${project}` : null;
};
const getRequiredNumber = (req) => {
const raw = typeof req.query?.number === 'string' ? req.query.number : '';
const number = Number(raw);
return Number.isFinite(number) && number > 0 ? number : null;
};
const mapGitLabUser = (data) => {
if (!data || typeof data !== 'object') {
return null;
}
return {
username: typeof data.username === 'string' ? data.username : null,
id: typeof data.id === 'number' ? data.id : null,
name: typeof data.name === 'string' ? data.name : null,
avatarUrl: typeof data.avatar_url === 'string' ? data.avatar_url : null,
webUrl: typeof data.web_url === 'string' ? data.web_url : null,
email: typeof data.email === 'string' ? data.email : null,
};
};
const mapAuthor = (author) => {
if (!author || typeof author !== 'object') {
return null;
}
return {
username: typeof author.username === 'string' ? author.username : null,
name: typeof author.name === 'string' ? author.name : null,
avatarUrl: typeof author.avatar_url === 'string' ? author.avatar_url : null,
id: typeof author.id === 'number' ? author.id : null,
};
};
const mapIssueSummary = (item) => ({
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
title: typeof item.title === 'string' ? item.title : '',
url: typeof item.web_url === 'string' ? item.web_url : '',
state: typeof item.state === 'string' ? item.state : 'opened',
author: mapAuthor(item.author) || {},
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
});
const mapMergeRequestSummary = (item) => ({
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
title: typeof item.title === 'string' ? item.title : '',
url: typeof item.web_url === 'string' ? item.web_url : '',
state: typeof item.state === 'string' ? item.state : 'opened',
draft: Boolean(item.draft) || Boolean(item.work_in_progress),
author: mapAuthor(item.author) || {},
sourceBranch: typeof item.source_branch === 'string' ? item.source_branch : '',
targetBranch: typeof item.target_branch === 'string' ? item.target_branch : '',
});
const mapComment = (note, webUrl) => ({
id: typeof note.id === 'number' ? note.id : Number(note.id),
url: webUrl ? `${webUrl}#note_${note.id}` : '',
body: typeof note.body === 'string' ? note.body : '',
createdAt: typeof note.created_at === 'string' ? note.created_at : undefined,
updatedAt: typeof note.updated_at === 'string' ? note.updated_at : undefined,
author: mapAuthor(note.author) || {},
});
const countDiffLines = (diffText) => {
if (typeof diffText !== 'string') {
return { additions: 0, deletions: 0, changes: 0 };
}
let additions = 0;
let deletions = 0;
let inHunk = false;
for (const line of diffText.split('\n')) {
if (line.startsWith('@@')) {
inHunk = true;
continue;
}
if (!inHunk) {
continue;
}
if (line.startsWith('+++') || line.startsWith('---')) {
continue;
}
if (line.startsWith('+')) {
additions += 1;
} else if (line.startsWith('-')) {
deletions += 1;
}
}
return { additions, deletions, changes: additions + deletions };
};
const mapDiffItem = (item) => {
const counts = countDiffLines(item.diff);
const status = item.new_file
? 'added'
: (item.deleted_file ? 'deleted' : (item.renamed_file ? 'renamed' : 'modified'));
return {
filename: typeof item.new_path === 'string' ? item.new_path : (typeof item.old_path === 'string' ? item.old_path : ''),
status,
additions: counts.additions,
deletions: counts.deletions,
changes: counts.changes,
patch: typeof item.diff === 'string' ? item.diff : '',
};
};
const repoRefFromProjectPath = (projectPath, baseUrl) => {
const segments = projectPath.split('/');
const project = segments[segments.length - 1] || '';
const namespace = segments.slice(0, -1).join('/');
let host = null;
let normalizedBaseUrl = null;
let url = null;
if (baseUrl) {
try {
const parsed = new URL(baseUrl);
host = parsed.hostname;
normalizedBaseUrl = parsed.href.replace(/\/+$/, '');
url = `${normalizedBaseUrl}/${projectPath}`;
} catch {
// fall back to unknown host
}
}
return { namespace, project, host, baseUrl: normalizedBaseUrl, url };
};
export function registerGitLabRoutes(app, options = {}) {
let gitlabLibraries = null;
const getGitLabLibraries = async () => {
if (!gitlabLibraries) {
gitlabLibraries = await import('./index.js');
}
return gitlabLibraries;
};
const getClient = async () => {
const { getGitLabClientOrNull } = await getGitLabLibraries();
return getGitLabClientOrNull();
};
// Resolve which GitLab project a request targets. A directory-local git
// remote is the primary source; `namespace`/`project` query params override
// it (needed for repos checked out from non-GitLab remotes).
const resolveProjectForRequest = async (directory, requestedProject) => {
if (requestedProject) {
return { projectPath: requestedProject, repo: null, fromDirectory: false };
}
if (!directory) {
return { projectPath: null, repo: null, fromDirectory: false };
}
const { resolveGitLabRepoFromDirectory } = await getGitLabLibraries();
const { repo } = await resolveGitLabRepoFromDirectory(directory);
if (!repo) {
return { projectPath: null, repo: null, fromDirectory: false };
}
return { projectPath: `${repo.namespace}/${repo.project}`, repo, fromDirectory: true };
};
// ================= GitLab Auth APIs =================
app.get('/api/gitlab/auth/status', async (_req, res) => {
try {
const { getGitLabAuth, getGitLabAuthAccounts, clearGitLabAuth, DEFAULT_GITLAB_BASE_URL } = await getGitLabLibraries();
const auth = getGitLabAuth();
const accounts = getGitLabAuthAccounts();
if (!auth?.accessToken) {
return res.json({ connected: false, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL });
}
const client = await getClient();
let user = null;
if (client) {
const resp = await client.user();
if (resp.status === 401 || resp.status === 403) {
clearGitLabAuth();
return res.json({ connected: false, accounts: getGitLabAuthAccounts(), defaultBaseUrl: DEFAULT_GITLAB_BASE_URL });
}
if (resp.status === 200 && resp.data) {
user = mapGitLabUser(resp.data);
}
}
return res.json({
connected: true,
...(user ? { user } : {}),
accounts,
defaultBaseUrl: DEFAULT_GITLAB_BASE_URL,
});
} catch (error) {
console.error('Failed to get GitLab auth status:', error);
return res.status(500).json({ error: error.message || 'Failed to get GitLab auth status' });
}
});
app.post('/api/gitlab/auth/connect', async (req, res) => {
try {
const accessToken = asString(req.body?.accessToken);
if (!accessToken) {
return res.status(400).json({ error: 'accessToken is required' });
}
const { normalizeBaseUrl, DEFAULT_GITLAB_BASE_URL, setGitLabAuth, getGitLabAuthAccounts } = await getGitLabLibraries();
const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || DEFAULT_GITLAB_BASE_URL;
const { createGitLabClient } = await getGitLabLibraries();
const client = createGitLabClient({ token: accessToken, baseUrl });
const resp = await client.user();
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 401 || resp.status === 403 || resp.status >= 400 || !resp.data?.username) {
return res.status(400).json({ error: 'Invalid GitLab access token' });
}
setGitLabAuth({ accessToken, baseUrl, user: resp.data });
return res.json({
connected: true,
user: mapGitLabUser(resp.data),
accounts: getGitLabAuthAccounts(),
defaultBaseUrl: DEFAULT_GITLAB_BASE_URL,
});
} catch (error) {
console.error('Failed to connect GitLab:', error);
return res.status(500).json({ error: error.message || 'Failed to connect GitLab' });
}
});
app.post('/api/gitlab/auth/activate', async (req, res) => {
try {
const accountId = asString(req.body?.accountId);
if (!accountId) {
return res.status(400).json({ error: 'accountId is required' });
}
const { activateGitLabAuth, getGitLabAuth, getGitLabAuthAccounts, DEFAULT_GITLAB_BASE_URL } = await getGitLabLibraries();
const activated = activateGitLabAuth(accountId);
if (!activated) {
return res.status(404).json({ error: 'GitLab account not found' });
}
const auth = getGitLabAuth();
const accounts = getGitLabAuthAccounts();
if (!auth?.accessToken) {
return res.json({ connected: false, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL });
}
let user = auth.username
? {
username: auth.username,
id: null,
name: auth.name,
avatarUrl: auth.avatarUrl,
webUrl: auth.webUrl,
email: auth.email,
}
: null;
const client = await getClient();
if (client) {
const resp = await client.user();
if (resp.status === 200 && resp.data) {
user = mapGitLabUser(resp.data);
}
}
return res.json({ connected: true, user, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL });
} catch (error) {
console.error('Failed to activate GitLab account:', error);
return res.status(500).json({ error: error.message || 'Failed to activate GitLab account' });
}
});
app.delete('/api/gitlab/auth', async (_req, res) => {
try {
const { clearGitLabAuth } = await getGitLabLibraries();
const removed = clearGitLabAuth();
return res.json({ removed });
} catch (error) {
console.error('Failed to disconnect GitLab:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect GitLab' });
}
});
app.get('/api/gitlab/me', async (_req, res) => {
try {
const { clearGitLabAuth } = await getGitLabLibraries();
const client = await getClient();
if (!client) {
return res.status(401).json({ error: 'GitLab not connected' });
}
const resp = await client.user();
if (resp.status === 401 || resp.status === 403) {
clearGitLabAuth();
return res.status(401).json({ error: 'GitLab token expired or revoked' });
}
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200 || !resp.data) {
return res.status(500).json({ error: 'Failed to fetch GitLab user' });
}
return res.json(mapGitLabUser(resp.data));
} catch (error) {
console.error('Failed to fetch GitLab user:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab user' });
}
});
// ================= GitLab Issue APIs =================
app.get('/api/gitlab/issues/list', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project is required' });
}
const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
const searchQuery = asString(req.query?.query);
const client = await getClient();
if (!client) {
return res.json({ connected: false, issues: [], page: effectivePage, hasMore: false });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, issues: [], page: effectivePage, hasMore: false });
}
const params = { state: 'opened', scope: 'all', per_page: 50, page: effectivePage };
if (searchQuery) {
params.search = searchQuery;
}
const resp = await withTimeout(client.issues(projectPath, params), ROUTE_TIMEOUT_MS, 'gitlab issues list');
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while listing issues' });
}
const issues = (Array.isArray(resp.data) ? resp.data : []).map(mapIssueSummary);
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
issues,
page: effectivePage,
hasMore: Boolean(resp.page?.hasMore),
});
} catch (error) {
console.error('Failed to list GitLab issues:', error);
return res.status(500).json({ error: error.message || 'Failed to list GitLab issues' });
}
});
app.get('/api/gitlab/issues/get', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, issue: null });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, issue: null });
}
const resp = await withTimeout(client.issue(projectPath, number), ROUTE_TIMEOUT_MS, 'gitlab issue get');
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status === 404) {
return res.status(404).json({ error: 'Issue not found' });
}
if (resp.status !== 200 || !resp.data) {
return res.status(502).json({ error: 'GitLab returned an error while fetching the issue' });
}
const item = resp.data;
const issue = {
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
title: typeof item.title === 'string' ? item.title : '',
url: typeof item.web_url === 'string' ? item.web_url : '',
state: typeof item.state === 'string' ? item.state : 'opened',
body: typeof item.description === 'string' ? item.description : '',
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
author: mapAuthor(item.author) || {},
assignees: Array.isArray(item.assignees)
? item.assignees.map(mapAuthor).filter(Boolean)
: [],
labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [],
};
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), issue });
} catch (error) {
console.error('Failed to fetch GitLab issue:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab issue' });
}
});
app.get('/api/gitlab/issues/comments', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, comments: [] });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, comments: [] });
}
// GitLab notes carry no web URL; resolve it from the issue so each note
// links as `{issue_web_url}#note_{id}`.
const issueResp = await withTimeout(client.issue(projectPath, number), ROUTE_TIMEOUT_MS, 'gitlab issue comments issue');
if (issueResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (issueResp.status === 404) {
return res.status(404).json({ error: 'Issue not found' });
}
if (issueResp.status !== 200 || !issueResp.data) {
return res.status(502).json({ error: 'GitLab returned an error while fetching the issue' });
}
const webUrl = typeof issueResp.data.web_url === 'string' ? issueResp.data.web_url : '';
const notesResp = await withTimeout(
client.issueNotes(projectPath, number, { per_page: 100 }),
ROUTE_TIMEOUT_MS,
'gitlab issue comments notes',
);
if (notesResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (notesResp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while fetching issue comments' });
}
const comments = (Array.isArray(notesResp.data) ? notesResp.data : [])
.filter((note) => !note.system)
.map((note) => mapComment(note, webUrl));
return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), comments });
} catch (error) {
console.error('Failed to fetch GitLab issue comments:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab issue comments' });
}
});
// ================= GitLab Merge Request APIs =================
app.get('/api/gitlab/mrs/list', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project is required' });
}
const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
const searchQuery = asString(req.query?.query);
const client = await getClient();
if (!client) {
return res.json({ connected: false, mrs: [], page: effectivePage, hasMore: false });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, mrs: [], page: effectivePage, hasMore: false });
}
const params = { state: 'opened', scope: 'all', per_page: 50, page: effectivePage };
if (searchQuery) {
params.search = searchQuery;
}
const resp = await withTimeout(client.mergeRequests(projectPath, params), ROUTE_TIMEOUT_MS, 'gitlab mrs list');
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200) {
return res.status(502).json({ error: 'GitLab returned an error while listing merge requests' });
}
const mrs = (Array.isArray(resp.data) ? resp.data : []).map(mapMergeRequestSummary);
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
mrs,
page: effectivePage,
hasMore: Boolean(resp.page?.hasMore),
});
} catch (error) {
console.error('Failed to list GitLab merge requests:', error);
return res.status(500).json({ error: error.message || 'Failed to list GitLab merge requests' });
}
});
app.get('/api/gitlab/mrs/context', async (req, res) => {
try {
const directory = asString(req.query?.directory);
const requestedProject = getRequestedProject(req);
const number = getRequiredNumber(req);
const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true';
if (!directory && !requestedProject) {
return res.status(400).json({ error: 'directory or namespace/project is required' });
}
if (!number) {
return res.status(400).json({ error: 'number is required' });
}
const client = await getClient();
if (!client) {
return res.json({ connected: false, mr: null, comments: [], files: [] });
}
const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject);
if (!projectPath) {
return res.json({ connected: true, repo: null, mr: null, comments: [], files: [] });
}
const mrResp = await withTimeout(client.mergeRequest(projectPath, number), ROUTE_TIMEOUT_MS, 'gitlab mr context');
if (mrResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (mrResp.status === 404) {
return res.status(404).json({ error: 'Merge request not found' });
}
if (mrResp.status !== 200 || !mrResp.data) {
return res.status(502).json({ error: 'GitLab returned an error while fetching the merge request' });
}
const item = mrResp.data;
const mr = {
number: typeof item.iid === 'number' ? item.iid : Number(item.iid),
title: typeof item.title === 'string' ? item.title : '',
url: typeof item.web_url === 'string' ? item.web_url : '',
state: typeof item.state === 'string' ? item.state : 'opened',
draft: Boolean(item.draft) || Boolean(item.work_in_progress),
body: typeof item.description === 'string' ? item.description : '',
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
author: mapAuthor(item.author) || {},
sourceBranch: typeof item.source_branch === 'string' ? item.source_branch : '',
targetBranch: typeof item.target_branch === 'string' ? item.target_branch : '',
headSha: typeof item.sha === 'string' ? item.sha : (typeof item.diff_head_sha === 'string' ? item.diff_head_sha : undefined),
};
const notesResp = await withTimeout(
client.request(`/projects/${encodeURIComponent(projectPath)}/merge_requests/${number}/notes`, { query: { per_page: 100 } }),
ROUTE_TIMEOUT_MS,
'gitlab mr context notes',
);
if (notesResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
const comments = (notesResp.status === 200 && Array.isArray(notesResp.data) ? notesResp.data : [])
.filter((note) => !note.system)
.map((note) => mapComment(note, mr.url));
// Diffs are paginated; loop pages but cap the total work.
const files = [];
for (let page = 1; page <= MR_DIFFS_MAX_PAGES; page += 1) {
const diffsResp = await client.mergeRequestDiffs(projectPath, number, { per_page: 100, page });
if (diffsResp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (diffsResp.status !== 200 || !Array.isArray(diffsResp.data)) {
break;
}
const chunk = diffsResp.data;
for (const diffItem of chunk) {
files.push(mapDiffItem(diffItem));
if (files.length >= MR_DIFFS_MAX_FILES) {
break;
}
}
if (files.length >= MR_DIFFS_MAX_FILES) {
break;
}
if (chunk.length < 100 || !diffsResp.page?.hasMore) {
break;
}
}
let diff;
if (includeDiff) {
const patches = files.map((file) => file.patch || '').filter(Boolean);
diff = patches.length > 0 ? patches.join('\n') : undefined;
}
return res.json({
connected: true,
repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl),
mr,
comments,
files,
...(diff ? { diff } : {}),
});
} catch (error) {
console.error('Failed to load GitLab merge request context:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitLab merge request context' });
}
});
// ================= GitLab Repo APIs =================
app.get('/api/gitlab/repo/branches', async (req, res) => {
try {
const namespace = asString(req.query?.namespace);
const project = asString(req.query?.project);
if (!namespace || !project) {
return res.status(400).json({ error: 'namespace and project are required' });
}
const client = await getClient();
if (!client) {
return res.json({ branches: [] });
}
const branches = [];
let page = 1;
while (page <= 10) {
const resp = await client.branches(`${namespace}/${project}`, { per_page: 100, page });
if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' });
}
if (resp.status !== 200 || !Array.isArray(resp.data)) {
break;
}
const chunk = resp.data;
for (const branch of chunk) {
if (typeof branch?.name === 'string') {
branches.push(branch.name);
}
}
if (chunk.length < 100 || !resp.page?.hasMore) {
break;
}
page += 1;
}
return res.json({ branches });
} catch (error) {
console.error('Failed to fetch GitLab repo branches:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitLab repo branches' });
}
});
}
@@ -0,0 +1,499 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitlab-routes-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
// Resolve a fake git remote so directory-based repo resolution finds a GitLab
// repo without touching the real filesystem/git.
vi.mock('../git/index.js', () => ({
getRemoteUrl: vi.fn(async () => 'git@gitlab.com:group/sub.git'),
}));
const { registerGitLabRoutes } = await import('./routes.js');
const { setGitLabAuth, clearGitLabAuth, getGitLabAuth, getGitLabAuthAccounts, GITLAB_AUTH_FILE } = await import('./index.js');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
// clearGitLabAuth only drops the *current* account (multi-account model), so a
// full wipe is done by removing the auth file between tests.
const resetAuthFile = () => {
if (fs.existsSync(GITLAB_AUTH_FILE)) {
fs.unlinkSync(GITLAB_AUTH_FILE);
}
};
const aliceUser = {
id: 42,
username: 'alice',
name: 'Alice Example',
state: 'active',
avatar_url: 'https://gitlab.com/uploads/-/avatar.png',
web_url: 'https://gitlab.com/alice',
email: 'alice@example.com',
};
const jsonResponse = (data, { status = 200, headers = {} } = {}) =>
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } });
const scriptedFetch = (handlers) => {
const fetchMock = vi.fn(async (url, options) => {
const str = String(url);
for (const handler of handlers) {
const result = handler(str, options);
if (result !== null && result !== undefined) {
return result;
}
}
return jsonResponse({ message: `unhandled request: ${str}` }, { status: 500 });
});
globalThis.fetch = fetchMock;
return fetchMock;
};
const matches = (pattern) => (url) => pattern.test(url);
const createApp = () => {
const app = express();
app.use(express.json());
registerGitLabRoutes(app);
return app;
};
describe('GitLab auth routes', () => {
beforeEach(() => {
resetAuthFile();
vi.restoreAllMocks();
delete globalThis.fetch;
});
test('auth/status returns disconnected with the default base URL', async () => {
const app = createApp();
const response = await request(app).get('/api/gitlab/auth/status');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: false,
accounts: [],
defaultBaseUrl: 'https://gitlab.com',
});
});
test('auth/connect validates the token, stores the account, and reports connected', async () => {
scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app)
.post('/api/gitlab/auth/connect')
.send({ accessToken: 'glpat-valid', baseUrl: 'https://gitlab.com' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
user: { username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitlab.com/uploads/-/avatar.png', email: 'alice@example.com' },
defaultBaseUrl: 'https://gitlab.com',
});
expect(response.body.accounts).toEqual([
{ id: 'gitlab.com:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitlab.com/uploads/-/avatar.png', webUrl: 'https://gitlab.com/alice' }, baseUrl: 'https://gitlab.com', current: true },
]);
expect(getGitLabAuth()?.accessToken).toBe('glpat-valid');
});
test('auth/connect rejects an invalid token with 400', async () => {
scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse({ message: '401 Unauthorized' }, { status: 401 }) : null)]);
const app = createApp();
const response = await request(app)
.post('/api/gitlab/auth/connect')
.send({ accessToken: 'glpat-invalid' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Invalid GitLab access token' });
expect(getGitLabAuth()).toBeNull();
});
test('auth/connect requires an access token', async () => {
const app = createApp();
const response = await request(app).post('/api/gitlab/auth/connect').send({});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'accessToken is required' });
});
test('auth/connect normalizes a scheme-less base URL', async () => {
const fetchMock = scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
await request(app)
.post('/api/gitlab/auth/connect')
.send({ accessToken: 'glpat-valid', baseUrl: 'gitlab.example.com' });
expect(fetchMock.mock.calls[0][0]).toBe('https://gitlab.example.com/api/v4/user');
});
test('auth/status reports connected with the live user', async () => {
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitlab/auth/status');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
user: { username: 'alice', id: 42 },
defaultBaseUrl: 'https://gitlab.com',
});
expect(response.body.accounts).toEqual([
{ id: 'gitlab.com:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitlab.com/uploads/-/avatar.png', webUrl: 'https://gitlab.com/alice' }, baseUrl: 'https://gitlab.com', current: true },
]);
});
test('auth/activate returns 404 for an unknown account', async () => {
const app = createApp();
const response = await request(app).post('/api/gitlab/auth/activate').send({ accountId: 'gitlab.com:nobody' });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'GitLab account not found' });
});
test('auth/activate switches the current account', async () => {
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
setGitLabAuth({
accessToken: 'glpat-b',
baseUrl: 'https://gitlab.example.com',
user: { ...aliceUser, username: 'bob', name: 'Bob' },
});
scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).post('/api/gitlab/auth/activate').send({ accountId: 'gitlab.com:alice' });
expect(response.status).toBe(200);
expect(response.body.connected).toBe(true);
expect(response.body.accounts.find((a) => a.id === 'gitlab.com:alice')?.current).toBe(true);
expect(getGitLabAuth()?.accountId).toBe('gitlab.com:alice');
});
test('DELETE /api/gitlab/auth clears the account', async () => {
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
const app = createApp();
const response = await request(app).delete('/api/gitlab/auth');
expect(response.status).toBe(200);
expect(response.body).toEqual({ removed: true });
expect(getGitLabAuth()).toBeNull();
});
test('me returns 401 when not connected', async () => {
const app = createApp();
const response = await request(app).get('/api/gitlab/me');
expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'GitLab not connected' });
});
test('me returns the connected user', async () => {
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitlab/me');
expect(response.status).toBe(200);
expect(response.body).toEqual({
username: 'alice',
id: 42,
name: 'Alice Example',
avatarUrl: 'https://gitlab.com/uploads/-/avatar.png',
webUrl: 'https://gitlab.com/alice',
email: 'alice@example.com',
});
});
});
describe('GitLab data routes', () => {
beforeEach(() => {
resetAuthFile();
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
vi.restoreAllMocks();
delete globalThis.fetch;
});
test('issues/list returns mapped issues with pagination info', async () => {
scriptedFetch([
(url) => (matches(/\/api\/v4\/projects\/group%2Fsub\/issues\?/)(url)
? jsonResponse(
[
{
iid: 3,
title: 'Fix the widget',
web_url: 'https://gitlab.com/group/sub/-/issues/3',
state: 'opened',
author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' },
labels: ['bug', 'priority:high'],
},
],
{ headers: { 'x-page': '1', 'x-next-page': '2', 'x-total-pages': '2' } },
)
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/issues/list?directory=%2Ftmp%2Fwork&page=1');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { namespace: 'group', project: 'sub', host: 'gitlab.com', url: 'https://gitlab.com/group/sub' },
issues: [
{
number: 3,
title: 'Fix the widget',
url: 'https://gitlab.com/group/sub/-/issues/3',
state: 'opened',
author: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitlab.com/alice.png' },
labels: ['bug', 'priority:high'],
},
],
page: 1,
hasMore: true,
});
});
test('issues/list sends the search query and opened state filter', async () => {
const fetchMock = scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse([]) : null)]);
const app = createApp();
await request(app).get('/api/gitlab/issues/list?directory=%2Ftmp%2Fwork&query=login');
const requestedUrl = String(fetchMock.mock.calls[0][0]);
expect(requestedUrl).toContain('state=opened');
expect(requestedUrl).toContain('search=login');
expect(requestedUrl).toContain('per_page=50');
});
test('issues/list reports connected:false when not authenticated', async () => {
clearGitLabAuth();
const app = createApp();
const response = await request(app).get('/api/gitlab/issues/list?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ connected: false, issues: [] });
});
test('issues/get returns a full issue', async () => {
scriptedFetch([
(url) => (matches(/\/issues\/7$/)(url)
? jsonResponse({
iid: 7,
title: 'Broken import',
web_url: 'https://gitlab.com/group/sub/-/issues/7',
state: 'opened',
description: 'It breaks at startup',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-02T10:00:00Z',
author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' },
assignees: [{ id: 43, username: 'bob', name: 'Bob', avatar_url: 'https://gitlab.com/bob.png' }],
labels: ['bug'],
})
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/issues/get?directory=%2Ftmp%2Fwork&number=7');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
issue: {
number: 7,
title: 'Broken import',
state: 'opened',
body: 'It breaks at startup',
createdAt: '2026-01-01T10:00:00Z',
updatedAt: '2026-01-02T10:00:00Z',
author: { username: 'alice', name: 'Alice Example' },
assignees: [{ username: 'bob', name: 'Bob' }],
labels: ['bug'],
},
});
});
test('issues/get returns 404 for a missing issue', async () => {
scriptedFetch([(url) => (matches(/\/issues\/999$/)(url) ? jsonResponse({ message: 'Not found' }, { status: 404 }) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitlab/issues/get?directory=%2Ftmp%2Fwork&number=999');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Issue not found' });
});
test('issues/comments skips system notes and links notes to the issue URL', async () => {
scriptedFetch([
(url) => (matches(/\/issues\/7$/)(url)
? jsonResponse({ iid: 7, web_url: 'https://gitlab.com/group/sub/-/issues/7' })
: null),
(url) => (matches(/\/issues\/7\/notes\?/)(url)
? jsonResponse([
{ id: 1, body: 'system note', system: true, author: { id: 1, username: 'system' }, created_at: '2026-01-01T00:00:00Z' },
{ id: 2, body: 'Looks good to me', system: false, author: { id: 42, username: 'alice', name: 'Alice Example' }, created_at: '2026-01-01T01:00:00Z' },
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/issues/comments?directory=%2Ftmp%2Fwork&number=7');
expect(response.status).toBe(200);
expect(response.body.comments).toEqual([
{
id: 2,
url: 'https://gitlab.com/group/sub/-/issues/7#note_2',
body: 'Looks good to me',
createdAt: '2026-01-01T01:00:00Z',
updatedAt: undefined,
author: { username: 'alice', name: 'Alice Example', avatarUrl: null, id: 42 },
},
]);
});
test('mrs/list returns mapped merge requests', async () => {
scriptedFetch([
(url) => (matches(/\/merge_requests\?/)(url)
? jsonResponse([
{
iid: 9,
title: 'Add the API',
web_url: 'https://gitlab.com/group/sub/-/merge_requests/9',
state: 'opened',
draft: false,
work_in_progress: false,
author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' },
source_branch: 'feat/api',
target_branch: 'main',
},
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/mrs/list?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
mrs: [
{
number: 9,
title: 'Add the API',
state: 'opened',
draft: false,
author: { username: 'alice', name: 'Alice Example' },
sourceBranch: 'feat/api',
targetBranch: 'main',
},
],
page: 1,
hasMore: false,
});
});
test('mrs/context returns mr, comments, files, and a concatenated diff', async () => {
scriptedFetch([
(url) => (matches(/\/merge_requests\/9$/)(url)
? jsonResponse({
iid: 9,
title: 'Add the API',
web_url: 'https://gitlab.com/group/sub/-/merge_requests/9',
state: 'opened',
draft: false,
description: 'Adds the public API',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-02T10:00:00Z',
author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' },
source_branch: 'feat/api',
target_branch: 'main',
sha: 'abc123def456',
})
: null),
(url) => (matches(/\/merge_requests\/9\/notes\?/)(url)
? jsonResponse([
{ id: 11, body: 'LGTM', system: false, author: { id: 43, username: 'bob', name: 'Bob' }, created_at: '2026-01-02T11:00:00Z' },
])
: null),
(url) => (matches(/\/merge_requests\/9\/diffs\?/)(url)
? jsonResponse([
{
old_path: 'src/a.ts',
new_path: 'src/a.ts',
new_file: false,
renamed_file: false,
deleted_file: false,
diff: '--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1,3 +1,4 @@\n import x\n+export const added = 1\n-export const old = 2\n context line\n',
},
{
old_path: 'src/new.ts',
new_path: 'src/new.ts',
new_file: true,
renamed_file: false,
deleted_file: false,
diff: '--- a/src/new.ts\n+++ b/src/new.ts\n@@ -0,0 +1,2 @@\n+line one\n+line two\n',
},
])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/mrs/context?directory=%2Ftmp%2Fwork&number=9&diff=1');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
mr: {
number: 9,
title: 'Add the API',
state: 'opened',
draft: false,
body: 'Adds the public API',
sourceBranch: 'feat/api',
targetBranch: 'main',
headSha: 'abc123def456',
},
comments: [{ id: 11, body: 'LGTM', author: { username: 'bob', name: 'Bob' } }],
files: [
{ filename: 'src/a.ts', status: 'modified', additions: 1, deletions: 1, changes: 2 },
{ filename: 'src/new.ts', status: 'added', additions: 2, deletions: 0, changes: 2 },
],
});
// diff field concatenates the two patches
expect(response.body.diff).toContain('export const added = 1');
expect(response.body.diff).toContain('line two');
});
test('repo/branches returns branch names', async () => {
scriptedFetch([
(url) => (matches(/\/repository\/branches\?/)(url)
? jsonResponse([{ name: 'main' }, { name: 'feat/api' }])
: null),
]);
const app = createApp();
const response = await request(app).get('/api/gitlab/repo/branches?namespace=group&project=sub');
expect(response.status).toBe(200);
expect(response.body).toEqual({ branches: ['main', 'feat/api'] });
});
test('repo/branches requires namespace and project', async () => {
const app = createApp();
const response = await request(app).get('/api/gitlab/repo/branches?namespace=group');
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'namespace and project are required' });
});
test('data routes surface a 503 when GitLab rate limits', async () => {
scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]);
const app = createApp();
const response = await request(app).get('/api/gitlab/issues/list?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(503);
expect(response.body).toEqual({ error: 'GitLab rate limited' });
});
});
@@ -4,6 +4,7 @@ import { registerSmallModelRoutes } from '../small-model/routes.js';
import { registerWalkthroughRoutes } from '../walkthrough/routes.js';
import { registerSessionGoalRoutes } from '../session-goal/routes.js';
import { registerGitHubRoutes } from '../github/routes.js';
import { registerGitLabRoutes } from '../gitlab/routes.js';
import { registerGitRoutes } from '../git/routes.js';
import { registerDevServerRoutes } from '../dev-servers/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
@@ -297,6 +298,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
registerWalkthroughRoutes(app, { getWalkthroughService });
registerSessionGoalRoutes(app);
registerGitHubRoutes(app);
registerGitLabRoutes(app);
registerGitRoutes(app);
registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts });
registerMagicPromptRoutes(app, {