From d798b3f479613afabf7254a7f4aa13dfa50a9ea0 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Tue, 11 Aug 2026 21:07:02 +0000 Subject: [PATCH] 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 --- .../web/server/lib/gitlab/DOCUMENTATION.md | 127 ++++ packages/web/server/lib/gitlab/auth.js | 317 ++++++++ packages/web/server/lib/gitlab/auth.test.js | 161 ++++ packages/web/server/lib/gitlab/client.js | 289 ++++++++ packages/web/server/lib/gitlab/client.test.js | 251 +++++++ packages/web/server/lib/gitlab/index.js | 22 + packages/web/server/lib/gitlab/repo.js | 122 +++ packages/web/server/lib/gitlab/repo.test.js | 122 +++ packages/web/server/lib/gitlab/routes.js | 697 ++++++++++++++++++ packages/web/server/lib/gitlab/routes.test.js | 499 +++++++++++++ .../lib/opencode/feature-routes-runtime.js | 2 + 11 files changed, 2609 insertions(+) create mode 100644 packages/web/server/lib/gitlab/DOCUMENTATION.md create mode 100644 packages/web/server/lib/gitlab/auth.js create mode 100644 packages/web/server/lib/gitlab/auth.test.js create mode 100644 packages/web/server/lib/gitlab/client.js create mode 100644 packages/web/server/lib/gitlab/client.test.js create mode 100644 packages/web/server/lib/gitlab/index.js create mode 100644 packages/web/server/lib/gitlab/repo.js create mode 100644 packages/web/server/lib/gitlab/repo.test.js create mode 100644 packages/web/server/lib/gitlab/routes.js create mode 100644 packages/web/server/lib/gitlab/routes.test.js diff --git a/packages/web/server/lib/gitlab/DOCUMENTATION.md b/packages/web/server/lib/gitlab/DOCUMENTATION.md new file mode 100644 index 00000000..91dc0d08 --- /dev/null +++ b/packages/web/server/lib/gitlab/DOCUMENTATION.md @@ -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:` when the username is missing. +- Auth header on every request: `PRIVATE-TOKEN: `. + +## 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=`. +- 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=`. +- 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. diff --git a/packages/web/server/lib/gitlab/auth.js b/packages/web/server/lib/gitlab/auth.js new file mode 100644 index 00000000..e9a84408 --- /dev/null +++ b/packages/web/server/lib/gitlab/auth.js @@ -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; diff --git a/packages/web/server/lib/gitlab/auth.test.js b/packages/web/server/lib/gitlab/auth.test.js new file mode 100644 index 00000000..3f6af618 --- /dev/null +++ b/packages/web/server/lib/gitlab/auth.test.js @@ -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); + }); +}); diff --git a/packages/web/server/lib/gitlab/client.js b/packages/web/server/lib/gitlab/client.js new file mode 100644 index 00000000..66567fd5 --- /dev/null +++ b/packages/web/server/lib/gitlab/client.js @@ -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 }); +} diff --git a/packages/web/server/lib/gitlab/client.test.js b/packages/web/server/lib/gitlab/client.test.js new file mode 100644 index 00000000..e4eace7e --- /dev/null +++ b/packages/web/server/lib/gitlab/client.test.js @@ -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: '; 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(); + }); +}); diff --git a/packages/web/server/lib/gitlab/index.js b/packages/web/server/lib/gitlab/index.js new file mode 100644 index 00000000..95b2deb2 --- /dev/null +++ b/packages/web/server/lib/gitlab/index.js @@ -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'; diff --git a/packages/web/server/lib/gitlab/repo.js b/packages/web/server/lib/gitlab/repo.js new file mode 100644 index 00000000..c83b691a --- /dev/null +++ b/packages/web/server/lib/gitlab/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, + }; +} diff --git a/packages/web/server/lib/gitlab/repo.test.js b/packages/web/server/lib/gitlab/repo.test.js new file mode 100644 index 00000000..0695a8a3 --- /dev/null +++ b/packages/web/server/lib/gitlab/repo.test.js @@ -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(); + }); +}); diff --git a/packages/web/server/lib/gitlab/routes.js b/packages/web/server/lib/gitlab/routes.js new file mode 100644 index 00000000..d90f679e --- /dev/null +++ b/packages/web/server/lib/gitlab/routes.js @@ -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' }); + } + }); +} diff --git a/packages/web/server/lib/gitlab/routes.test.js b/packages/web/server/lib/gitlab/routes.test.js new file mode 100644 index 00000000..54b70b0c --- /dev/null +++ b/packages/web/server/lib/gitlab/routes.test.js @@ -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' }); + }); +}); diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 90c15f1b..8c93fcd1 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -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, {