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:
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user