merge: resolve v1.21.1 conflicts with custom

Resolve all 10 upstream v1.21.1 merge conflicts into custom, keeping
custom's GitLab/Gitea forge customizations layered on upstream while
lifting upstream improvements. Restored tr.ts i18n key parity with the
custom en.ts dictionary (471 custom forge keys added, en fallback) so the
upstream-introduced locale stays in parity.

Also stage bun.lock version alignment (1.21.0 -> 1.21.1) that matches the
staged package.json bump.

Verification: ui + web type-check pass; ui/web tests pass except
pre-existing failures on the custom baseline (forge.test.ts, session-actions,
issue-1637-2270, routes.test.js fs-stat directory scope).
This commit is contained in:
2026-08-30 06:56:43 -04:00
200 changed files with 44728 additions and 370 deletions
+8
View File
@@ -800,6 +800,14 @@ export const registerFsRoutes = (app, dependencies) => {
openchamberUserConfigRoot,
});
if (!resolved.ok) {
// An `optional` stat is a graceful probe — the caller only wants to
// know whether a path resolves to a readable file, and treats both
// "missing" and "outside the active workspace" as absent. Without this,
// boot-time probes of config/backup paths outside the workspace 400 and
// surface as console errors for a check that was never mandatory.
if (optional && resolved.error === 'Path is outside of active workspace') {
return res.json({ path: filePath, exists: false });
}
if (req.query?.allowOutsideWorkspace === 'true') {
console.warn(`Rejected outside-workspace stat: ${resolved.error}`);
}
+66
View File
@@ -286,6 +286,32 @@ const callRead = async (handler, query) => {
return res;
};
const registerStat = (fsPromises) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
...fsPromises,
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/repo' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('GET', '/api/fs/stat');
};
const callStat = async (handler, query) => {
const res = createMockResponse();
await handler({ query }, res);
return res;
};
const callRaw = async (handler, query) => {
const res = createMockResponse();
await handler({ query }, res);
@@ -770,6 +796,46 @@ describe('fs read', () => {
warn.mockRestore();
});
});
describe('fs stat', () => {
it('returns exists:false for an outside-workspace path when optional', async () => {
const fsPromises = {
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
};
const handler = registerStat(fsPromises);
const res = await callStat(handler, { path: '/outside/plan.md', optional: 'true' });
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ path: '/outside/plan.md', exists: false });
expect(fsPromises.stat).not.toHaveBeenCalled();
});
it('still rejects an outside-workspace path when not optional', async () => {
const fsPromises = {
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
};
const handler = registerStat(fsPromises);
const res = await callStat(handler, { path: '/outside/plan.md' });
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
expect(fsPromises.stat).not.toHaveBeenCalled();
});
it('returns stat data for an in-workspace file', async () => {
const fsPromises = {
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
};
const handler = registerStat(fsPromises);
const res = await callStat(handler, { path: '/repo/file.txt' });
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ path: '/repo/file.txt', isFile: true, size: 3 });
});
});
describe('fs reveal', () => {
it.each([
['linux', 'xdg-open', ['/repo']],
@@ -0,0 +1,92 @@
# Git Providers Configuration Module
## Purpose
- This module owns the per-provider git hosting configuration (`gitProviders` in the user settings file): API base URLs and provider-detection hostnames for GitHub, GitLab, and Gitea.
- It is the single source of truth for the effective provider defaults consumed by `packages/web/server/lib/{github,gitlab,gitea}` and is validated end-to-end through the settings GET/PUT routes (the `gitProviders` key round-trips via `sanitizeSettingsUpdate` in `packages/web/server/lib/opencode/settings-helpers.js`).
- Per-project API base URL overrides (`gitProviders` in `projects/<projectId>.json`) extend the global settings; a project override wins per provider over the global value.
## Entrypoints
- `packages/web/server/lib/git-providers/config.js`: global settings helpers, exporting the helpers directly.
- `packages/web/server/lib/git-providers/project-config.js`: per-project git provider API base URL overrides (`gitProviders` in `projects/<projectId>.json`), including directory→projectId resolution.
- `packages/web/server/lib/git-providers/routes.js`: `GET/PUT /api/projects/:projectId/git-providers` API routes (wired via `registerGitProviderRoutes` in `packages/web/server/lib/opencode/feature-routes-runtime.js`).
## Public exports — `config.js`
- `GIT_PROVIDER_DEFAULTS`: `{ github: 'https://api.github.com', gitlab: 'https://gitlab.com', gitea: 'https://codeberg.org' }`. Built-in defaults are **not persisted**; they are applied at read time by getters.
- `GIT_PROVIDER_DEFAULT_DETECT_URLS`: `{ github: ['github.com'], gitlab: ['gitlab.com'], gitea: ['codeberg.org'] }`. Built-in detection hostnames; remotes on these hosts classify as the provider with no configuration (mirrors the client-side built-ins in `packages/ui/src/lib/gitProvider.ts`).
- `normalizeBaseUrl(raw)`: normalize an API base URL (add `https://` when the scheme is missing, strip trailing slashes, preserve subpaths like `/gitlab`), `null` for empty/unparseable input.
- `normalizeDetectionHost(raw)`: extract the bare lowercase hostname from any git remote/URL form (`https://`, `ssh://`, scp-like `git@host:path`, IPv6); mirrors `packages/ui/src/lib/gitHost.ts` `parseGitHost`.
- `sanitizeGitProviders(payload)`: validate/normalize the `gitProviders` shape — only `github|gitlab|gitea` keys survive; `apiBaseUrl` via `normalizeBaseUrl`, `detectUrls` deduped bare hostnames; empty/absent values dropped; returns `undefined` when nothing valid remains.
- `readGitProvidersConfig()`: read the `gitProviders` section from `settings.json` (`OPENCHAMBER_DATA_DIR` env override, else `~/.config/openchamber`); never throws, returns `{}` on missing/invalid data.
- `getProviderApiBaseUrl(provider)`: configured value -> `GIT_PROVIDER_DEFAULTS[provider]` -> `null`.
- `getProviderDetectUrls(provider)`: effective detection hostnames — built-in default hosts plus configured `detectUrls`, deduped (the built-ins always apply).
- `githubWebOriginFromApiBase(apiBase)`: GitHub web origin from an API base — `https://api.github.com` -> `https://github.com`; Enterprise `https://host/api[/v3]` -> `https://host` (trailing `/api`/`/api/v3` stripped, subpath prefixes kept); otherwise the URL origin; never throws, falls back to `https://github.com`.
## Public exports — `project-config.js`
- `OPENCHAMBER_PROJECTS_DIR`: `path.join(OPENCHAMBER_DATA_DIR, 'projects')` (same `OPENCHAMBER_DATA_DIR` env logic as `config.js`).
- `sanitizeProjectGitProviders(payload)`: same provider allowlist/`normalizeBaseUrl` rules as `sanitizeGitProviders`, but the per-project shape only carries `apiBaseUrl` (`detectUrls` tolerated and stripped) plus an optional forced `provider` (`github|gitlab|gitea`, normalized lowercase; unknown values dropped); `undefined` when nothing valid remains.
- `readProjectJson(projectId)`: raw JSON object from `projects/<projectId>.json`; `{}` on missing/malformed file, `null` for an invalid projectId; never throws.
- `getProjectGitProviders(projectId)`: effective per-project overrides (`{}` when unset or invalid projectId).
- `resolveProjectIdFromDirectory(directory)`: projectId for a directory — worktree-aware: the directory is first resolved to its main repo root via `git rev-parse --git-common-dir` (handles linked worktrees created outside the project root, and a project rooted at the filesystem `/`), then the longest matching project path from the settings.json `projects` list that equals it or is a path-prefix wins; when git is unavailable or the directory is not a git repo, the directory's own exact/containment match applies; fallback `createProjectIdFromPath(directory)`; `null` for empty input. Results are cached per-directory for 60s (TTL cache, negative results included) so forge hot paths don't exec git / re-read settings.json per request. `_clearResolveProjectIdCache()` is a test-only hook to drop the cache.
- `getProjectProviderApiBaseUrl(provider, projectId)`: per-project `apiBaseUrl` override or `null`.
- `getProjectProvider(projectId)`: the project's forced provider (`github|gitlab|gitea`) or `null` when auto-detected.
- `getProjectProviderFromDirectory(directory)`: forced provider for a directory's owning project (via `resolveProjectIdFromDirectory``getProjectProvider`), or `null`.
- `getEffectiveProviderApiBaseUrl(provider, directory)`: project override -> `getProviderApiBaseUrl(provider)` (global -> built-in default); `null` only when nothing resolves.
- `saveProjectGitProviders(projectId, payload)`: persist the per-project overrides, preserving all other project JSON keys (atomic tmp-file + rename write); returns the saved `gitProviders` object (or `{}`); throws for an invalid projectId.
## Routes
- `GET /api/projects/:projectId/git-providers``{ gitProviders: { github?: { apiBaseUrl }, ... } }`.
- `PUT /api/projects/:projectId/git-providers` with body `{ gitProviders }``{ gitProviders }` (saved); `400` on missing projectId or invalid body shape.
## Settings shape
`~/.config/openchamber/settings.json`:
```json
"gitProviders": {
"github": { "apiBaseUrl": "https://github.example.com/api/v3", "detectUrls": ["github.example.com"] },
"gitlab": { "apiBaseUrl": "https://gitlab.example.com", "detectUrls": [] },
"gitea": { "apiBaseUrl": "", "detectUrls": ["gitea.example.com"] }
}
```
- `apiBaseUrl`: API base URL; the per-account baseUrl (gitlab/gitea accounts) still wins when set; this is the default/fallback plus connect-form prefill.
- `detectUrls`: SSH/HTTPS URLs normalized to bare hostnames for provider autodetection (client-side; the server only persists/validates them).
- The whole `gitProviders` key is omitted when empty.
## Per-project overrides
`projects/<projectId>.json` (under the same `OPENCHAMBER_DATA_DIR` root as `settings.json`):
```json
{
"version": 1,
"projectNotes": "...",
"gitProviders": {
"provider": "gitlab",
"github": { "apiBaseUrl": "https://project.github.example.com" },
"gitlab": { "apiBaseUrl": "https://project.gitlab.example.com" }
}
}
```
- Per-project `gitProviders` carry `apiBaseUrl` only (no `detectUrls`); unknown provider keys are dropped and `apiBaseUrl` is normalized with the same rules as the global settings.
- Optional `provider` (`github|gitlab|gitea`) forces the project's git provider instead of auto-detection. Client-side, a forced provider short-circuits remote-host detection (`useGitProvider`); server-side, it makes any remote host acceptable for that provider's repo parsing (`gitlab/repo.js`, `gitea/repo.js`).
- `projects/<projectId>.json` is shared with the scheduled-tasks/projectNotes config; reading and saving preserve all other keys (the `gitProviders` key is omitted entirely when empty).
- **Precedence per provider:** project override (`projects/<projectId>.json``getProjectProviderApiBaseUrl`) > global `settings.json` (`getProviderApiBaseUrl`) > built-in default (`GIT_PROVIDER_DEFAULTS`).
## Consumers
- `packages/web/server/lib/github/octokit.js`, `device-flow.js`, `routes.js`, `repo/index.js`, `pr-status.js`, `repo/fork-detection.js`: GitHub Enterprise support (Octokit `baseUrl`, device-flow web origin, remote parsing, fallback URLs).
- `packages/web/server/lib/gitlab/auth.js`, `client.js`, `routes.js`: effective default base URL.
- `packages/web/server/lib/gitea/auth.js`, `routes.js`: connect-form default / status `defaultBaseUrl`.
- `packages/web/server/lib/opencode/settings-helpers.js`: `gitProviders` persistence whitelist.
## Notes for contributors
- Readers must never throw: `readGitProvidersConfig`, `readProjectJson`, and `githubWebOriginFromApiBase` fail closed.
- No new dependencies.
@@ -0,0 +1,224 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
// Built-in defaults are applied at read time by getters; they are never
// persisted (sanitizeGitProviders only stores user-provided overrides).
const GIT_PROVIDER_KEYS = ['github', 'gitlab', 'gitea'];
export const GIT_PROVIDER_DEFAULTS = {
github: 'https://api.github.com',
gitlab: 'https://gitlab.com',
gitea: 'https://codeberg.org',
};
// Built-in detection hostnames: remotes on these hosts are recognized as the
// provider even when the user configures nothing. Configured detectUrls extend
// them — the built-ins always apply (mirrors the client-side detection in
// packages/ui/src/lib/gitProvider.ts).
export const GIT_PROVIDER_DEFAULT_DETECT_URLS = {
github: ['github.com'],
gitlab: ['gitlab.com'],
gitea: ['codeberg.org'],
};
/**
* Normalize a user-provided API base URL. Adds `https://` when no scheme is
* present, strips a trailing slash, preserves any subpath (e.g. `/gitlab`),
* and returns null for anything unparseable or empty.
*/
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(/\/+$/, '');
}
const normalizeHost = (host) =>
String(host || '').replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
/**
* Extract the bare lowercase hostname from any git remote / URL form:
* `https://host/...`, `ssh://git@host/...`, scp-like `git@host:path`,
* `host:path`, and bracketed or unbracketed IPv6. Returns null for empty or
* unparseable input.
*/
export function normalizeDetectionHost(raw) {
if (typeof raw !== 'string') {
return null;
}
const value = raw.trim();
if (!value) {
return null;
}
// scp-like form: [user@]host:path — never applies once a scheme is present.
if (!value.includes('://')) {
const authority = value.slice(value.lastIndexOf('@') + 1);
// Bracketed IPv6, e.g. `[2001:db8::1]` or `[2001:db8::1]:owner/repo.git`.
if (authority.startsWith('[')) {
const close = authority.indexOf(']');
if (close > 0 && authority.slice(1, close).includes(':')) {
return normalizeHost(authority.slice(1, close));
}
// Malformed brackets fall through to URL parsing, which rejects them.
} else {
const colon = authority.indexOf(':');
if (colon > 0) {
const candidate = authority.slice(0, colon);
// A single-segment pre-colon value without a dot is not a host — the
// guard rejects Windows paths like `C:\foo`. Hosts with a numeric
// port (`localhost:3000`) still resolve via the URL branch.
if (!candidate.includes('/') && candidate.includes('.')) {
return normalizeHost(candidate);
}
}
// Unbracketed IPv6 (e.g. `2001:db8::1`): parse as a bracketed host.
if (authority.includes(':') && !authority.includes('/') && authority.length > 2) {
try {
return normalizeHost(new URL(`ssh://[${authority}]`).hostname);
} catch {
// Not IPv6; fall through to generic URL parsing.
}
}
}
}
try {
const parsed = new URL(value.includes('://') ? value : `ssh://${value}`);
return normalizeHost(parsed.hostname);
} catch {
return null;
}
}
const sanitizeDetectionHosts = (value) => {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const hosts = [];
for (const raw of value) {
const host = normalizeDetectionHost(raw);
if (!host || seen.has(host)) continue;
seen.add(host);
hosts.push(host);
}
return hosts;
};
/**
* Validate and normalize a `gitProviders` settings value. Only the known
* provider keys (github|gitlab|gitea) survive; per provider, `apiBaseUrl` is
* normalized via normalizeBaseUrl and `detectUrls` becomes a deduped array of
* bare hostnames. Empty/absent values are dropped. Returns undefined when
* nothing valid remains, otherwise the normalized partial object.
*/
export function sanitizeGitProviders(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return undefined;
}
const result = {};
for (const provider of GIT_PROVIDER_KEYS) {
const entry = payload[provider];
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const normalized = {};
if (entry.apiBaseUrl !== undefined && entry.apiBaseUrl !== null) {
const baseUrl = normalizeBaseUrl(entry.apiBaseUrl);
if (baseUrl) normalized.apiBaseUrl = baseUrl;
}
if (entry.detectUrls !== undefined && entry.detectUrls !== null) {
const hosts = sanitizeDetectionHosts(entry.detectUrls);
if (hosts.length > 0) normalized.detectUrls = hosts;
}
if (Object.keys(normalized).length > 0) {
result[provider] = normalized;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Read the `gitProviders` section of the user settings file
* (`~/.config/openchamber/settings.json`, overridable via
* OPENCHAMBER_DATA_DIR). Never throws; returns {} on missing/invalid data.
*/
export function readGitProvidersConfig() {
try {
if (fs.existsSync(SETTINGS_FILE)) {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) || {};
return sanitizeGitProviders(parsed.gitProviders) ?? {};
}
} catch {
// ignore
}
return {};
}
/**
* Effective API base URL for a provider: the configured settings.json value if
* present, else the built-in default.
*/
export function getProviderApiBaseUrl(provider) {
return readGitProvidersConfig()[provider]?.apiBaseUrl || GIT_PROVIDER_DEFAULTS[provider] || null;
}
/**
* Effective detection hostnames for a provider: the built-in default hosts
* plus any user-configured detectUrls, deduped. The built-ins always apply so
* a default host (e.g. github.com) keeps classifying remotes even when custom
* enterprise hosts are configured.
*/
export function getProviderDetectUrls(provider) {
const configured = readGitProvidersConfig()[provider]?.detectUrls ?? [];
return [...new Set([...(GIT_PROVIDER_DEFAULT_DETECT_URLS[provider] ?? []), ...configured])];
}
/**
* Derive the GitHub web origin from an API base URL. The public API host
* (`https://api.github.com`) maps to `https://github.com`; an Enterprise API
* base (`https://host/api/v3` or `https://host/api`) maps to `https://host`
* (trailing `/api[/v3]` path segments are stripped, so subpath installs like
* `https://host/ghe/api/v3` keep their prefix). Anything else yields the
* origin of the URL. Never throws; falls back to `https://github.com`.
*/
export function githubWebOriginFromApiBase(apiBase) {
try {
const url = new URL(apiBase);
if (!url.hostname) {
return 'https://github.com';
}
if (url.hostname === 'api.github.com') {
return 'https://github.com';
}
const pathname = url.pathname.replace(/\/+$/, '');
const stripped = pathname.replace(/\/api\/v3$/, '').replace(/\/api$/, '');
return `${url.protocol}//${url.host}${stripped}`;
} catch {
return 'https://github.com';
}
}
@@ -0,0 +1,198 @@
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-git-providers-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
const {
GIT_PROVIDER_DEFAULTS,
GIT_PROVIDER_DEFAULT_DETECT_URLS,
normalizeBaseUrl,
normalizeDetectionHost,
sanitizeGitProviders,
readGitProvidersConfig,
getProviderApiBaseUrl,
getProviderDetectUrls,
githubWebOriginFromApiBase,
} = await import('./config.js');
const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
afterEach(() => {
if (fs.existsSync(SETTINGS_FILE)) {
fs.unlinkSync(SETTINGS_FILE);
}
});
describe('normalizeBaseUrl', () => {
test('adds https scheme when missing', () => {
expect(normalizeBaseUrl('github.example.com')).toBe('https://github.example.com');
expect(normalizeBaseUrl('gitlab.example.com/gitlab')).toBe('https://gitlab.example.com/gitlab');
});
test('strips trailing slashes but preserves subpaths', () => {
expect(normalizeBaseUrl('https://github.example.com/api/v3/')).toBe('https://github.example.com/api/v3');
expect(normalizeBaseUrl('https://gitlab.example.com/')).toBe('https://gitlab.example.com');
expect(normalizeBaseUrl('https://gitlab.example.com/gitlab/')).toBe('https://gitlab.example.com/gitlab');
});
test('keeps an explicit non-https scheme', () => {
expect(normalizeBaseUrl('http://localhost:8080')).toBe('http://localhost:8080');
});
test('returns null for empty or unparseable input', () => {
expect(normalizeBaseUrl('')).toBeNull();
expect(normalizeBaseUrl(' ')).toBeNull();
expect(normalizeBaseUrl('not a url')).toBeNull();
expect(normalizeBaseUrl(null)).toBeNull();
expect(normalizeBaseUrl(undefined)).toBeNull();
expect(normalizeBaseUrl(42)).toBeNull();
});
});
describe('normalizeDetectionHost', () => {
test('extracts the host from https remotes', () => {
expect(normalizeDetectionHost('https://Github.Example.com/owner/repo.git')).toBe('github.example.com');
expect(normalizeDetectionHost('https://github.com/owner/repo')).toBe('github.com');
});
test('extracts the host from scp-like and ssh remotes', () => {
expect(normalizeDetectionHost('git@github.example.com:owner/repo.git')).toBe('github.example.com');
expect(normalizeDetectionHost('ssh://git@github.example.com/owner/repo.git')).toBe('github.example.com');
expect(normalizeDetectionHost('github.example.com:owner/repo.git')).toBe('github.example.com');
});
test('handles ports, user info, and IPv6', () => {
expect(normalizeDetectionHost('https://github.example.com:8443/owner/repo')).toBe('github.example.com');
expect(normalizeDetectionHost('ssh://user@host.example.com/owner/repo')).toBe('host.example.com');
expect(normalizeDetectionHost('[2001:db8::1]:owner/repo.git')).toBe('2001:db8::1');
expect(normalizeDetectionHost('2001:db8::1')).toBe('2001:db8::1');
});
test('returns null for empty or unparseable input', () => {
expect(normalizeDetectionHost('')).toBeNull();
expect(normalizeDetectionHost(null)).toBeNull();
expect(normalizeDetectionHost(42)).toBeNull();
expect(normalizeDetectionHost('C:\\foo')).toBeNull();
});
});
describe('sanitizeGitProviders', () => {
test('normalizes a valid payload', () => {
expect(sanitizeGitProviders({
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['https://github.example.com/owner/repo.git'] },
gitlab: { apiBaseUrl: 'gitlab.example.com', detectUrls: [] },
gitea: { apiBaseUrl: '', detectUrls: ['gitea.example.com'] },
})).toEqual({
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
gitea: { detectUrls: ['gitea.example.com'] },
});
});
test('dedupes and lowercases detectUrls', () => {
expect(sanitizeGitProviders({
github: { detectUrls: ['GitHub.Example.com', 'https://github.example.com/x', 'other.example.com', 'other.example.com'] },
})).toEqual({
github: { detectUrls: ['github.example.com', 'other.example.com'] },
});
});
test('drops malformed or empty entries', () => {
expect(sanitizeGitProviders({ github: { apiBaseUrl: ' ' } })).toBeUndefined();
expect(sanitizeGitProviders({ github: { detectUrls: 'not-an-array' } })).toBeUndefined();
expect(sanitizeGitProviders({ unknown: { apiBaseUrl: 'https://x.example.com' } })).toBeUndefined();
expect(sanitizeGitProviders('not-an-object')).toBeUndefined();
expect(sanitizeGitProviders(null)).toBeUndefined();
expect(sanitizeGitProviders([])).toBeUndefined();
});
test('ignores unknown provider keys', () => {
expect(sanitizeGitProviders({
github: { apiBaseUrl: 'https://github.example.com' },
bitbucket: { apiBaseUrl: 'https://bitbucket.example.com' },
})).toEqual({
github: { apiBaseUrl: 'https://github.example.com' },
});
});
});
describe('readGitProvidersConfig / getProviderApiBaseUrl', () => {
test('returns {} / defaults when no settings file exists', () => {
expect(readGitProvidersConfig()).toEqual({});
expect(getProviderApiBaseUrl('github')).toBe('https://api.github.com');
expect(getProviderApiBaseUrl('gitlab')).toBe('https://gitlab.com');
expect(getProviderApiBaseUrl('gitea')).toBe('https://codeberg.org');
});
test('reads the configured values from settings.json', () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
gitProviders: {
github: { apiBaseUrl: 'https://github.example.com/api/v3' },
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
},
}));
expect(readGitProvidersConfig()).toEqual({
github: { apiBaseUrl: 'https://github.example.com/api/v3' },
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
});
expect(getProviderApiBaseUrl('github')).toBe('https://github.example.com/api/v3');
expect(getProviderApiBaseUrl('gitlab')).toBe('https://gitlab.example.com');
expect(getProviderApiBaseUrl('gitea')).toBe('https://codeberg.org');
});
test('never throws on a malformed settings file', () => {
fs.writeFileSync(SETTINGS_FILE, '{not-json');
expect(readGitProvidersConfig()).toEqual({});
expect(getProviderApiBaseUrl('github')).toBe(GIT_PROVIDER_DEFAULTS.github);
});
});
describe('getProviderDetectUrls', () => {
test('returns the built-in default hosts when nothing is configured', () => {
expect(getProviderDetectUrls('github')).toEqual(['github.com']);
expect(getProviderDetectUrls('gitlab')).toEqual(['gitlab.com']);
expect(getProviderDetectUrls('gitea')).toEqual(['codeberg.org']);
expect(GIT_PROVIDER_DEFAULT_DETECT_URLS.gitea).toEqual(['codeberg.org']);
});
test('keeps the built-in hosts and appends configured detectUrls', () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
gitProviders: {
github: { detectUrls: ['github.example.com'] },
gitea: { detectUrls: ['gitea.example.com', 'codeberg.org'] },
},
}));
expect(getProviderDetectUrls('github')).toEqual(['github.com', 'github.example.com']);
expect(getProviderDetectUrls('gitea')).toEqual(['codeberg.org', 'gitea.example.com']);
});
});
describe('githubWebOriginFromApiBase', () => {
test('maps the public api host to github.com', () => {
expect(githubWebOriginFromApiBase('https://api.github.com')).toBe('https://github.com');
});
test('maps enterprise api bases to the host', () => {
expect(githubWebOriginFromApiBase('https://github.example.com/api/v3')).toBe('https://github.example.com');
expect(githubWebOriginFromApiBase('https://github.example.com/api')).toBe('https://github.example.com');
});
test('keeps subpath prefixes and plain origins', () => {
expect(githubWebOriginFromApiBase('https://github.example.com/ghe/api/v3')).toBe('https://github.example.com/ghe');
expect(githubWebOriginFromApiBase('https://github.example.com')).toBe('https://github.example.com');
expect(githubWebOriginFromApiBase('https://github.example.com:8443/api/v3')).toBe('https://github.example.com:8443');
});
test('falls back for invalid input and never throws', () => {
expect(githubWebOriginFromApiBase('')).toBe('https://github.com');
expect(githubWebOriginFromApiBase(null)).toBe('https://github.com');
expect(githubWebOriginFromApiBase('not a url')).toBe('https://github.com');
});
});
@@ -0,0 +1,349 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { execFileSync } from 'child_process';
import { createProjectIdFromPath } from '../projects/project-id.js';
import { getProviderApiBaseUrl, sanitizeGitProviders } from './config.js';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
// Per-project git provider overrides live in the same `projects/` directory as
// the scheduled-tasks/projectNotes config (`projects/<projectId>.json`).
export const OPENCHAMBER_PROJECTS_DIR = path.join(OPENCHAMBER_DATA_DIR, 'projects');
// Same rule used by `packages/web/server/lib/projects/project-config.js` to
// keep a projectId safe for use in a file path.
const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/;
const isSafeProjectId = (projectId) =>
typeof projectId === 'string' && projectId.length > 0 && PROJECT_ID_PATTERN.test(projectId);
const projectConfigPath = (projectId) => path.join(OPENCHAMBER_PROJECTS_DIR, `${projectId}.json`);
// Mirror the path normalization used by `createProjectIdFromPath`
// (packages/web/server/lib/projects/project-id.js) so directory matching and
// fallback id generation agree on the same canonical path.
const normalizeProjectPathForMatch = (value) => {
if (typeof value !== 'string') return '';
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || value;
};
const GIT_PROVIDER_SET = new Set(['github', 'gitlab', 'gitea']);
/**
* Validate/normalize a per-project `gitProviders` value. Same provider
* allowlist and `normalizeBaseUrl` rules as `sanitizeGitProviders`, but the
* per-project shape only carries `apiBaseUrl` (any `detectUrls` are tolerated
* and stripped) plus an optional forced `provider` (github|gitlab|gitea) that
* overrides automatic provider detection for the project. Returns undefined
* when nothing valid remains.
*/
export function sanitizeProjectGitProviders(payload) {
const result = {};
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const forcedProvider = typeof payload.provider === 'string' ? payload.provider.trim().toLowerCase() : '';
if (GIT_PROVIDER_SET.has(forcedProvider)) {
result.provider = forcedProvider;
}
}
const sanitized = sanitizeGitProviders(payload);
if (sanitized) {
for (const provider of Object.keys(sanitized)) {
const entry = sanitized[provider];
const normalized = {};
if (entry.apiBaseUrl) {
normalized.apiBaseUrl = entry.apiBaseUrl;
}
if (Object.keys(normalized).length > 0) {
result[provider] = normalized;
}
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
const readRawProjectJson = async (projectId) => {
const filePath = projectConfigPath(projectId);
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
// Missing or malformed file: fail closed.
return {};
}
};
/**
* Read the raw JSON object from `projects/<projectId>.json`. Returns `{}` when
* the file is missing or malformed, `null` for an invalid projectId. Never
* throws.
*/
export function readProjectJson(projectId) {
if (!isSafeProjectId(projectId)) {
return null;
}
const filePath = projectConfigPath(projectId);
try {
if (fs.existsSync(filePath)) {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
}
} catch {
// ignore
}
return {};
}
/**
* Effective per-project `gitProviders` overrides for a projectId. Returns {}
* when unset or for an invalid projectId.
*/
export function getProjectGitProviders(projectId) {
const json = readProjectJson(projectId);
if (!json) {
return {};
}
return sanitizeProjectGitProviders(json.gitProviders) ?? {};
}
const readProjectsFromSettings = () => {
try {
const settingsFile = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
if (fs.existsSync(settingsFile)) {
const parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf8')) || {};
if (Array.isArray(parsed.projects)) {
return parsed.projects.filter((entry) => entry && typeof entry === 'object');
}
}
} catch {
// ignore
}
return [];
};
// Per-directory projectId resolution is memoized for RESOLVE_CACHE_TTL_MS so
// forge hot paths (per-request effective base URL lookups) do not exec git or
// re-read settings.json on every call. Negative results are cached too. Mirrors
// the client-side per-directory cache in `packages/ui/src/lib/gitProvider.ts`.
const RESOLVE_CACHE_TTL_MS = 60_000;
const RESOLVE_CACHE_MAX_ENTRIES = 500;
const resolveCache = new Map();
// Short timeout so an unresponsive git cannot stall a forge hot path; failures
// fall through to the directory containment matching below.
const GIT_COMMON_DIR_TIMEOUT_MS = 3_000;
/**
* Resolve a directory to its main repository root via
* `git rev-parse --git-common-dir`. For both a main checkout and a linked
* worktree this prints the main repo's `.git` path (a linked worktree points at
* the main repo's git dir), so `path.dirname` yields the main repo root — the
* directory a settings.json project path is recorded against. Also handles a
* repository rooted at the filesystem root (`dirname('/.git') === '/'`).
* Returns null on any failure (not a git repo, git unavailable, parse failure).
*/
const tryResolveGitCommonDirRoot = (directory) => {
try {
const output = execFileSync('git', ['rev-parse', '--git-common-dir'], {
cwd: directory,
encoding: 'utf8',
timeout: GIT_COMMON_DIR_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
const commonDir = String(output || '').trim();
if (!commonDir) {
return null;
}
return path.dirname(path.resolve(directory, commonDir));
} catch {
return null;
}
};
// A project path contains a candidate directory when it equals it or is a
// path-prefix (mirrors the original exact/containment rules). The filesystem
// root `/` additionally contains every absolute path.
const projectMatches = (projectPath, candidate) => {
if (projectPath === candidate) {
return true;
}
if (projectPath === '/') {
return candidate.startsWith('/');
}
return candidate.startsWith(`${projectPath}/`);
};
// Longest matching project path from the projects list wins, as before.
// Returns `{ id, length }` (length of the matched project path) or null.
const matchProjectAgainst = (candidatePath, projects) => {
const normalized = normalizeProjectPathForMatch(candidatePath).trim();
if (!normalized) {
return null;
}
let bestId = null;
let bestPathLength = -1;
for (const entry of projects) {
if (typeof entry.id !== 'string' || !entry.id) {
continue;
}
const projectPath = normalizeProjectPathForMatch(entry.path).trim();
if (!projectPath) {
continue;
}
if (projectMatches(projectPath, normalized) && projectPath.length > bestPathLength) {
bestPathLength = projectPath.length;
bestId = entry.id;
}
}
return bestId ? { id: bestId, length: bestPathLength } : null;
};
// Cache overflow: drop the oldest entry so the map stays bounded.
const evictOldestResolveCacheEntry = () => {
if (resolveCache.size < RESOLVE_CACHE_MAX_ENTRIES) {
return;
}
let oldestKey = null;
let oldestAt = Infinity;
for (const [key, value] of resolveCache) {
if (value.at < oldestAt) {
oldestAt = value.at;
oldestKey = key;
}
}
if (oldestKey !== null) {
resolveCache.delete(oldestKey);
}
};
/**
* Test hook: drop all cached directory→projectId resolutions. Tests mutate the
* settings file between assertions and must not observe the 60s TTL.
*/
export const _clearResolveProjectIdCache = () => {
resolveCache.clear();
};
/**
* Resolve the projectId for a directory. The directory is first resolved to
* its main repo root via git (worktree-aware: a linked worktree created outside
* the project root maps back to the main repo), then matched against the
* settings.json `projects` list; the longest matching project path among the
* git root and the directory itself wins (a nested project path under the
* directory still wins over a broader repo-root match). When git is
* unavailable or the directory is not a git repo, the directory's own
* exact/containment match applies. Falls back to
* `createProjectIdFromPath(directory)` when no project matches; null when the
* directory is empty. Results are cached for RESOLVE_CACHE_TTL_MS.
*/
export function resolveProjectIdFromDirectory(directory) {
const normalizedDirectory = normalizeProjectPathForMatch(directory).trim();
if (!normalizedDirectory) {
return null;
}
const cached = resolveCache.get(normalizedDirectory);
if (cached && Date.now() - cached.at < RESOLVE_CACHE_TTL_MS) {
return cached.projectId;
}
const projects = readProjectsFromSettings();
const gitRoot = tryResolveGitCommonDirRoot(normalizedDirectory);
const gitMatch = gitRoot ? matchProjectAgainst(gitRoot, projects) : null;
const directoryMatch = matchProjectAgainst(normalizedDirectory, projects);
let projectId;
if (gitMatch && directoryMatch) {
// Ties prefer the authoritative git-derived root.
projectId = gitMatch.length >= directoryMatch.length ? gitMatch.id : directoryMatch.id;
} else {
projectId = gitMatch?.id || directoryMatch?.id || null;
}
if (!projectId) {
projectId = createProjectIdFromPath(normalizedDirectory) || null;
}
evictOldestResolveCacheEntry();
resolveCache.set(normalizedDirectory, { at: Date.now(), projectId });
return projectId;
}
/**
* Per-project API base URL override for a provider, or null when unset.
*/
export function getProjectProviderApiBaseUrl(provider, projectId) {
return getProjectGitProviders(projectId)[provider]?.apiBaseUrl || null;
}
/**
* The project's forced git provider (github|gitlab|gitea), or null when the
* provider is auto-detected from the remote. Only meaningful for a projectId
* that resolves to a project config; invalid ids yield null.
*/
export function getProjectProvider(projectId) {
const providers = getProjectGitProviders(projectId);
return providers.provider || null;
}
/**
* The forced git provider for a directory's owning project, or null when
* unset or when the directory resolves to no project.
*/
export function getProjectProviderFromDirectory(directory) {
const projectId = resolveProjectIdFromDirectory(directory);
if (!projectId) {
return null;
}
return getProjectProvider(projectId);
}
/**
* Effective API base URL for a provider given a directory: the project override
* (when the directory resolves to a project with one) wins, else the global
* settings.json value, else the built-in default. Null only when nothing
* resolves.
*/
export function getEffectiveProviderApiBaseUrl(provider, directory) {
const projectId = resolveProjectIdFromDirectory(directory);
if (projectId) {
const projectOverride = getProjectProviderApiBaseUrl(provider, projectId);
if (projectOverride) {
return projectOverride;
}
}
return getProviderApiBaseUrl(provider);
}
/**
* Persist the per-project `gitProviders` overrides for a projectId. All other
* keys in the project JSON (projectNotes, scheduledTasks, version, ...) are
* preserved; the `gitProviders` key is omitted entirely when the sanitized
* payload is empty. Atomic write (tmp file + rename), mkdir recursive. Returns
* the saved `gitProviders` object (or {}). Throws for an invalid projectId.
*/
export async function saveProjectGitProviders(projectId, payload) {
if (!isSafeProjectId(projectId)) {
throw new Error('projectId contains unsupported characters');
}
const sanitized = sanitizeProjectGitProviders(payload) ?? {};
const existing = await readRawProjectJson(projectId);
const merged = { ...existing };
if (Object.keys(sanitized).length > 0) {
merged.gitProviders = sanitized;
} else {
delete merged.gitProviders;
}
const filePath = projectConfigPath(projectId);
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
await fs.promises.rename(temporaryPath, filePath);
return sanitized;
}
@@ -0,0 +1,391 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { execFileSync } from 'child_process';
import { afterAll, afterEach, describe, expect, test } from 'vitest';
import express from 'express';
import request from 'supertest';
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-providers-project-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
const {
sanitizeProjectGitProviders,
getProjectGitProviders,
getProjectProvider,
getProjectProviderFromDirectory,
resolveProjectIdFromDirectory,
getProjectProviderApiBaseUrl,
getEffectiveProviderApiBaseUrl,
saveProjectGitProviders,
_clearResolveProjectIdCache,
} = await import('./project-config.js');
const { registerGitProviderRoutes } = await import('./routes.js');
const PROJECTS_DIR = path.join(TEMP_DATA_DIR, 'projects');
const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json');
const projectFile = (projectId) => path.join(PROJECTS_DIR, `${projectId}.json`);
const writeSettingsProjects = (projects) => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ projects }));
};
// Resolution results are cached for 60s; tests mutate settings.json between
// assertions, so reset the module-level cache before every test.
afterEach(() => {
_clearResolveProjectIdCache();
fs.rmSync(PROJECTS_DIR, { recursive: true, force: true });
if (fs.existsSync(SETTINGS_FILE)) {
fs.unlinkSync(SETTINGS_FILE);
}
});
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
describe('sanitizeProjectGitProviders', () => {
test('keeps only known providers and strips detectUrls', () => {
expect(sanitizeProjectGitProviders({
github: { apiBaseUrl: 'github.example.com', detectUrls: ['github.example.com'] },
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
gitea: { detectUrls: ['gitea.example.com'] },
bitbucket: { apiBaseUrl: 'https://bitbucket.example.com' },
})).toEqual({
github: { apiBaseUrl: 'https://github.example.com' },
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
});
});
test('normalizes apiBaseUrl with the same rules as config.js', () => {
expect(sanitizeProjectGitProviders({
github: { apiBaseUrl: 'github.example.com/api/v3/' },
})).toEqual({
github: { apiBaseUrl: 'https://github.example.com/api/v3' },
});
expect(sanitizeProjectGitProviders({ github: { apiBaseUrl: '' } })).toBeUndefined();
expect(sanitizeProjectGitProviders({ github: { apiBaseUrl: ' ' } })).toBeUndefined();
});
test('returns undefined for empty or invalid payloads', () => {
expect(sanitizeProjectGitProviders({})).toBeUndefined();
expect(sanitizeProjectGitProviders(null)).toBeUndefined();
expect(sanitizeProjectGitProviders('not-an-object')).toBeUndefined();
expect(sanitizeProjectGitProviders([])).toBeUndefined();
expect(getProjectGitProviders('proj_1')).toEqual({});
});
test('keeps a forced provider when it is one of the known providers', () => {
expect(sanitizeProjectGitProviders({
provider: 'GitLab',
gitlab: { apiBaseUrl: 'gitlab.example.com' },
})).toEqual({
provider: 'gitlab',
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
});
});
test('drops an unknown or empty forced provider', () => {
expect(sanitizeProjectGitProviders({
provider: 'bitbucket',
github: { apiBaseUrl: 'github.example.com' },
})).toEqual({ github: { apiBaseUrl: 'https://github.example.com' } });
expect(sanitizeProjectGitProviders({ provider: '' })).toBeUndefined();
expect(sanitizeProjectGitProviders({ provider: ' ' })).toBeUndefined();
});
});
describe('getProjectProvider / getProjectProviderFromDirectory', () => {
test('returns the forced provider or null', () => {
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
fs.writeFileSync(projectFile('proj_provider'), JSON.stringify({
gitProviders: { provider: 'gitea', gitea: { apiBaseUrl: 'https://gitea.example.com' } },
}, null, 2));
expect(getProjectProvider('proj_provider')).toBe('gitea');
expect(getProjectProvider('proj_missing')).toBeNull();
});
test('resolves the forced provider through the directory', () => {
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
fs.writeFileSync(projectFile('proj_forced'), JSON.stringify({
gitProviders: { provider: 'gitlab' },
}, null, 2));
writeSettingsProjects([{ id: 'proj_forced', path: '/home/user/gl' }]);
expect(getProjectProviderFromDirectory('/home/user/gl')).toBe('gitlab');
expect(getProjectProviderFromDirectory('/home/user/unregistered')).toBeNull();
});
});
describe('saveProjectGitProviders round-trip', () => {
test('preserves unrelated keys and normalizes gitProviders', async () => {
const existing = {
version: 1,
projectNotes: 'keep me',
setupWorktree: { clone: 'git@github.com:org/repo.git' },
scheduledTasks: [{ id: 'task_1', name: 'nightly', enabled: true }],
};
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
fs.writeFileSync(projectFile('proj_1'), JSON.stringify(existing, null, 2));
const saved = await saveProjectGitProviders('proj_1', {
github: { apiBaseUrl: 'github.example.com' },
gitlab: { apiBaseUrl: '' },
});
expect(saved).toEqual({ github: { apiBaseUrl: 'https://github.example.com' } });
const onDisk = JSON.parse(fs.readFileSync(projectFile('proj_1'), 'utf8'));
expect(onDisk.projectNotes).toBe('keep me');
expect(onDisk.setupWorktree).toEqual(existing.setupWorktree);
expect(onDisk.scheduledTasks).toEqual(existing.scheduledTasks);
expect(onDisk.version).toBe(1);
expect(onDisk.gitProviders).toEqual({ github: { apiBaseUrl: 'https://github.example.com' } });
});
test('creates the projects dir when missing', async () => {
const saved = await saveProjectGitProviders('proj_new', {
gitea: { apiBaseUrl: 'gitea.example.com' },
});
expect(saved).toEqual({ gitea: { apiBaseUrl: 'https://gitea.example.com' } });
expect(fs.existsSync(projectFile('proj_new'))).toBe(true);
});
test('removes the gitProviders key when the payload sanitizes empty', async () => {
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
fs.writeFileSync(projectFile('proj_1'), JSON.stringify({
version: 1,
projectNotes: 'keep me',
gitProviders: { github: { apiBaseUrl: 'https://github.example.com' } },
}));
await saveProjectGitProviders('proj_1', { github: { apiBaseUrl: '' } });
const onDisk = JSON.parse(fs.readFileSync(projectFile('proj_1'), 'utf8'));
expect(onDisk.projectNotes).toBe('keep me');
expect('gitProviders' in onDisk).toBe(false);
expect(getProjectGitProviders('proj_1')).toEqual({});
});
});
describe('resolveProjectIdFromDirectory', () => {
test('matches the exact project path', () => {
writeSettingsProjects([{ id: 'proj_root', path: '/home/user/proj' }]);
expect(resolveProjectIdFromDirectory('/home/user/proj')).toBe('proj_root');
});
test('matches a worktree child path to the root project', () => {
writeSettingsProjects([{ id: 'proj_root', path: '/home/user/proj' }]);
expect(resolveProjectIdFromDirectory('/home/user/proj/.git/worktrees/feature')).toBe('proj_root');
});
test('longest matching project path wins', () => {
writeSettingsProjects([
{ id: 'proj_root', path: '/home/user/proj' },
{ id: 'proj_nested', path: '/home/user/proj/sub' },
]);
expect(resolveProjectIdFromDirectory('/home/user/proj/sub/work')).toBe('proj_nested');
expect(resolveProjectIdFromDirectory('/home/user/proj/sub')).toBe('proj_nested');
expect(resolveProjectIdFromDirectory('/home/user/proj/work')).toBe('proj_root');
});
test('normalizes trailing slashes and backslashes', () => {
writeSettingsProjects([
{ id: 'proj_back', path: 'C:\\Users\\dev\\proj\\' },
{ id: 'proj_slash', path: '/home/user/proj/' },
]);
expect(resolveProjectIdFromDirectory('C:\\Users\\dev\\proj')).toBe('proj_back');
expect(resolveProjectIdFromDirectory('/home/user/proj/sub')).toBe('proj_slash');
});
test('falls back to the path-derived id when no project matches', () => {
writeSettingsProjects([{ id: 'proj_root', path: '/home/user/proj' }]);
const expected = `path_${Buffer.from('/home/other/x', 'utf8').toString('base64url')}`;
expect(resolveProjectIdFromDirectory('/home/other/x')).toBe(expected);
expect(resolveProjectIdFromDirectory('/home/user/proj2')).toBe(`path_${Buffer.from('/home/user/proj2', 'utf8').toString('base64url')}`);
});
test('returns null for empty input', () => {
writeSettingsProjects([{ id: 'proj_root', path: '/home/user/proj' }]);
expect(resolveProjectIdFromDirectory('')).toBeNull();
expect(resolveProjectIdFromDirectory(' ')).toBeNull();
expect(resolveProjectIdFromDirectory(undefined)).toBeNull();
expect(resolveProjectIdFromDirectory(null)).toBeNull();
});
test('falls back to the path-derived id when the settings file is missing or malformed', () => {
const expected = `path_${Buffer.from('/home/user/proj', 'utf8').toString('base64url')}`;
expect(resolveProjectIdFromDirectory('/home/user/proj')).toBe(expected);
fs.writeFileSync(SETTINGS_FILE, '{not-json');
expect(resolveProjectIdFromDirectory('/home/user/proj')).toBe(expected);
});
// Git may not be installed in every environment; availability is checked once
// and the worktree test is skipped when it is missing.
const hasGit = (() => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
test.skipIf(!hasGit)('resolves an external git worktree (a sibling of the repo root) to its main repo project', () => {
const main = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitprov-main-'));
const siblingParent = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitprov-sibling-'));
const worktree = path.join(siblingParent, 'feature-wt');
try {
execFileSync('git', ['init', '-q', main]);
execFileSync('git', ['-C', main, 'worktree', 'add', '-q', '-b', 'feature', worktree]);
const mainRoot = path.resolve(main);
const projectId = `path_${Buffer.from(mainRoot, 'utf8').toString('base64url')}`;
writeSettingsProjects([{ id: projectId, path: mainRoot }]);
expect(resolveProjectIdFromDirectory(worktree)).toBe(projectId);
// A subdirectory of the worktree resolves the same way.
const nested = path.join(worktree, 'src', 'deep');
fs.mkdirSync(nested, { recursive: true });
expect(resolveProjectIdFromDirectory(nested)).toBe(projectId);
} finally {
try {
execFileSync('git', ['-C', main, 'worktree', 'remove', '--force', worktree], { stdio: 'ignore' });
} catch {
// already removed
}
fs.rmSync(siblingParent, { recursive: true, force: true });
fs.rmSync(main, { recursive: true, force: true });
}
});
test('resolves a subdirectory to a project whose path is the filesystem root /', () => {
writeSettingsProjects([{ id: 'proj_rootfs', path: '/' }]);
expect(resolveProjectIdFromDirectory('/tmp/somewhere/under')).toBe('proj_rootfs');
expect(resolveProjectIdFromDirectory('/')).toBe('proj_rootfs');
// A more specific registered path still wins over the root catch-all.
_clearResolveProjectIdCache();
writeSettingsProjects([
{ id: 'proj_rootfs', path: '/' },
{ id: 'proj_tmp', path: '/tmp' },
]);
expect(resolveProjectIdFromDirectory('/tmp/somewhere/under')).toBe('proj_tmp');
});
test('serves the cached resolution within the TTL even after the settings change', () => {
writeSettingsProjects([{ id: 'proj_first', path: '/cache/proj' }]);
expect(resolveProjectIdFromDirectory('/cache/proj')).toBe('proj_first');
writeSettingsProjects([{ id: 'proj_second', path: '/cache/proj' }]);
expect(resolveProjectIdFromDirectory('/cache/proj')).toBe('proj_first');
});
});
describe('getEffectiveProviderApiBaseUrl precedence', () => {
const PROJECT_OVERRIDES = {
github: { apiBaseUrl: 'https://project.github.example.com' },
};
test('project override beats the global settings value', () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
projects: [{ id: 'proj_1', path: '/home/user/proj' }],
gitProviders: {
github: { apiBaseUrl: 'https://global.github.example.com' },
gitlab: { apiBaseUrl: 'https://global.gitlab.example.com' },
},
}));
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
fs.writeFileSync(projectFile('proj_1'), JSON.stringify({ gitProviders: PROJECT_OVERRIDES }));
expect(getEffectiveProviderApiBaseUrl('github', '/home/user/proj')).toBe('https://project.github.example.com');
});
test('falls through to the global value when the project has no override for that provider', () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
projects: [{ id: 'proj_1', path: '/home/user/proj' }],
gitProviders: { gitlab: { apiBaseUrl: 'https://global.gitlab.example.com' } },
}));
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
fs.writeFileSync(projectFile('proj_1'), JSON.stringify({ gitProviders: PROJECT_OVERRIDES }));
expect(getEffectiveProviderApiBaseUrl('github', '/home/user/proj')).toBe('https://project.github.example.com');
expect(getEffectiveProviderApiBaseUrl('gitlab', '/home/user/proj')).toBe('https://global.gitlab.example.com');
});
test('falls through to the built-in default when neither project nor global is set', () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
projects: [{ id: 'proj_1', path: '/home/user/proj' }],
}));
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
fs.writeFileSync(projectFile('proj_1'), JSON.stringify({ gitProviders: PROJECT_OVERRIDES }));
expect(getEffectiveProviderApiBaseUrl('github', '/home/user/proj')).toBe('https://project.github.example.com');
expect(getEffectiveProviderApiBaseUrl('gitea', '/home/user/proj')).toBe('https://codeberg.org');
});
test('applies the global value for a directory not registered as a project', () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
projects: [],
gitProviders: { github: { apiBaseUrl: 'https://global.github.example.com' } },
}));
expect(getEffectiveProviderApiBaseUrl('github', '/home/unregistered/proj')).toBe('https://global.github.example.com');
expect(getEffectiveProviderApiBaseUrl('gitea', '/home/unregistered/proj')).toBe('https://codeberg.org');
});
test('per-provider independence with no global settings file', () => {
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
fs.writeFileSync(projectFile('proj_1'), JSON.stringify({ gitProviders: PROJECT_OVERRIDES }));
expect(getProjectProviderApiBaseUrl('github', 'proj_1')).toBe('https://project.github.example.com');
expect(getProjectProviderApiBaseUrl('gitlab', 'proj_1')).toBeNull();
expect(getProjectProviderApiBaseUrl('github', 'missing_project')).toBeNull();
});
});
describe('project git-providers routes', () => {
const createApp = () => {
const app = express();
app.use(express.json());
registerGitProviderRoutes(app);
return app;
};
test('GET returns {} when nothing is set', async () => {
const app = createApp();
const response = await request(app).get('/api/projects/proj_1/git-providers');
expect(response.status).toBe(200);
expect(response.body).toEqual({ gitProviders: {} });
});
test('PUT persists and GET returns the saved overrides', async () => {
const app = createApp();
const putResponse = await request(app)
.put('/api/projects/proj_1/git-providers')
.send({ gitProviders: { github: { apiBaseUrl: 'github.example.com' } } });
expect(putResponse.status).toBe(200);
expect(putResponse.body).toEqual({ gitProviders: { github: { apiBaseUrl: 'https://github.example.com' } } });
const getResponse = await request(app).get('/api/projects/proj_1/git-providers');
expect(getResponse.status).toBe(200);
expect(getResponse.body).toEqual({ gitProviders: { github: { apiBaseUrl: 'https://github.example.com' } } });
// The saved value is readable via the direct module API too.
expect(getProjectGitProviders('proj_1')).toEqual({ github: { apiBaseUrl: 'https://github.example.com' } });
});
test('PUT rejects an invalid body shape with 400', async () => {
const app = createApp();
expect((await request(app).put('/api/projects/proj_1/git-providers').send({})).status).toBe(400);
expect((await request(app).put('/api/projects/proj_1/git-providers').send({ gitProviders: 'nope' })).status).toBe(400);
expect((await request(app).put('/api/projects/proj_1/git-providers').send({ gitProviders: [] })).status).toBe(400);
expect((await request(app).put('/api/projects/proj_1/git-providers').send({ gitProviders: null })).status).toBe(400);
});
test('PUT with an empty gitProviders object clears the stored overrides', async () => {
const app = createApp();
await request(app)
.put('/api/projects/proj_1/git-providers')
.send({ gitProviders: { github: { apiBaseUrl: 'github.example.com' } } });
const putResponse = await request(app)
.put('/api/projects/proj_1/git-providers')
.send({ gitProviders: {} });
expect(putResponse.status).toBe(200);
expect(putResponse.body).toEqual({ gitProviders: {} });
expect(getProjectGitProviders('proj_1')).toEqual({});
});
});
@@ -0,0 +1,50 @@
import { getProjectGitProviders, saveProjectGitProviders } from './project-config.js';
const asNonEmptyString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const parseProjectId = (req) => asNonEmptyString(req?.params?.projectId);
const isPlainObject = (value) =>
value !== null && value !== undefined && typeof value === 'object' && !Array.isArray(value);
export function registerGitProviderRoutes(app) {
app.get('/api/projects/:projectId/git-providers', async (req, res) => {
const projectId = parseProjectId(req);
if (!projectId) {
return res.status(400).json({ error: 'projectId is required' });
}
try {
return res.json({ gitProviders: getProjectGitProviders(projectId) });
} catch (error) {
console.error('[GitProviders] failed to load project git providers:', error);
return res.status(500).json({ error: 'Failed to load project git providers' });
}
});
app.put('/api/projects/:projectId/git-providers', async (req, res) => {
const projectId = parseProjectId(req);
if (!projectId) {
return res.status(400).json({ error: 'projectId is required' });
}
if (!isPlainObject(req.body) || !isPlainObject(req.body.gitProviders)) {
return res.status(400).json({ error: 'gitProviders payload is required' });
}
try {
const saved = await saveProjectGitProviders(projectId, req.body.gitProviders);
return res.json({ gitProviders: saved });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save project git providers';
const statusCode = message.toLowerCase().includes('unsupported characters') ? 400 : 500;
if (statusCode === 500) {
console.error('[GitProviders] failed to save project git providers:', error);
}
return res.status(statusCode).json({ error: message });
}
});
}
@@ -0,0 +1,154 @@
# Gitea Module Documentation
## Purpose
- This module owns Gitea/Forgejo auth (Personal Access Token), raw REST v1 client access, remote-URL repo resolution, and Gitea issue / pull-request (PR) APIs for OpenChamber, including issue create/update and PR create/update/merge writes.
- From a user perspective, this is the layer that lets the app show Gitea issues and pull requests for a local project, including comments and per-file diffs, and create, edit, and merge pull requests.
- Gitea and Forgejo share the same GitHub-style REST v1 API, so this module serves both. Gitea calls remote work **pull requests** (PR), not merge requests. Gitea repos are flat `owner/repo` — there are no multi-segment namespaces.
- The module mirrors `packages/web/server/lib/gitlab/` (PAT auth + raw-fetch client) but uses a **Personal Access Token** against the `Authorization: token <pat>` header and a **user-supplied base URL** (Gitea is self-hosted; codeberg.org is the only built-in default).
## Entrypoints and structure
- `packages/web/server/lib/gitea/index.js`: public server entrypoint re-exports.
- `packages/web/server/lib/gitea/routes.js`: Express route registration for `/api/gitea/*` endpoints.
- `packages/web/server/lib/gitea/auth.js`: PAT auth storage, multi-account support, base URL normalization.
- `packages/web/server/lib/gitea/client.js`: raw `fetch` Gitea REST v1 client (timeout, ETag conditional GET, rate-limit cooldown, `Link`-header pagination, redirect handling).
- `packages/web/server/lib/gitea/client.d.ts`: hand-written type declaration for `client.js` (the module is plain JS); consumed by the live-test harness.
- `packages/web/server/lib/gitea/repo.js`: Gitea remote URL parsing (flat `owner/repo`) and directory-to-repo resolution.
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: API route layer that calls this module (via `registerGiteaRoutes`).
- `packages/web/src/api/gitea.ts`: web client wrapper for Gitea endpoints.
- `packages/ui/src/lib/api/types.ts`: shared response types consumed by web, desktop, VS Code, and mobile.
## Public exports
### Auth (`auth.js`)
- `getGiteaAuth()`: current auth entry.
- `getGiteaAuthAccounts()`: all configured accounts (`{ id, user, baseUrl, current }`).
- `setGiteaAuth({ accessToken, baseUrl, user })`: save or update an account (validating `user` comes from `GET /user`). `baseUrl` is required — throws when missing/invalid.
- `activateGiteaAuth(accountId)`: switch active account.
- `clearGiteaAuth()`: remove the current account.
- `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input.
- `GITEA_AUTH_FILE`: auth file path.
- `getGiteaDefaultBaseUrl()`: effective default base URL — configured `gitProviders.gitea.apiBaseUrl` from `settings.json`, else `https://codeberg.org`. Used to prefill the connect form and as the connect/status default; stored accounts still require an explicit base URL.
- The only built-in default base URL is **codeberg.org** (a well-known public Forgejo instance); any other Gitea/Forgejo instance URL is user-provided.
### Client (`client.js`)
- `createGiteaClient({ token, baseUrl })`: raw-fetch REST v1 client with `request(path, { method, query, body, signal, raw })` plus convenience methods `user()`, `repo(owner, repo)`, `issues(owner, repo, params)`, `issue(owner, repo, number)`, `issueComments(owner, repo, number, params)`, `createIssueComment(owner, repo, number, body)`, `createIssue(owner, repo, params)` (POST), `updateIssue(owner, repo, number, params)` (PATCH), `milestones(owner, repo, params)`, `repoLabels(owner, repo, params)`, `pullRequests(owner, repo, params)`, `pullRequest(owner, repo, number)`, `pullRequestDiff(owner, repo, number)` (raw `.diff` text via the `raw` option), `pullRequestFiles(owner, repo, number, params)`, `pullRequestCommits(owner, repo, number, params)`, `pullRequestReviews(owner, repo, number, params)`, `createPullReview(owner, repo, number, params)` (POST), `commitStatuses(owner, repo, sha, params)`, `createPullRequest(owner, repo, body)`, `updatePullRequest(owner, repo, number, body)` (PATCH), `mergePullRequest(owner, repo, number, body)` (POST), `branches(owner, repo, params)`.
- `getGiteaClientOrNull(directory?)`: client for the current account, or `null`. With `directory`, a per-project API base override wins over the account's base URL for that project (see "Per-project overrides").
- `isGiteaRateLimited()` / `noteGiteaRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub/GitLab modules).
### Repo (`repo.js`)
- `parseGiteaRemoteUrl(raw, knownHosts?)`: parse SSH/HTTPS remote URL into `{ owner, repo, host, baseUrl, url }` (exactly two path segments; never matches `github.com` or `gitlab.com`).
- `resolveGiteaRepoFromDirectory(directory, remoteName?)`: resolve a Gitea repo from a local git remote.
## Auth storage and config
- Auth storage: `~/.config/openchamber/gitea-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
- Writes are atomic (tmp file + rename) and file mode is `0o600`.
- Base URL resolution: the caller-supplied `baseUrl` (normalized) is the primary source, then the effective default (configured `settings.json` `gitProviders.gitea.apiBaseUrl`, else `https://codeberg.org`). Stored entries without a usable base URL are dropped.
- Per-project overrides: a per-project `gitProviders.gitea.apiBaseUrl` override (stored under `projects/<projectId>.json`, resolved via `getEffectiveProviderApiBaseUrl('gitea', directory)` in `packages/web/server/lib/git-providers/project-config.js`) replaces the account's base URL for that project's data routes (`getGiteaClientOrNull(directory)`), and its host is accepted for directory-to-repo resolution (`resolveGiteaRepoFromDirectory`). A forced `gitProviders.provider: 'gitea'` accepts any remote host for directory resolution. Global routes (`auth/status`, `auth/connect`, `auth/activate`, DELETE auth, `me`, `repo/branches`) stay global.
- Account id: `` `${host}:${username}` `` (e.g. `gitea.example.com:alice`), falling back to `token:<first8>` when the username is missing.
- Auth header on every request: `Authorization: token <pat>`.
- Gitea's `GET /user` uses `login`/`full_name`/`html_url`; `setGiteaAuth` accepts both that and the `username`/`web_url` variants.
## Client behavior
- Base URL joining: `{baseUrl}/api/v1{path}`. Gitea repos are flat `owner/repo`, so owner/repo segments are interpolated directly (single path segments, no encoding needed).
- 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: Gitea list endpoints return a `Link` header (`rel="next"`) plus `X-Total-Count`; both are parsed into the returned `page` object (`hasMore` = a next page exists). List requests use `page` + `limit` query params (Gitea caps `limit` at 50).
- Redirects: `301`/`302`/`308` with a `Location` header are followed exactly once with `redirect: 'manual'`, preserving the `Authorization` header across the hop.
- Rate limits: a `429` records a module-level cooldown (honoring `Retry-After` seconds / `X-RateLimit-Reset` Unix seconds when present) and surfaces `{ status: 429, error: 'Gitea 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`. The `raw: true` option returns the response body as text (used for the `.diff` endpoint).
## API integration overview
- Issues/PRs are repo-scoped by **number** (GitHub-style, not per-namespace iid).
- User: `GET /user` -> `{ id, login, full_name, avatar_url, html_url, email, ... }`.
- Issue list: `GET /repos/{owner}/{repo}/issues?type=issues&state=open&limit=50&page=N&q=<query>` (`type=issues` excludes pull requests; entries carrying a `pull_request` field are skipped client-side as a backstop).
- Issue detail: `GET /repos/{owner}/{repo}/issues/{number}`.
- Issue create: `POST /repos/{owner}/{repo}/issues` with `{ title, body?, labels? }` (labels are label **names**; `body` omitted when absent).
- Issue/PR comments: `GET /repos/{owner}/{repo}/issues/{number}/comments`.
- PR list: `GET /repos/{owner}/{repo}/pulls?state=open&limit=50&page=N&q=<query>`. Gitea has no server-side source-branch filter, so when `sourceBranch` is requested the route scans `state=all` pages (cap 10 pages) and filters by `head.ref === sourceBranch` client-side, returning all matching states (open and merged).
- PR detail: `GET /repos/{owner}/{repo}/pulls/{number}`.
- PR files: `GET /repos/{owner}/{repo}/pulls/{number}/files?patch=true` (capitalized JSON fields `Filename`/`Status`/`Additions`/`Deletions`/`Patch`; a `404` on older Gitea instances falls back to `files: []`).
- PR diff: `GET /repos/{owner}/{repo}/pulls/{number}.diff` (raw text; falls back to concatenated per-file patches when it fails).
- PR commits: `GET /repos/{owner}/{repo}/pulls/{number}/commits?limit=100` (mapped to `{ sha, message, summary, author, committedAt, parents }`).
- PR reviews: `GET /repos/{owner}/{repo}/pulls/{number}/reviews?limit=100` (mapped to `{ id, state, author, submittedAt, body, commitSha }`; `state` passes through, e.g. `APPROVED`/`REQUEST_CHANGES`).
- Commit statuses: `GET /repos/{owner}/{repo}/commits/{sha}/statuses?limit=100` (the `prs/statuses` route resolves the PR `head.sha` first, then maps statuses to `{ state, name, description, url, createdAt }` with `state` lowercased).
- PR create: `POST /repos/{owner}/{repo}/pulls` with `{ title, head, base, body? }` (body omitted when absent).
- PR update: `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{ title?, body?, state? }` (undefined fields omitted; the PR number IS the issue index, so the edit-issue `state` transition applies directly).
- PR merge: `POST /repos/{owner}/{repo}/pulls/{number}/merge` with `{ Do: 'merge' | 'squash' | 'rebase' }` (`method` defaults to `'merge'`). `Do` is a string enum of the merge style — Gitea has no separate `MergeMethod` field.
- Issue comment write: `POST /repos/{owner}/{repo}/issues/{number}/comments` with `{ body }` (PRs are issues at the API level, so `prs/comment` uses the same endpoint with the PR number as the index).
- Issue update: `PATCH /repos/{owner}/{repo}/issues/{number}` with `{ title?, body?, state?, labels?, assignees?, milestone?, unset_milestone? }` (labels are label **names**, assignees are logins; `milestone` is resolved from a title to a milestone id and `null` sets `unset_milestone: true`).
- Pull review write: `POST /repos/{owner}/{repo}/pulls/{number}/reviews` with `{ event, body? }` (`event` is `APPROVED`/`REQUEST_CHANGES`/`COMMENT`).
- Milestones: `GET /repos/{owner}/{repo}/milestones?state=all&limit=50` (first page) for title-to-id resolution on issue updates.
- Repo labels: `GET /repos/{owner}/{repo}/labels?limit=100` (first page) so metadata editors can offer existing labels.
- Branches: `GET /repos/{owner}/{repo}/branches?limit=50&page=N` mapped to names, plus `GET /repos/{owner}/{repo}` for `default_branch` (Gitea branch objects carry no default flag).
- There is **no ready-for-review endpoint** in this module (Gitea has no GitLab-style ready_for_review action).
## Route contract (`/api/gitea/*`)
| Method | Path | Shape |
|---|---|---|
| GET | `/api/gitea/auth/status` | `{ connected, user?, accounts[], defaultBaseUrl? }` (`defaultBaseUrl` present when connected; the effective default — configured `gitProviders.gitea.apiBaseUrl`, else `https://codeberg.org`) |
| POST | `/api/gitea/auth/connect` | body `{ accessToken, baseUrl? }` -> `{ connected, user, accounts }`; `400` for missing/invalid token; `400` when neither a valid `baseUrl` nor a configured default exists |
| POST | `/api/gitea/auth/activate` | body `{ accountId }` -> `{ connected, user, accounts }`; `404` unknown account |
| DELETE | `/api/gitea/auth` | `{ removed }` |
| GET | `/api/gitea/me` | `{ username, id, name, avatarUrl, webUrl, email? }`; `401` when not connected |
| GET | `/api/gitea/issues/list` | `?directory&page&query` -> `{ connected, repo?, issues[], page, hasMore }` |
| GET | `/api/gitea/issues/get` | `?directory&number&owner&repo` -> `{ connected, repo?, issue }` |
| GET | `/api/gitea/issues/comments` | `?directory&number&owner&repo` -> `{ connected, repo?, comments[] }` |
| GET | `/api/gitea/prs/list` | `?directory&page&query&sourceBranch` -> `{ connected, repo?, prs[], page, hasMore }` |
| GET | `/api/gitea/pr/context` | `?directory&number&includeDiff&owner&repo` -> `{ connected, repo?, pr, comments[], files[], diff? }` |
| GET | `/api/gitea/prs/commits` | `?directory&number&owner&repo` -> `{ connected, repo?, commits[] }` |
| GET | `/api/gitea/prs/reviews` | `?directory&number&owner&repo` -> `{ connected, repo?, reviews[] }` |
| GET | `/api/gitea/prs/statuses` | `?directory&number&owner&repo` -> `{ connected, repo?, statuses[] }` (resolves the PR `head.sha` first, then lists commit statuses for that SHA) |
| POST | `/api/gitea/pr/create` | body `{ directory, title, sourceBranch, targetBranch, description? }` -> `{ connected, repo?, pr }`; `400` for missing fields or an unresolvable repo |
| PATCH | `/api/gitea/pr/update` | body `{ directory, number, title?, description?, state? }` -> `{ connected, repo?, pr }`; `404` when the PR does not exist |
| POST | `/api/gitea/pr/merge` | body `{ directory, number, method? }` -> `{ connected, merged: true }` on success; non-mergeable PRs -> the Gitea status (`405`/`409`/`422`) with `{ connected, merged: false, message }` |
| POST | `/api/gitea/issues/comment` | body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment }` |
| POST | `/api/gitea/issues/create` | body `{ directory, title, body?, labels?, owner?, repo? }` -> `{ connected, repo?, issue }` |
| PATCH | `/api/gitea/issues/update` | body `{ directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? }` -> `{ connected, repo?, issue }`; `400 'Milestone not found'` when a milestone title does not match |
| POST | `/api/gitea/prs/comment` | body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment }` (PRs are issues at the API level, so the PR number is the issue index) |
| POST | `/api/gitea/prs/review` | body `{ directory, number, event, body?, owner?, repo? }` -> `{ connected, repo?, review }`; `400` when `event` is not `APPROVED`/`REQUEST_CHANGES`/`COMMENT` |
| GET | `/api/gitea/repo/labels` | `?directory&owner&repo` -> `{ connected, repo?, labels[] }` |
| GET | `/api/gitea/repo/branches` | `?owner&repo` -> `{ branches[], defaultBranch? }` (`defaultBranch` is `null` when Gitea is disconnected or the repo has no default) |
Conventions mirror `github/routes.js` and `gitlab/routes.js`:
- Not authenticated -> `connected: false` (or `401` for `/me`).
- Missing/invalid params -> `400` with `{ error }`.
- Hard failures -> `4xx`/`5xx` with `{ error }`.
- A Gitea `429` -> `503 { error: 'Gitea rate limited' }`.
- Lazy-import pattern: route handlers import `./index.js` on first use, so the module never loads unless Gitea endpoints are hit.
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout. Write routes deliberately skip the route-level timeout (a timeout can orphan a write); the client's per-request timeout still bounds them.
- Repo targeting: `owner`/`repo` query params override the directory-local git remote; write routes also accept them in the JSON body.
## Consumers
- `packages/web/src/api/gitea.ts` calls every `/api/gitea/*` endpoint and maps them to the shared types.
- `packages/ui/src/lib/api/types.ts` defines the shared `Gitea*` response types used across web, desktop, VS Code, and mobile.
- `packages/web/scripts/gitea-live-test.ts` is a live-test harness for the raw client: run with `bun run gitea:live-test` (requires `GITEA_TOKEN`; `GITEA_BASE_URL` defaults to `https://git.example.com`). It exercises every client method against a real instance, reports PASS/WARN/FAIL/SKIP per endpoint, and runs a controlled write pass (scratch issue plus a scratch-repo PR lifecycle that is deleted afterward).
## Failure handling
- If Gitea 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 GitHub/GitLab behavior. Write routes reject an unresolvable repo with `400 { error: 'Unable to resolve Gitea repo from directory' }`.
- Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected.
- Gitea `403` on write routes means the token lacks repository write scope; they respond `400 { error: 'Your Gitea token needs write:repository scope to ...' }`.
- Milestone titles on issue updates are resolved against `GET /repos/{owner}/{repo}/milestones`; an unmatched title yields `400 { error: 'Milestone not found' }` and `null` sets `unset_milestone: true`.
- PR merge rejections (`405`/`409`/`422` from Gitea) are surfaced as `{ connected, merged: false, message }` with the Gitea status so clients can show the message without treating it as a transport error (mirrors `github/pr/merge`).
- The pull-files endpoint returning `404` (older Gitea) yields `files: []` instead of failing the whole PR context; a missing `.diff` falls back to concatenated patches.
- 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 `Gitea*` types in `packages/ui/src/lib/api/types.ts`.
- Never log tokens. Error messages must not include the access token.
- The ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub or GitLab modules.
- Gitea `GET /user` returns `login`/`full_name`/`html_url`; the route mappers accept the GitHub-style `username`/`name`/`web_url` variants too, so Forgejo versions that differ still map.
- To add further Gitea write operations, add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing issue/PR write routes and the GitHub PR write routes.
+337
View File
@@ -0,0 +1,337 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getProviderApiBaseUrl } from '../git-providers/config.js';
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, 'gitea-auth.json');
// Gitea/Forgejo are primarily self-hosted, but codeberg.org (a well-known
// public Forgejo instance) acts as the built-in default base URL. The instance
// URL is always user-provided when connecting to a different host (see
// `normalizeBaseUrl`); auth.js never invents a host for a stored account. A
// configured settings.json gitProviders.gitea.apiBaseUrl overrides the default.
/** Effective default Gitea base URL: configured settings.json value, else codeberg.org. */
export function getGiteaDefaultBaseUrl() {
return getProviderApiBaseUrl('gitea');
}
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 Gitea 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 Gitea/Forgejo base URL. Adds `https://` when no
* scheme is present, strips a trailing slash, and returns null for anything
* unparseable. There is no default base URL: self-hosted Gitea instances are
* always named explicitly by the user.
*/
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);
// No default base URL exists for Gitea; an entry without a usable instance
// URL cannot make any API call, so it is dropped.
if (!baseUrl) return null;
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 getGiteaAuth() {
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 getGiteaAuthAccounts() {
const list = readAuthList();
return list
.filter((entry) => entry?.accountId && entry?.baseUrl)
.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,
current: Boolean(entry.current),
}));
}
export function setGiteaAuth({ accessToken, baseUrl, user }) {
if (!accessToken || typeof accessToken !== 'string') {
throw new Error('accessToken is required');
}
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
throw new Error('baseUrl is required and must be a valid URL');
}
// Gitea/Forgejo `GET /user` uses `login`/`full_name`; tolerate the snake_case
// variants too so stored entries stay robust across API versions.
const normalizedUser = user && typeof user === 'object'
? {
username: typeof user.login === 'string' ? user.login : (typeof user.username === 'string' ? user.username : undefined),
name: typeof user.full_name === 'string' ? user.full_name : (typeof user.name === 'string' ? user.name : undefined),
avatarUrl: typeof user.avatar_url === 'string' ? user.avatar_url : undefined,
webUrl: typeof user.html_url === 'string' ? user.html_url : (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 activateGiteaAuth(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 clearGiteaAuth() {
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 Gitea auth file:', error);
return false;
}
}
export const GITEA_AUTH_FILE = STORAGE_FILE;
+178
View File
@@ -0,0 +1,178 @@
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-gitea-auth-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
const {
getGiteaAuth,
getGiteaAuthAccounts,
setGiteaAuth,
activateGiteaAuth,
clearGiteaAuth,
normalizeBaseUrl,
GITEA_AUTH_FILE,
} = await import('./auth.js');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
afterEach(() => {
if (fs.existsSync(GITEA_AUTH_FILE)) {
fs.unlinkSync(GITEA_AUTH_FILE);
}
});
const aliceUser = {
id: 42,
login: 'alice',
full_name: 'Alice Example',
avatar_url: 'https://gitea.example.com/avatars/alice.png',
html_url: 'https://gitea.example.com/alice',
email: 'alice@example.com',
};
describe('normalizeBaseUrl', () => {
test('adds https scheme when missing', () => {
expect(normalizeBaseUrl('gitea.example.com')).toBe('https://gitea.example.com');
});
test('strips trailing slash', () => {
expect(normalizeBaseUrl('https://gitea.example.com/')).toBe('https://gitea.example.com');
expect(normalizeBaseUrl('https://gitea.example.com/gitea/')).toBe('https://gitea.example.com/gitea');
});
test('keeps an explicit scheme', () => {
expect(normalizeBaseUrl('http://localhost:3000')).toBe('http://localhost:3000');
});
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('setGiteaAuth', () => {
test('stores an account with a host-prefixed accountId', () => {
setGiteaAuth({ accessToken: 'gitea-secret', baseUrl: 'gitea.example.com', user: aliceUser });
const auth = getGiteaAuth();
expect(auth).not.toBeNull();
expect(auth.accountId).toBe('gitea.example.com:alice');
expect(auth.baseUrl).toBe('https://gitea.example.com');
expect(auth.username).toBe('alice');
expect(auth.name).toBe('Alice Example');
expect(auth.avatarUrl).toBe('https://gitea.example.com/avatars/alice.png');
expect(auth.webUrl).toBe('https://gitea.example.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', () => {
setGiteaAuth({ accessToken: 'gitea-secret', baseUrl: 'https://gitea.example.com', user: aliceUser });
const stats = fs.statSync(GITEA_AUTH_FILE);
// 0o600 mask
expect(stats.mode & 0o777).toBe(0o600);
});
test('replaces the same account instead of duplicating it', () => {
setGiteaAuth({ accessToken: 'gitea-old', baseUrl: 'gitea.example.com', user: aliceUser });
setGiteaAuth({
accessToken: 'gitea-new',
baseUrl: 'https://gitea.example.com',
user: { ...aliceUser, full_name: 'Alice Renamed' },
});
const accounts = getGiteaAuthAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].user.name).toBe('Alice Renamed');
expect(getGiteaAuth().accessToken).toBe('gitea-new');
});
test('falls back to a token prefix accountId when username is missing', () => {
setGiteaAuth({ accessToken: 'gitea-prefixtest', baseUrl: 'gitea.example.com', user: { id: 1 } });
const accounts = getGiteaAuthAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].id).toBe('token:gitea-pr');
});
test('requires an access token', () => {
expect(() => setGiteaAuth({ baseUrl: 'gitea.example.com', user: aliceUser })).toThrow('accessToken is required');
});
test('requires a base URL (no default instance)', () => {
expect(() => setGiteaAuth({ accessToken: 'gitea-secret', user: aliceUser })).toThrow('baseUrl is required and must be a valid URL');
expect(() => setGiteaAuth({ accessToken: 'gitea-secret', baseUrl: 'not a url', user: aliceUser })).toThrow('baseUrl is required and must be a valid URL');
});
});
describe('multi-account switching', () => {
test('tracks a single current account and can switch it', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
setGiteaAuth({
accessToken: 'gitea-b',
baseUrl: 'https://gitea.other.example',
user: { ...aliceUser, login: 'bob', full_name: 'Bob' },
});
expect(getGiteaAuth().accountId).toBe('gitea.other.example:bob');
const switched = activateGiteaAuth('gitea.example.com:alice');
expect(switched).toBe(true);
expect(getGiteaAuth().accountId).toBe('gitea.example.com:alice');
expect(getGiteaAuthAccounts().find((a) => a.id === 'gitea.other.example:bob')?.current).toBe(false);
});
test('activate returns false for an unknown account', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
expect(activateGiteaAuth('gitea.example.com:nobody')).toBe(false);
expect(activateGiteaAuth('')).toBe(false);
expect(activateGiteaAuth(undefined)).toBe(false);
});
});
describe('clearGiteaAuth', () => {
test('removes the current account and deletes the file when empty', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
const removed = clearGiteaAuth();
expect(removed).toBe(true);
expect(getGiteaAuth()).toBeNull();
expect(fs.existsSync(GITEA_AUTH_FILE)).toBe(false);
});
test('keeps other accounts and promotes the first remaining', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
setGiteaAuth({
accessToken: 'gitea-b',
baseUrl: 'https://gitea.other.example',
user: { ...aliceUser, login: 'bob' },
});
clearGiteaAuth();
const accounts = getGiteaAuthAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].id).toBe('gitea.example.com:alice');
expect(accounts[0].current).toBe(true);
});
});
describe('no default base URL', () => {
test('the module exports no DEFAULT_GITEA_BASE_URL', async () => {
const module = await import('./auth.js');
expect(module.DEFAULT_GITEA_BASE_URL).toBeUndefined();
});
test('accounts always carry a real baseUrl', () => {
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
for (const account of getGiteaAuthAccounts()) {
expect(account.baseUrl).toMatch(/^https?:\/\//);
}
});
});
+63
View File
@@ -0,0 +1,63 @@
// Hand-written declaration for the plain-JS Gitea/Forgejo REST v1 client
// (client.js). Kept in sync with the client's public surface; the web package
// type-checks the gitea live-test harness which imports this module.
export interface GiteaClientPageInfo {
page: number | null;
next: string | null;
total: number | null;
hasMore: boolean;
nextUrl?: string;
}
export interface GiteaClientResponse {
status: number;
headers: Record<string, string>;
data: unknown;
page: GiteaClientPageInfo | null;
error?: string;
}
export type GiteaQuery = Record<string, string | number | boolean | null | undefined>;
export interface GiteaRequestOptions {
method?: string;
query?: GiteaQuery;
body?: unknown;
signal?: AbortSignal;
raw?: boolean;
}
export interface GiteaClient {
baseUrl: string;
request: (path: string, options?: GiteaRequestOptions) => Promise<GiteaClientResponse>;
user: () => Promise<GiteaClientResponse>;
repo: (owner: string, repo: string) => Promise<GiteaClientResponse>;
issues: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
issue: (owner: string, repo: string, number: number) => Promise<GiteaClientResponse>;
issueComments: (owner: string, repo: string, number: number, params?: GiteaQuery) => Promise<GiteaClientResponse>;
createIssueComment: (owner: string, repo: string, number: number, body: string) => Promise<GiteaClientResponse>;
createIssue: (owner: string, repo: string, params: Record<string, unknown>) => Promise<GiteaClientResponse>;
updateIssue: (owner: string, repo: string, number: number, params: Record<string, unknown>) => Promise<GiteaClientResponse>;
milestones: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
repoLabels: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
pullRequests: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
pullRequest: (owner: string, repo: string, number: number) => Promise<GiteaClientResponse>;
pullRequestDiff: (owner: string, repo: string, number: number) => Promise<GiteaClientResponse>;
pullRequestFiles: (owner: string, repo: string, number: number, params?: GiteaQuery) => Promise<GiteaClientResponse>;
pullRequestCommits: (owner: string, repo: string, number: number, params?: GiteaQuery) => Promise<GiteaClientResponse>;
pullRequestReviews: (owner: string, repo: string, number: number, params?: GiteaQuery) => Promise<GiteaClientResponse>;
createPullReview: (owner: string, repo: string, number: number, params: Record<string, unknown>) => Promise<GiteaClientResponse>;
commitStatuses: (owner: string, repo: string, sha: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
createPullRequest: (owner: string, repo: string, body: Record<string, unknown>) => Promise<GiteaClientResponse>;
updatePullRequest: (owner: string, repo: string, number: number, body: Record<string, unknown>) => Promise<GiteaClientResponse>;
mergePullRequest: (owner: string, repo: string, number: number, body: Record<string, unknown>) => Promise<GiteaClientResponse>;
branches: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
assignees: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
tags: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
}
export function createGiteaClient(options: { token: string; baseUrl: string }): GiteaClient;
export function getGiteaClientOrNull(directory?: string): GiteaClient | null;
export function isGiteaRateLimited(): boolean;
export function noteGiteaRateLimit(error: unknown): void;
+331
View File
@@ -0,0 +1,331 @@
import { getGiteaAuth } from './auth.js';
import { getProviderApiBaseUrl } from '../git-providers/config.js';
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
// Per-request timeout for every Gitea call. Self-hosted instances can hang
// under load; 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: Gitea serves 304 Not Modified for
// matching If-None-Match, so polling unchanged issues/PRs stays cheap. Keyed by
// token+URL so different identities never share responses.
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/gitlab) ----
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 Gitea 429. Accepts a fetch Response or any object
* carrying headers, honoring `Retry-After` (seconds) or `X-RateLimit-Reset`
* (Unix seconds) when present.
*/
export function noteGiteaRateLimit(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) {
// Gitea sends `X-RateLimit-Reset`; check the generic name too for robustness.
const reset = headerValue(headers, 'x-ratelimit-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(`[gitea] rate limited — pausing Gitea calls for ~${Math.round(retryMs / 1000)}s`);
}
}
export function isGiteaRateLimited() {
return Date.now() < rateLimitedUntil;
}
// ---- Response helpers ----
const joinApiUrl = (baseUrl, path) => {
const base = String(baseUrl || '').replace(/\/+$/, '');
const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : '';
return `${base}/api/v1${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 : '';
};
// Gitea paginates list endpoints via the `Link` header (rel="next") and
// reports the total via `X-Total-Count`.
const linkHeader = get('link');
const relNextMatch = linkHeader.match(/<([^>]+)>\s*;\s*rel="next"/);
const totalRaw = get('x-total-count');
const total = totalRaw ? Number(totalRaw) : null;
const parsed = {
page: null,
next: null,
total: total !== null && Number.isFinite(total) ? total : null,
hasMore: Boolean(relNextMatch),
};
if (relNextMatch) {
parsed.nextUrl = relNextMatch[1];
}
return parsed;
};
const parseData = async (response, raw) => {
const text = await response.text();
if (raw) {
return text;
}
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return null;
}
};
/**
* Create a raw-fetch Gitea/Forgejo REST v1 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: 'Gitea rate limited'`
* and records a module-level cooldown.
*/
export function createGiteaClient({ token, baseUrl }) {
const effectiveBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim().replace(/\/+$/, '') : '';
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;
const raw = options.raw === true;
if (isGiteaRateLimited()) {
return { status: 429, headers: {}, data: null, page: null, error: 'Gitea 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()}`;
}
// Gitea/Forgejo PAT auth: `Authorization: token <pat>`.
const headers = {
Authorization: `token ${token}`,
accept: raw ? 'text/plain' : '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 redirects (301/302/308) exactly once. Gitea serves them for moved
// repos/users; a manual redirect keeps our Authorization header across the hop.
// Only follow same-origin redirects to avoid leaking the token to a different host.
let redirects = 0;
const baseHost = new URL(url).host;
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();
if (new URL(nextUrl).host !== baseHost) break;
response = await conditionalFetch(nextUrl, fetchOptions);
redirects += 1;
}
const result = {
status: response.status,
headers: headersToObject(response.headers),
data: await parseData(response, raw),
page: parsePageInfo(response.headers),
};
if (response.status === 429) {
noteGiteaRateLimit(response);
result.error = 'Gitea rate limited';
}
return result;
};
return {
request,
baseUrl: effectiveBaseUrl,
user: () => request('/user'),
repo: (owner, repo) => request(`/repos/${owner}/${repo}`),
issues: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/issues`, { query: params }),
issue: (owner, repo, number) =>
request(`/repos/${owner}/${repo}/issues/${number}`),
issueComments: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { query: params }),
createIssueComment: (owner, repo, number, body) =>
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { method: 'POST', body: { body } }),
createIssue: (owner, repo, params) =>
request(`/repos/${owner}/${repo}/issues`, { method: 'POST', body: params }),
updateIssue: (owner, repo, number, params) =>
request(`/repos/${owner}/${repo}/issues/${number}`, { method: 'PATCH', body: params }),
milestones: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/milestones`, { query: params }),
repoLabels: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/labels`, { query: params }),
pullRequests: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/pulls`, { query: params }),
pullRequest: (owner, repo, number) =>
request(`/repos/${owner}/${repo}/pulls/${number}`),
pullRequestDiff: (owner, repo, number) =>
request(`/repos/${owner}/${repo}/pulls/${number}.diff`, { raw: true }),
pullRequestFiles: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/pulls/${number}/files`, { query: params }),
pullRequestCommits: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/pulls/${number}/commits`, { query: params }),
pullRequestReviews: (owner, repo, number, params = {}) =>
request(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { query: params }),
createPullReview: (owner, repo, number, params) =>
request(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { method: 'POST', body: params }),
commitStatuses: (owner, repo, sha, params = {}) =>
request(`/repos/${owner}/${repo}/commits/${sha}/statuses`, { query: params }),
createPullRequest: (owner, repo, body) =>
request(`/repos/${owner}/${repo}/pulls`, { method: 'POST', body }),
updatePullRequest: (owner, repo, number, body) =>
request(`/repos/${owner}/${repo}/pulls/${number}`, { method: 'PATCH', body }),
mergePullRequest: (owner, repo, number, body) =>
request(`/repos/${owner}/${repo}/pulls/${number}/merge`, { method: 'POST', body }),
branches: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/branches`, { query: params }),
// Assignable users (collaborators with role access + org members) are the
// mention/assign candidate set; Gitea mirrors the GitHub assignees route.
assignees: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/assignees`, { query: params }),
tags: (owner, repo, params = {}) =>
request(`/repos/${owner}/${repo}/tags`, { query: params }),
};
}
/** Picks the current account (from auth.js) token + base URL, or null. A per-project override replaces the account's base URL for that project. */
export function getGiteaClientOrNull(directory) {
const auth = getGiteaAuth();
if (!auth?.accessToken || !auth?.baseUrl) {
return null;
}
let baseUrl = auth.baseUrl;
if (directory) {
const effectiveBaseUrl = getEffectiveProviderApiBaseUrl('gitea', directory);
// Only a per-project override replaces the account's base URL; without one
// the effective value is just the global default, which stored accounts
// (an explicit baseUrl is required) already outrank.
if (effectiveBaseUrl !== null && effectiveBaseUrl !== getProviderApiBaseUrl('gitea')) {
baseUrl = effectiveBaseUrl;
}
}
return createGiteaClient({ token: auth.accessToken, baseUrl });
}
@@ -0,0 +1,372 @@
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 getGiteaClientOrNull never reads a real account.
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitea-client-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
const {
createGiteaClient,
getGiteaClientOrNull,
isGiteaRateLimited,
noteGiteaRateLimit,
} = 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('createGiteaClient request basics', () => {
test('calls {baseUrl}/api/v1{path} and sends the token Authorization header', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 42, login: 'alice' }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 'gitea-token', baseUrl: 'https://gitea.example.com' });
const result = await client.user();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/user');
expect(options.headers.Authorization).toBe('token gitea-token');
expect(result).toMatchObject({ status: 200, data: { id: 42, login: 'alice' } });
expect(result.error).toBeUndefined();
});
test('joins a custom base URL with a path without duplicating /api/v1', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com/gitea/' });
await client.issues('owner', 'repo', { state: 'open' });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/gitea/api/v1/repos/owner/repo/issues?state=open');
});
test('serializes query params and omits empty ones', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.pullRequests('owner', 'repo', { state: 'open', limit: 50, page: 2, q: '', sort: null });
const [url] = fetchMock.mock.calls[0];
const query = String(url).split('?')[1];
expect(query).toContain('state=open');
expect(query).toContain('limit=50');
expect(query).toContain('page=2');
expect(query).not.toContain('q');
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 = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.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 = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.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 = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const controller = new AbortController();
await client.branches('owner', 'repo', { limit: 50 });
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);
});
test('raw requests return the body as text', async () => {
const fetchMock = vi.fn(async () => new Response('diff --git a/src/a.ts b/src/a.ts\n', { status: 200 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.pullRequestDiff('owner', 'repo', 5);
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/5.diff');
expect(options.headers.accept).toBe('text/plain');
expect(result.status).toBe(200);
expect(result.data).toBe('diff --git a/src/a.ts b/src/a.ts\n');
});
});
describe('pagination', () => {
test('parses the Link rel=next header into the page object', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], {
headers: {
link: '<https://gitea.example.com/api/v1/repos/o/r/issues?page=3>; rel="next", <...>; rel="last"',
'x-total-count': '57',
},
}));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.issues('o', 'r', { page: 2 });
expect(result.page.hasMore).toBe(true);
expect(result.page.nextUrl).toBe('https://gitea.example.com/api/v1/repos/o/r/issues?page=3');
expect(result.page.total).toBe(57);
});
test('reports hasMore=false on the last page', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], { headers: {} }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.issues('o', 'r', { page: 1 });
expect(result.page.hasMore).toBe(false);
});
});
describe('redirect handling', () => {
test('follows a redirect exactly once, preserving the Authorization header', async () => {
const movedUrl = 'https://gitea.example.com/api/v1/repos/newowner/home/issues';
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/repos/owner/repo/issues')) {
return jsonResponse({}, { status: 301, headers: { location: '/api/v1/repos/newowner/home/issues' } });
}
if (String(url) === movedUrl) {
return jsonResponse([{ number: 1 }]);
}
return jsonResponse({}, { status: 404 });
});
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.com' });
const result = await client.issues('owner', 'repo');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result.status).toBe(200);
expect(result.data).toEqual([{ number: 1 }]);
const [, secondOptions] = fetchMock.mock.calls[1];
expect(secondOptions.headers.Authorization).toBe('token gitea-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 = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.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 = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.com' });
await client.request('/thing', { method: 'POST', body: {} });
await client.request('/thing', { method: 'POST', body: {} });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('pull request write methods', () => {
test('createPullRequest POSTs title/head/base to the pulls endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ number: 5, title: 'New PR' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.createPullRequest('owner', 'repo', {
title: 'New PR',
head: 'feat/x',
base: 'main',
});
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls');
expect(options.method).toBe('POST');
expect(options.headers['content-type']).toBe('application/json');
expect(JSON.parse(options.body)).toEqual({ title: 'New PR', head: 'feat/x', base: 'main' });
expect(result.status).toBe(201);
expect(result.data).toEqual({ number: 5, title: 'New PR' });
});
test('updatePullRequest PATCHes a JSON body to the pull request endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ number: 5, title: 'Updated' }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.updatePullRequest('owner', 'repo', 5, { title: 'Updated', body: 'Body text' });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/5');
expect(options.method).toBe('PATCH');
expect(options.headers['content-type']).toBe('application/json');
expect(JSON.parse(options.body)).toEqual({ title: 'Updated', body: 'Body text' });
});
test('mergePullRequest POSTs the merge style in Do to the merge endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ merged: true }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.mergePullRequest('owner', 'repo', 5, { Do: 'squash' });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/5/merge');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ Do: 'squash' });
});
test('write methods surface error statuses without throwing', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ message: 'Conflict' }, { status: 409 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.mergePullRequest('owner', 'repo', 5, { Do: 'merge' });
expect(result.status).toBe(409);
expect(result.data).toEqual({ message: 'Conflict' });
});
});
describe('issue, review, and repo write methods', () => {
test('createIssueComment POSTs a body to the issue comments endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 5, body: 'hi' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.createIssueComment('owner', 'repo', 7, 'Nice catch');
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7/comments');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
expect(result.status).toBe(201);
});
test('updateIssue PATCHes params to the issue endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ number: 7, title: 'Updated' }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.updateIssue('owner', 'repo', 7, { state: 'closed', labels: ['bug'], milestone: 33 });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7');
expect(options.method).toBe('PATCH');
expect(JSON.parse(options.body)).toEqual({ state: 'closed', labels: ['bug'], milestone: 33 });
});
test('createPullReview POSTs event/body to the reviews endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 101, state: 'APPROVED' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.createPullReview('owner', 'repo', 12, { event: 'APPROVED', body: 'LGTM' });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/12/reviews');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ event: 'APPROVED', body: 'LGTM' });
});
test('milestones GETs the repo milestones list', async () => {
const fetchMock = vi.fn(async () => jsonResponse([{ id: 33, title: 'v1.0' }]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.milestones('owner', 'repo', { state: 'all', limit: 50 });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/milestones?state=all&limit=50');
});
test('repoLabels GETs the repo labels list', async () => {
const fetchMock = vi.fn(async () => jsonResponse([{ id: 1, name: 'bug', color: 'd73a4a' }]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.repoLabels('owner', 'repo', { limit: 100 });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/labels?limit=100');
});
});
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 = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.com' });
const result = await client.user();
expect(result.status).toBe(429);
expect(result.error).toBe('Gitea rate limited');
expect(isGiteaRateLimited()).toBe(true);
});
test('short-circuits while the cooldown is active without calling fetch', async () => {
noteGiteaRateLimit({ headers: new Headers({ 'retry-after': '120' }) });
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 'gitea-t', baseUrl: 'https://gitea.example.com' });
const gated = await client.issues('o', 'r');
expect(gated.status).toBe(429);
expect(gated.error).toBe('Gitea rate limited');
expect(fetchMock).not.toHaveBeenCalled();
});
test('parses Retry-After seconds into the cooldown', () => {
noteGiteaRateLimit({ headers: new Headers({ 'retry-after': '120' }) });
expect(isGiteaRateLimited()).toBe(true);
});
test('honors X-RateLimit-Reset for the cooldown', () => {
noteGiteaRateLimit({ headers: new Headers({ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 60) }) });
expect(isGiteaRateLimited()).toBe(true);
});
test('getGiteaClientOrNull returns null without stored auth', () => {
expect(getGiteaClientOrNull()).toBeNull();
});
});
+22
View File
@@ -0,0 +1,22 @@
export {
getGiteaAuth,
getGiteaAuthAccounts,
setGiteaAuth,
activateGiteaAuth,
clearGiteaAuth,
normalizeBaseUrl,
GITEA_AUTH_FILE,
getGiteaDefaultBaseUrl,
} from './auth.js';
export {
createGiteaClient,
getGiteaClientOrNull,
isGiteaRateLimited,
noteGiteaRateLimit,
} from './client.js';
export {
parseGiteaRemoteUrl,
resolveGiteaRepoFromDirectory,
} from './repo.js';
+142
View File
@@ -0,0 +1,142 @@
import { getRemoteUrl } from '../git/index.js';
import { getGiteaAuthAccounts, normalizeBaseUrl } from './auth.js';
import { getEffectiveProviderApiBaseUrl, getProjectProviderFromDirectory } from '../git-providers/project-config.js';
// When no explicit host allowlist is provided, accept any host that matches the
// base URL of a stored Gitea account. Gitea/Forgejo is self-hosted, so there is
// no default host. Never github.com or gitlab.com — those belong to other
// providers and must not be classified as Gitea.
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;
}
for (const account of getGiteaAuthAccounts()) {
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 Gitea/Forgejo remote URL into `{ owner, repo, host, baseUrl, url }`.
*
* Gitea repos are flat `owner/repo` (no multi-segment namespaces). Supports:
* - `git@HOST:owner/repo.git`
* - `ssh://git@HOST/owner/repo.git`
* - `https://HOST/owner/repo(.git)`
*
* `knownHosts` (optional Set of hostnames) restricts which hosts are accepted.
* When omitted, hosts from stored auth accounts are accepted. `github.com` and
* `gitlab.com` are never accepted.
*/
export const parseGiteaRemoteUrl = (raw, knownHosts, options = {}) => {
if (typeof raw !== 'string') {
return null;
}
const value = raw.trim();
if (!value) {
return null;
}
let host = '';
let path = '';
// git@HOST:owner/repo.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' || host === 'gitlab.com') {
return null;
}
if (!options.allowAnyHost && !acceptedHosts(knownHosts).has(host)) {
return null;
}
path = path.replace(/\/+$/, '');
if (path.endsWith('.git')) {
path = path.slice(0, -4);
}
const segments = path.split('/').filter(Boolean);
// Gitea repos are flat owner/repo — exactly two segments.
if (segments.length !== 2) {
return null;
}
const owner = segments[0];
const repo = segments[1];
if (!owner || !repo) {
return null;
}
return {
owner,
repo,
host,
baseUrl: `https://${host}`,
url: `https://${host}/${owner}/${repo}`,
};
};
export async function resolveGiteaRepoFromDirectory(directory, remoteName = 'origin') {
const remoteUrl = await getRemoteUrl(directory, remoteName).catch(() => null);
if (!remoteUrl) {
return { repo: null, remoteUrl: null };
}
// A per-project API base override makes its host acceptable for directory
// resolution even when no connected account covers it.
const overrideBaseUrl = getEffectiveProviderApiBaseUrl('gitea', directory);
let knownHosts;
if (overrideBaseUrl) {
knownHosts = acceptedHosts();
try {
const host = new URL(overrideBaseUrl).hostname.toLowerCase();
if (host) {
knownHosts.add(host);
}
} catch {
// ignore a malformed override base URL
}
}
// A forced gitea provider (per-project override) accepts any remote host.
const forcedProvider = getProjectProviderFromDirectory(directory);
return {
repo: parseGiteaRemoteUrl(remoteUrl, knownHosts, { allowAnyHost: forcedProvider === 'gitea' }),
remoteUrl,
};
}
+166
View File
@@ -0,0 +1,166 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitea-repo-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
vi.mock('../git/index.js', () => ({
getRemoteUrl: vi.fn(async () => null),
}));
// Per-project overrides only apply for the directory configured with one; all
// other directories fall through to the real (global-only) resolution.
vi.mock('../git-providers/project-config.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getEffectiveProviderApiBaseUrl: vi.fn((provider, directory) => {
if (directory === '/override/project') {
return provider === 'gitea' ? 'https://gitea.override.example' : actual.getEffectiveProviderApiBaseUrl(provider, directory);
}
return actual.getEffectiveProviderApiBaseUrl(provider, directory);
}),
getProjectProviderFromDirectory: vi.fn((directory) => {
if (directory === '/forced/project') {
return 'gitea';
}
return actual.getProjectProviderFromDirectory(directory);
}),
};
});
const { parseGiteaRemoteUrl, resolveGiteaRepoFromDirectory } = await import('./repo.js');
const { getRemoteUrl } = await import('../git/index.js');
const { setGiteaAuth, clearGiteaAuth } = await import('./auth.js');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
clearGiteaAuth();
});
describe('parseGiteaRemoteUrl', () => {
test('parses scp-like git@host:owner/repo.git', () => {
expect(parseGiteaRemoteUrl('git@gitea.example.com:group/project.git', new Set(['gitea.example.com']))).toEqual({
owner: 'group',
repo: 'project',
host: 'gitea.example.com',
baseUrl: 'https://gitea.example.com',
url: 'https://gitea.example.com/group/project',
});
});
test('parses ssh:// URLs', () => {
expect(parseGiteaRemoteUrl('ssh://git@gitea.example.com/owner/proj.git', new Set(['gitea.example.com']))).toMatchObject({
owner: 'owner',
repo: 'proj',
host: 'gitea.example.com',
});
});
test('parses https URLs with and without .git suffix', () => {
expect(parseGiteaRemoteUrl('https://gitea.example.com/owner/proj.git', new Set(['gitea.example.com']))).toMatchObject({
owner: 'owner',
repo: 'proj',
host: 'gitea.example.com',
});
expect(parseGiteaRemoteUrl('https://gitea.example.com/owner/proj', new Set(['gitea.example.com']))).toMatchObject({
owner: 'owner',
repo: 'proj',
});
});
test('rejects multi-segment paths (Gitea repos are flat owner/repo)', () => {
expect(parseGiteaRemoteUrl('git@gitea.example.com:a/b/c/proj.git', new Set(['gitea.example.com']))).toBeNull();
expect(parseGiteaRemoteUrl('https://gitea.example.com/a/b/proj.git', new Set(['gitea.example.com']))).toBeNull();
});
test('rejects hosts not in knownHosts', () => {
expect(parseGiteaRemoteUrl('git@gitea.example.com:owner/app.git', new Set(['other.example.com']))).toBeNull();
});
test('accepts hosts stored in auth accounts when knownHosts is omitted', () => {
setGiteaAuth({
accessToken: 'gitea-account-test',
baseUrl: 'https://git.internal.example',
user: { id: 1, login: 'worker' },
});
const result = parseGiteaRemoteUrl('git@git.internal.example:team/app.git');
expect(result).toMatchObject({ host: 'git.internal.example', owner: 'team', repo: 'app' });
});
test('never accepts github.com or gitlab.com', () => {
expect(parseGiteaRemoteUrl('git@github.com:owner/repo.git')).toBeNull();
expect(parseGiteaRemoteUrl('git@gitlab.com:owner/repo.git')).toBeNull();
expect(parseGiteaRemoteUrl('https://github.com/owner/repo.git', new Set(['github.com']))).toBeNull();
expect(parseGiteaRemoteUrl('https://gitlab.com/owner/repo.git', new Set(['gitlab.com']))).toBeNull();
});
test('returns null for malformed input', () => {
expect(parseGiteaRemoteUrl('')).toBeNull();
expect(parseGiteaRemoteUrl('not a remote')).toBeNull();
expect(parseGiteaRemoteUrl('git@gitea.example.com:onlyone')).toBeNull();
expect(parseGiteaRemoteUrl(null)).toBeNull();
expect(parseGiteaRemoteUrl(undefined)).toBeNull();
});
});
describe('resolveGiteaRepoFromDirectory', () => {
// Gitea has no default host, so directory resolution only accepts hosts from
// stored accounts — set one up like a connected user would.
beforeEach(() => {
setGiteaAuth({
accessToken: 'gitea-dir-test',
baseUrl: 'https://gitea.example.com',
user: { id: 1, login: 'worker' },
});
});
test('resolves the repo from the origin remote', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitea.example.com:acme/widgets.git');
const { repo, remoteUrl } = await resolveGiteaRepoFromDirectory('/some/project');
expect(remoteUrl).toBe('git@gitea.example.com:acme/widgets.git');
expect(repo).toMatchObject({ owner: 'acme', repo: 'widgets', host: 'gitea.example.com' });
});
test('uses a custom remote name', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('https://gitea.example.com/acme/widgets.git');
await resolveGiteaRepoFromDirectory('/some/project', 'upstream');
expect(getRemoteUrl).toHaveBeenCalledWith('/some/project', 'upstream');
});
test('returns null repo when the remote is not Gitea', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@github.com:owner/repo.git');
const { repo, remoteUrl } = await resolveGiteaRepoFromDirectory('/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 resolveGiteaRepoFromDirectory('/some/project');
expect(repo).toBeNull();
expect(remoteUrl).toBeNull();
});
test('accepts the per-project override host for a directory with an override', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitea.override.example:team/app.git');
const { repo, remoteUrl } = await resolveGiteaRepoFromDirectory('/override/project');
expect(remoteUrl).toBe('git@gitea.override.example:team/app.git');
expect(repo).toMatchObject({ owner: 'team', repo: 'app', host: 'gitea.override.example' });
});
test('rejects the override host for a directory without an override', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitea.override.example:team/app.git');
const { repo } = await resolveGiteaRepoFromDirectory('/some/project');
expect(repo).toBeNull();
});
test('accepts any remote host when the provider is forced to gitea', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitea.internal.corp:team/app.git');
const { repo, remoteUrl } = await resolveGiteaRepoFromDirectory('/forced/project');
expect(remoteUrl).toBe('git@gitea.internal.corp:team/app.git');
expect(repo).toMatchObject({ owner: 'team', repo: 'app', host: 'gitea.internal.corp' });
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -32,18 +32,30 @@
### Device flow
- `startDeviceFlow({ clientId, scope })`: request device code.
- `exchangeDeviceCode({ clientId, deviceCode })`: poll for access token.
- `startDeviceFlow({ clientId, scope, webOrigin? })`: request device code.
- `exchangeDeviceCode({ clientId, deviceCode, webOrigin? })`: poll for access token.
### Octokit
- `getOctokitOrNull()`: current Octokit or `null`.
- `getOctokitOrNull(directory?)`: current Octokit or `null`. When `directory` is provided the API base resolution is directory-aware (see "Per-project overrides" below); without it the global base URL is used.
- `createOctokit(token, baseUrl?)`: Octokit factory; the optional `baseUrl` (GitHub Enterprise API base) is passed to the Octokit constructor.
### Repo
- `parseGitHubRemoteUrl(raw)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`.
- `parseGitHubRemoteUrl(raw, options?)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`; `options.host` / `options.webOrigin` default to `github.com` / `https://github.com` and are used for self-hosted (Enterprise) remotes.
- `resolveGitHubRepoFromDirectory(directory, remoteName)`: resolve GitHub repo from a local git remote.
## Git provider configuration
Per-provider settings come from `~/.config/openchamber/settings.json` under `gitProviders` (validated in `packages/web/server/lib/git-providers/config.js`, persisted via the settings GET/PUT routes). GitHub resolution:
- API base URL: configured `gitProviders.github.apiBaseUrl` -> default `https://api.github.com`. The configured value drives the Octokit `baseUrl` (`getOctokitOrNull`, device-flow account activation).
- Device flow web origin: derived from the API base via `githubWebOriginFromApiBase` — the public host maps to `https://github.com`; an Enterprise base (`https://host/api/v3` or `https://host/api`) maps to `https://host`.
### Per-project overrides
API base resolution is directory-aware for project-scoped routes: `getOctokitOrNull(directory)` resolves the effective base via `getEffectiveProviderApiBaseUrl('github', directory)` (in `packages/web/server/lib/git-providers/project-config.js`), which prefers a per-project `gitProviders.github.apiBaseUrl` override (stored under `projects/<projectId>.json`) over the global `settings.json` value and the built-in default. Global routes (auth/status, auth/activate, me, repo/branches) and the device flow keep using the global base URL unchanged.
## Auth storage and config
- Auth storage: `~/.config/openchamber/github-auth.json`
@@ -61,6 +73,24 @@
- The route then enriches that result with checks, mergeability, and permission-related fields.
- The client caches and shares the result between sidebar and Git view.
## Enrichment read APIs
- `GET /api/github/pulls/commits?directory&number&owner&repo` -> `{ connected, repo?, commits[] }` (via `octokit.rest.pulls.listCommits`, mapped to `{ sha, shortSha, message, summary, author, committer, committedAt, parents }`).
- `GET /api/github/pulls/timeline?directory&number&owner&repo` -> `{ connected, repo?, events[] }` (via `octokit.rest.issues.listEventsForTimeline`, each event `{ id, type, author, createdAt, body, commitSha }` with the event name lowercased).
- Both follow the `issues/comments` envelope pattern: unauthenticated -> `connected: false`, unresolvable repo -> `repo: null` with an empty list, `429` -> `503 { error: 'GitHub rate limited' }`, other provider `4xx` -> `502`.
## Write APIs
All write routes accept an optional `owner`/`repo` in the body to target a fork-network repo; otherwise the repo is resolved from `directory`. Unauthenticated -> `{ connected: false }`; `429` -> `503 { error: 'GitHub rate limited' }`; generic failures -> `500` with a generic error (raw upstream text is never leaked).
- `POST /api/github/issues/comment` — body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment? }` (via `octokit.rest.issues.createComment`, mapped to `GitHubIssueComment`).
- `POST /api/github/issues/create` — body `{ directory, title, body?, labels?, owner?, repo? }` -> `{ connected, repo?, issue? }` (via `octokit.rest.issues.create`; `labels` is a full-set list of names).
- `PATCH /api/github/issues/update` — body `{ directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? }` -> `{ connected, repo?, issue? }` (via `octokit.rest.issues.update`; `labels`/`assignees` replace the full set, `milestone` is a title resolved to a milestone number — `400 { error: 'Milestone not found' }` when it matches nothing, `null` clears it). Also works for pull requests (PRs are issues), so it serves PR metadata/state changes too.
- `POST /api/github/pulls/comment` — same input/result shape as `issues/comment`; posts to the PR's issue thread via `octokit.rest.issues.createComment`. Invalidates the PR context cache.
- `POST /api/github/pulls/review-comment` — body `{ directory, number, body, inReplyToId?, path?, line?, owner?, repo? }` -> `{ connected, repo?, comment? }` (via `octokit.rest.pulls.createReviewComment`). With `inReplyToId` it is a reply; otherwise `path` + `line` are required and the PR head commit is resolved first. Invalidates the PR context cache.
- `POST /api/github/pulls/review` — body `{ directory, number, event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body?, owner?, repo? }` -> `{ connected, repo?, review? }` (via `octokit.rest.pulls.createReview`, mapped to `{ id, state, author, submittedAt, body, commitSha }`). Invalidates the PR context cache.
- `POST /api/github/pr/update` — existing route extended with optional `state`, `draft`, `labels`, `assignees`, `milestone`. When any extended field is present it branches to `octokit.rest.issues.update` (milestone title -> number; `draft` applied separately via `octokit.rest.pulls.update`); title/body-only updates keep using `pulls.update`. Invalidates the PR context cache and the repo pulls cache.
## Consumers of PR data
- `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`.
@@ -1,6 +1,5 @@
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
const DEFAULT_WEB_ORIGIN = 'https://github.com';
const encodeForm = (params) => {
const body = new URLSearchParams();
@@ -32,16 +31,19 @@ async function postForm(url, params) {
return payload;
}
export async function startDeviceFlow({ clientId, scope }) {
return postForm(DEVICE_CODE_URL, {
const deviceCodeUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/device/code`;
const accessTokenUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/oauth/access_token`;
export async function startDeviceFlow({ clientId, scope, webOrigin }) {
return postForm(deviceCodeUrl(webOrigin), {
client_id: clientId,
scope,
});
}
export async function exchangeDeviceCode({ clientId, deviceCode }) {
export async function exchangeDeviceCode({ clientId, deviceCode, webOrigin }) {
// GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
const payload = await postForm(ACCESS_TOKEN_URL, {
const payload = await postForm(accessTokenUrl(webOrigin), {
client_id: clientId,
device_code: deviceCode,
grant_type: DEVICE_GRANT_TYPE,
+5
View File
@@ -28,3 +28,8 @@ export {
parseGitHubRemoteUrl,
resolveGitHubRepoFromDirectory,
} from './repo/index.js';
export {
getProviderApiBaseUrl,
githubWebOriginFromApiBase,
} from '../git-providers/config.js';
+10 -4
View File
@@ -1,6 +1,8 @@
import { Octokit } from '@octokit/rest';
import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js';
import { getGhCliToken } from './gh-cli-credential.js';
import { getProviderApiBaseUrl } from '../git-providers/config.js';
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
// Per-request timeout for every GitHub call. Octokit v22 uses native fetch,
// which has no built-in timeout — without this, a stuck connection hangs until
@@ -69,16 +71,20 @@ const createConditionalFetch = (token) => async (url, options = {}) => {
};
/** Create an Octokit instance with per-request timeout + ETag revalidation. */
export function createOctokit(token) {
return new Octokit({ auth: token, request: { fetch: createConditionalFetch(token) } });
export function createOctokit(token, baseUrl) {
return new Octokit({
auth: token,
...(baseUrl ? { baseUrl } : {}),
request: { fetch: createConditionalFetch(token) },
});
}
export function getOctokitOrNull() {
export function getOctokitOrNull(directory) {
const auth = getGitHubAuth();
const ghToken = !isGhCliDisabled() ? getGhCliToken() : null;
const token = isGhCliActive() ? ghToken || auth?.accessToken : auth?.accessToken || ghToken;
if (!token) {
return null;
}
return createOctokit(token);
return createOctokit(token, directory ? getEffectiveProviderApiBaseUrl('github', directory) : getProviderApiBaseUrl('github'));
}
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
// getOctokitOrNull reads auth + config modules; mock them all so the base URL
// resolution can be asserted without a real token, data dir, or Octokit client.
const mockState = vi.hoisted(() => ({
octokitConfigs: [],
getGitHubAuth: vi.fn(),
isGhCliActive: vi.fn(),
isGhCliDisabled: vi.fn(),
getGhCliToken: vi.fn(),
getProviderApiBaseUrl: vi.fn(),
getEffectiveProviderApiBaseUrl: vi.fn(),
}));
vi.mock('@octokit/rest', () => ({
Octokit: class {
constructor(config) {
mockState.octokitConfigs.push(config);
}
},
}));
vi.mock('./auth.js', () => ({
getGitHubAuth: mockState.getGitHubAuth,
isGhCliActive: mockState.isGhCliActive,
isGhCliDisabled: mockState.isGhCliDisabled,
}));
vi.mock('./gh-cli-credential.js', () => ({
getGhCliToken: mockState.getGhCliToken,
}));
vi.mock('../git-providers/config.js', () => ({
getProviderApiBaseUrl: mockState.getProviderApiBaseUrl,
}));
vi.mock('../git-providers/project-config.js', () => ({
getEffectiveProviderApiBaseUrl: mockState.getEffectiveProviderApiBaseUrl,
}));
const { getOctokitOrNull } = await import('./octokit.js');
beforeEach(() => {
mockState.octokitConfigs.length = 0;
mockState.getGitHubAuth.mockReset();
mockState.isGhCliActive.mockReset().mockReturnValue(false);
mockState.isGhCliDisabled.mockReset().mockReturnValue(false);
mockState.getGhCliToken.mockReset().mockReturnValue(null);
mockState.getProviderApiBaseUrl.mockReset();
mockState.getEffectiveProviderApiBaseUrl.mockReset();
});
describe('getOctokitOrNull base URL resolution', () => {
test('uses the global base URL without a directory and never consults project overrides', () => {
mockState.getGitHubAuth.mockReturnValue({ accessToken: 'ghp-test' });
mockState.getProviderApiBaseUrl.mockReturnValue('https://api.github.com');
const octokit = getOctokitOrNull();
expect(octokit).not.toBeNull();
expect(mockState.octokitConfigs).toHaveLength(1);
expect(mockState.octokitConfigs[0].auth).toBe('ghp-test');
expect(mockState.octokitConfigs[0].baseUrl).toBe('https://api.github.com');
expect(mockState.getEffectiveProviderApiBaseUrl).not.toHaveBeenCalled();
});
test('resolves the per-project override base URL for a directory', () => {
mockState.getGitHubAuth.mockReturnValue({ accessToken: 'ghp-test' });
mockState.getEffectiveProviderApiBaseUrl.mockReturnValue('https://github.enterprise.example');
const octokit = getOctokitOrNull('/work/override-project');
expect(octokit).not.toBeNull();
expect(mockState.getEffectiveProviderApiBaseUrl).toHaveBeenCalledWith('github', '/work/override-project');
expect(mockState.octokitConfigs[0].baseUrl).toBe('https://github.enterprise.example');
});
test('returns null without a token', () => {
mockState.getGitHubAuth.mockReturnValue(null);
expect(getOctokitOrNull('/work/override-project')).toBeNull();
expect(mockState.octokitConfigs).toHaveLength(0);
});
});
+5 -2
View File
@@ -2,6 +2,9 @@ import { stat } from 'node:fs/promises';
import { getRemotes, getStatus } from '../git/index.js';
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
import { noteIfGitHubRateLimit } from './rate-limit.js';
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../git-providers/config.js';
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
const directoryExists = async (dir) => {
if (!dir) return false;
@@ -295,7 +298,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
pushCandidate({
owner: parent.owner.login,
repo: parent.name,
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`,
}, candidate.remoteName, candidate.priority + 0.1);
}
@@ -304,7 +307,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
pushCandidate({
owner: source.owner.login,
repo: source.name,
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`,
}, candidate.remoteName, candidate.priority + 0.2);
}
}
@@ -1,4 +1,7 @@
import { resolveGitHubRepoFromDirectory } from './index.js';
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js';
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
const REPO_METADATA_TTL_MS = 5 * 60_000;
const REPO_METADATA_CACHE_MAX_ENTRIES = 200;
@@ -75,7 +78,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
result.push({
owner: parent.owner.login,
repo: parent.name,
url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`,
url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`,
source: 'upstream',
});
}
@@ -89,7 +92,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
result.push({
owner: source.owner.login,
repo: source.name,
url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`,
url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`,
source: 'upstream',
});
}
+25 -10
View File
@@ -1,6 +1,17 @@
import { getRemoteUrl } from '../../git/index.js';
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js';
export const parseGitHubRemoteUrl = (raw) => {
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
const webHostFromOrigin = (webOrigin) => {
try {
return new URL(webOrigin).hostname || 'github.com';
} catch {
return 'github.com';
}
};
export const parseGitHubRemoteUrl = (raw, { host = 'github.com', webOrigin = 'https://github.com' } = {}) => {
if (typeof raw !== 'string') {
return null;
}
@@ -10,34 +21,36 @@ export const parseGitHubRemoteUrl = (raw) => {
}
// git@github.com:OWNER/REPO.git
if (value.startsWith('git@github.com:')) {
const rest = value.slice('git@github.com:'.length);
const scpPrefix = `git@${host}:`;
if (value.startsWith(scpPrefix)) {
const rest = value.slice(scpPrefix.length);
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
}
// ssh://git@github.com/OWNER/REPO.git
if (value.startsWith('ssh://git@github.com/')) {
const rest = value.slice('ssh://git@github.com/'.length);
const sshPrefix = `ssh://git@${host}/`;
if (value.startsWith(sshPrefix)) {
const rest = value.slice(sshPrefix.length);
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
}
// https://github.com/OWNER/REPO(.git)
try {
const url = new URL(value);
if (url.hostname !== 'github.com') {
if (url.hostname !== host) {
return null;
}
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
} catch {
return null;
}
@@ -48,8 +61,10 @@ export async function resolveGitHubRepoFromDirectory(directory, remoteName = 'or
if (!remoteUrl) {
return { repo: null, remoteUrl: null };
}
const webOrigin = getGitHubWebOrigin();
const host = webHostFromOrigin(webOrigin);
return {
repo: parseGitHubRemoteUrl(remoteUrl),
repo: parseGitHubRemoteUrl(remoteUrl, { host, webOrigin }),
remoteUrl,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,874 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
// The GitHub route handlers lazy-import ./index.js (via getGitHubLibraries)
// for auth + repo resolution, so mocking the module is enough to exercise the
// read routes without real GitHub credentials or a temp data dir.
const mockState = vi.hoisted(() => ({
getOctokitOrNull: vi.fn(),
clearGitHubAuth: vi.fn(),
octokit: {
rest: {
pulls: {
listCommits: vi.fn(),
get: vi.fn(),
update: vi.fn(),
createReview: vi.fn(),
createReviewComment: vi.fn(),
listReviewComments: vi.fn(),
listFiles: vi.fn(),
},
issues: {
listEventsForTimeline: vi.fn(),
createComment: vi.fn(),
create: vi.fn(),
update: vi.fn(),
listMilestonesForRepo: vi.fn(),
listComments: vi.fn(),
listAssignees: vi.fn(),
listLabelsForRepo: vi.fn(),
},
repos: {
listBranches: vi.fn(),
listTags: vi.fn(),
},
},
},
}));
vi.mock('./index.js', () => ({
getOctokitOrNull: mockState.getOctokitOrNull,
clearGitHubAuth: mockState.clearGitHubAuth,
resolveGitHubRepoFromDirectory: vi.fn(async () => ({
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
})),
}));
const { registerGitHubRoutes } = await import('./routes.js');
const createApp = () => {
const app = express();
app.use(express.json());
registerGitHubRoutes(app);
return app;
};
beforeEach(() => {
mockState.getOctokitOrNull.mockReset();
mockState.clearGitHubAuth.mockReset();
mockState.octokit.rest.pulls.listCommits.mockReset();
mockState.octokit.rest.pulls.get.mockReset();
mockState.octokit.rest.pulls.update.mockReset();
mockState.octokit.rest.pulls.createReview.mockReset();
mockState.octokit.rest.pulls.createReviewComment.mockReset();
mockState.octokit.rest.pulls.listReviewComments.mockReset();
mockState.octokit.rest.pulls.listFiles.mockReset();
mockState.octokit.rest.issues.listEventsForTimeline.mockReset();
mockState.octokit.rest.issues.createComment.mockReset();
mockState.octokit.rest.issues.create.mockReset();
mockState.octokit.rest.issues.update.mockReset();
mockState.octokit.rest.issues.listMilestonesForRepo.mockReset();
mockState.octokit.rest.issues.listComments.mockReset();
mockState.octokit.rest.issues.listAssignees.mockReset();
mockState.octokit.rest.issues.listLabelsForRepo.mockReset();
mockState.octokit.rest.repos.listBranches.mockReset();
mockState.octokit.rest.repos.listTags.mockReset();
mockState.getOctokitOrNull.mockImplementation(() => mockState.octokit);
});
describe('GitHub pull request enrichment routes', () => {
test('pulls/commits maps commits with shortSha and summary', async () => {
mockState.octokit.rest.pulls.listCommits.mockResolvedValue({
data: [
{
sha: 'abc123def4567890',
commit: {
message: 'Add the API\n\nAdds the public API',
committer: { date: '2026-01-01T10:00:00Z' },
},
author: { login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
committer: null,
parents: [{ sha: 'parent-one' }],
},
],
});
const app = createApp();
const response = await request(app).get('/api/github/pulls/commits?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
commits: [
{
sha: 'abc123def4567890',
shortSha: 'abc123d',
message: 'Add the API\n\nAdds the public API',
summary: 'Add the API',
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
committer: null,
committedAt: '2026-01-01T10:00:00Z',
parents: ['parent-one'],
},
],
});
expect(mockState.octokit.rest.pulls.listCommits).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
per_page: 100,
});
});
test('pulls/timeline maps timeline events with lowercased types', async () => {
mockState.octokit.rest.issues.listEventsForTimeline.mockResolvedValue({
data: [
{ id: 1, event: 'committed', actor: { login: 'alice', id: 42, avatar_url: 'u' }, created_at: '2026-01-01T10:00:00Z', commit_id: 'abc123def4567890' },
{ id: 2, event: 'CLOSED', actor: { login: 'alice', id: 42, avatar_url: 'u' }, created_at: '2026-01-02T10:00:00Z' },
{ id: 3, event: 'reviewed', actor: null, created_at: '2026-01-03T10:00:00Z', body: 'LGTM' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/pulls/timeline?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
events: [
{ id: '1', type: 'committed', author: { login: 'alice', id: 42 }, createdAt: '2026-01-01T10:00:00Z', commitSha: 'abc123def4567890' },
{ id: '2', type: 'closed', author: { login: 'alice', id: 42 } },
{ id: '3', type: 'reviewed', author: null, body: 'LGTM' },
],
});
expect(mockState.octokit.rest.issues.listEventsForTimeline).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
issue_number: 9,
per_page: 100,
});
});
test('pulls/commits returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app).get('/api/github/pulls/commits?directory=%2Ftmp%2Fwork&number=9');
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
test('pulls/commits requires directory and number', async () => {
const app = createApp();
const response = await request(app).get('/api/github/pulls/commits?directory=%2Ftmp%2Fwork');
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory and number are required' });
});
});
describe('GitHub write routes', () => {
test('issues/comment creates a comment and returns the envelope', async () => {
mockState.octokit.rest.issues.createComment.mockResolvedValue({
data: {
id: 1001,
html_url: 'https://github.com/owner/repo/issues/7#issuecomment-1001',
body: 'Hello',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/issues/comment')
.send({ directory: '/tmp/work', number: 7, body: 'Hello' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
comment: {
id: 1001,
url: 'https://github.com/owner/repo/issues/7#issuecomment-1001',
body: 'Hello',
createdAt: '2026-01-01T10:00:00Z',
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
},
});
expect(mockState.octokit.rest.issues.createComment).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
issue_number: 7,
body: 'Hello',
});
});
test('issues/comment returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app)
.post('/api/github/issues/comment')
.send({ directory: '/tmp/work', number: 7, body: 'Hello' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
});
test('issues/comment requires directory, number, and body', async () => {
const app = createApp();
const response = await request(app)
.post('/api/github/issues/comment')
.send({ directory: '/tmp/work', number: 7 });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory, number, body are required' });
});
test('issues/update passes state and labels through', async () => {
mockState.octokit.rest.issues.update.mockResolvedValue({
data: {
number: 7,
title: 'Bug',
body: 'desc',
html_url: 'https://github.com/owner/repo/issues/7',
state: 'closed',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-02T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'u' },
labels: [{ name: 'bug', color: 'd73a4a' }],
assignees: [{ login: 'bob', id: 43, avatar_url: 'u' }],
milestone: { title: 'v1.0', state: 'open' },
comments: 3,
},
});
const app = createApp();
const response = await request(app)
.patch('/api/github/issues/update')
.send({ directory: '/tmp/work', number: 7, state: 'closed', labels: ['bug'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
issue: {
number: 7,
title: 'Bug',
state: 'closed',
labels: [{ name: 'bug', color: 'd73a4a' }],
assignees: [{ login: 'bob', id: 43 }],
milestone: { title: 'v1.0', state: 'open' },
commentsCount: 3,
},
});
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
issue_number: 7,
state: 'closed',
labels: ['bug'],
});
});
test('issues/update resolves milestone title to a number (case-insensitive)', async () => {
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({
data: [{ number: 5, title: 'v1.0', state: 'open' }],
});
mockState.octokit.rest.issues.update.mockResolvedValue({
data: { number: 7, title: 'Bug', html_url: 'u', state: 'open', user: null, labels: [], assignees: [], milestone: null, body: '' },
});
const app = createApp();
const response = await request(app)
.patch('/api/github/issues/update')
.send({ directory: '/tmp/work', number: 7, milestone: 'V1.0' });
expect(response.status).toBe(200);
expect(mockState.octokit.rest.issues.listMilestonesForRepo).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
state: 'all',
per_page: 100,
});
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
expect.objectContaining({ milestone: 5 })
);
});
test('issues/update returns 400 when the milestone title matches nothing', async () => {
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({ data: [] });
const app = createApp();
const response = await request(app)
.patch('/api/github/issues/update')
.send({ directory: '/tmp/work', number: 7, milestone: 'nope' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Milestone not found' });
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
});
test('issues/update passes milestone null through to clear it', async () => {
mockState.octokit.rest.issues.update.mockResolvedValue({
data: { number: 7, title: 'Bug', html_url: 'u', state: 'open', user: null, labels: [], assignees: [], milestone: null, body: '' },
});
const app = createApp();
const response = await request(app)
.patch('/api/github/issues/update')
.send({ directory: '/tmp/work', number: 7, milestone: null });
expect(response.status).toBe(200);
expect(mockState.octokit.rest.issues.listMilestonesForRepo).not.toHaveBeenCalled();
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
expect.objectContaining({ milestone: null })
);
});
test('pulls/comment posts to the PR issue thread', async () => {
mockState.octokit.rest.issues.createComment.mockResolvedValue({
data: {
id: 2001,
html_url: 'https://github.com/owner/repo/pull/9#issuecomment-2001',
body: 'Thanks',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/comment')
.send({ directory: '/tmp/work', number: 9, body: 'Thanks' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
comment: { id: 2001, body: 'Thanks', author: { login: 'alice', id: 42 } },
});
expect(mockState.octokit.rest.issues.createComment).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
issue_number: 9,
body: 'Thanks',
});
});
test('pulls/review-comment creates a reply when inReplyToId is provided', async () => {
mockState.octokit.rest.pulls.createReviewComment.mockResolvedValue({
data: {
id: 3001,
html_url: 'u',
body: 'reply',
path: 'src/a.ts',
line: 3,
position: null,
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/review-comment')
.send({ directory: '/tmp/work', number: 9, body: 'reply', inReplyToId: 2999 });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
comment: { id: 3001, body: 'reply', path: 'src/a.ts', line: 3 },
});
expect(mockState.octokit.rest.pulls.createReviewComment).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
body: 'reply',
in_reply_to_id: 2999,
});
expect(mockState.octokit.rest.pulls.get).not.toHaveBeenCalled();
});
test('pulls/review-comment resolves the PR head sha for a new inline comment', async () => {
mockState.octokit.rest.pulls.get.mockResolvedValue({ data: { head: { sha: 'abc123def4567890' } } });
mockState.octokit.rest.pulls.createReviewComment.mockResolvedValue({
data: {
id: 3002,
html_url: 'u',
body: 'nit',
path: 'src/a.ts',
line: 5,
position: 1,
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/review-comment')
.send({ directory: '/tmp/work', number: 9, body: 'nit', path: 'src/a.ts', line: 5 });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
comment: { id: 3002, body: 'nit', path: 'src/a.ts', line: 5, position: 1 },
});
expect(mockState.octokit.rest.pulls.get).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
});
expect(mockState.octokit.rest.pulls.createReviewComment).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
body: 'nit',
commit_id: 'abc123def4567890',
path: 'src/a.ts',
line: 5,
});
});
test('pulls/review-comment requires path and line for a new inline comment', async () => {
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/review-comment')
.send({ directory: '/tmp/work', number: 9, body: 'nit' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'path and line are required for a new review comment' });
expect(mockState.octokit.rest.pulls.createReviewComment).not.toHaveBeenCalled();
});
test('pulls/review maps the submitted review and invalidates the PR context cache', async () => {
mockState.octokit.rest.pulls.get.mockResolvedValue({
data: {
number: 9,
title: 'T',
body: '',
html_url: 'u',
state: 'open',
draft: false,
base: { ref: 'main' },
head: { ref: 'feature' },
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
mockState.octokit.rest.issues.listComments.mockResolvedValue({ data: [] });
mockState.octokit.rest.pulls.listReviewComments.mockResolvedValue({ data: [] });
mockState.octokit.rest.pulls.listFiles.mockResolvedValue({ data: [] });
const app = createApp();
await request(app).get('/api/github/pulls/context?directory=%2Ftmp%2Fwork&number=9');
const pullsGetCallsAfterContext = mockState.octokit.rest.pulls.get.mock.calls.length;
mockState.octokit.rest.pulls.createReview.mockResolvedValue({
data: {
id: 4001,
state: 'APPROVED',
submitted_at: '2026-01-01T10:00:00Z',
body: 'LGTM',
commit_id: 'abc123def4567890',
user: { login: 'alice', id: 42, avatar_url: 'u' },
},
});
const reviewResponse = await request(app)
.post('/api/github/pulls/review')
.send({ directory: '/tmp/work', number: 9, event: 'APPROVE', body: 'LGTM' });
expect(reviewResponse.status).toBe(200);
expect(reviewResponse.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
review: {
id: '4001',
state: 'APPROVED',
submittedAt: '2026-01-01T10:00:00Z',
body: 'LGTM',
commitSha: 'abc123def4567890',
author: { login: 'alice', id: 42 },
},
});
expect(mockState.octokit.rest.pulls.createReview).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
event: 'APPROVE',
body: 'LGTM',
});
// The PR context cache must have been invalidated: the next context fetch
// re-resolves the PR instead of serving the cached copy.
await request(app).get('/api/github/pulls/context?directory=%2Ftmp%2Fwork&number=9');
expect(mockState.octokit.rest.pulls.get.mock.calls.length).toBe(pullsGetCallsAfterContext + 1);
});
test('pulls/review requires directory, number, and event', async () => {
const app = createApp();
const response = await request(app)
.post('/api/github/pulls/review')
.send({ directory: '/tmp/work', number: 9 });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory, number, event are required' });
});
test('pr/update branches to issues.update and applies draft via pulls.update', async () => {
mockState.octokit.rest.issues.update.mockResolvedValue({
data: {
number: 9,
title: 'T',
body: '',
html_url: 'u',
state: 'open',
draft: false,
base: { ref: 'main' },
head: { ref: 'feature' },
mergeable: true,
mergeable_state: 'clean',
user: null,
labels: [{ name: 'bug', color: 'd73a4a' }],
assignees: [],
milestone: null,
},
});
mockState.octokit.rest.pulls.update.mockResolvedValue({
data: {
number: 9,
title: 'T',
body: '',
html_url: 'u',
state: 'open',
draft: true,
base: { ref: 'main' },
head: { ref: 'feature' },
mergeable: true,
mergeable_state: 'clean',
user: null,
labels: [{ name: 'bug', color: 'd73a4a' }],
assignees: [],
milestone: null,
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pr/update')
.send({
directory: '/tmp/work',
number: 9,
title: 'T',
state: 'closed',
draft: true,
labels: ['bug'],
assignees: ['alice'],
milestone: null,
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ number: 9, state: 'open', draft: true });
expect(mockState.octokit.rest.issues.update).toHaveBeenCalledWith(
expect.objectContaining({
owner: 'owner',
repo: 'repo',
issue_number: 9,
state: 'closed',
labels: ['bug'],
assignees: ['alice'],
milestone: null,
})
);
expect(mockState.octokit.rest.pulls.update).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
draft: true,
});
});
test('pr/update keeps title/body on pulls.update when no extended fields are present', async () => {
mockState.octokit.rest.pulls.update.mockResolvedValue({
data: {
number: 9,
title: 'New title',
body: '',
html_url: 'u',
state: 'open',
draft: false,
base: { ref: 'main' },
head: { ref: 'feature' },
mergeable: true,
mergeable_state: 'clean',
user: null,
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/pr/update')
.send({ directory: '/tmp/work', number: 9, title: 'New title' });
expect(response.status).toBe(200);
expect(mockState.octokit.rest.pulls.update).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
pull_number: 9,
title: 'New title',
});
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
});
test('pr/update returns 400 when the milestone title matches nothing', async () => {
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({ data: [] });
const app = createApp();
const response = await request(app)
.post('/api/github/pr/update')
.send({ directory: '/tmp/work', number: 9, title: 'T', milestone: 'nope' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Milestone not found' });
expect(mockState.octokit.rest.issues.update).not.toHaveBeenCalled();
});
});
describe('GitHub issues/create route', () => {
test('issues/create calls issues.create and returns the created issue', async () => {
mockState.octokit.rest.issues.create.mockResolvedValue({
data: {
number: 12,
title: 'Add feature',
html_url: 'https://github.com/owner/repo/issues/12',
state: 'open',
body: 'The body',
created_at: '2026-01-01T10:00:00Z',
updated_at: '2026-01-01T10:00:00Z',
user: { login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
labels: [{ name: 'bug', color: 'd73a4a' }],
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work', title: 'Add feature', body: 'The body', labels: ['bug'] });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
connected: true,
repo: { owner: 'owner', repo: 'repo' },
issue: {
number: 12,
title: 'Add feature',
url: 'https://github.com/owner/repo/issues/12',
state: 'open',
body: 'The body',
author: { login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' },
labels: [{ name: 'bug', color: 'd73a4a' }],
},
});
expect(mockState.octokit.rest.issues.create).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Add feature',
body: 'The body',
labels: ['bug'],
});
});
test('issues/create omits body and labels when not provided', async () => {
mockState.octokit.rest.issues.create.mockResolvedValue({
data: {
number: 13,
title: 'Title only',
html_url: 'https://github.com/owner/repo/issues/13',
state: 'open',
user: null,
},
});
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work', title: 'Title only' });
expect(response.status).toBe(200);
expect(response.body.issue.title).toBe('Title only');
expect(mockState.octokit.rest.issues.create).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Title only',
});
});
test('issues/create returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work', title: 'Hi' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false });
expect(mockState.octokit.rest.issues.create).not.toHaveBeenCalled();
});
test('issues/create requires directory and title', async () => {
const app = createApp();
const response = await request(app)
.post('/api/github/issues/create')
.send({ directory: '/tmp/work' });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'directory and title are required' });
});
});
describe('GitHub rich lookup routes', () => {
describe('users/search', () => {
test('maps assignable users and filters by query', async () => {
mockState.octokit.rest.issues.listAssignees.mockResolvedValue({
data: [
{ login: 'alice', id: 42, avatar_url: 'https://avatars.githubusercontent.com/u/42' },
{ login: 'bob', id: 43, avatar_url: 'https://avatars.githubusercontent.com/u/43' },
{ login: 'carol', id: 44, avatar_url: null, name: 'Carol Coder' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/users/search?directory=%2Ftmp%2Fwork&query=al');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
users: [{ login: 'alice', id: 42, avatarUrl: 'https://avatars.githubusercontent.com/u/42' }],
});
expect(mockState.octokit.rest.issues.listAssignees).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
});
});
test('returns connected:false when not authenticated', async () => {
mockState.getOctokitOrNull.mockImplementation(() => null);
const app = createApp();
const response = await request(app).get('/api/github/users/search?directory=%2Ftmp%2Fwork&query=al');
expect(response.status).toBe(200);
expect(response.body).toEqual({ connected: false, users: [] });
});
});
describe('labels/search', () => {
test('maps repo labels and filters by query', async () => {
mockState.octokit.rest.issues.listLabelsForRepo.mockResolvedValue({
data: [
{ name: 'bug', color: 'd73a4a' },
{ name: 'feature', color: '0e8a16' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/labels/search?directory=%2Ftmp%2Fwork&query=bug');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
labels: [{ name: 'bug', color: 'd73a4a' }],
});
expect(mockState.octokit.rest.issues.listLabelsForRepo).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
});
});
});
describe('milestones/search', () => {
test('maps milestone titles and states', async () => {
mockState.octokit.rest.issues.listMilestonesForRepo.mockResolvedValue({
data: [
{ title: 'v1.0', state: 'open' },
{ title: 'v2.0', state: 'closed' },
],
});
const app = createApp();
const response = await request(app).get('/api/github/milestones/search?directory=%2Ftmp%2Fwork&query=v1');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
milestones: [{ title: 'v1.0', state: 'open' }],
});
expect(mockState.octokit.rest.issues.listMilestonesForRepo).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
state: 'all',
per_page: 100,
});
});
});
describe('branches/search', () => {
test('aggregates branch names across pages and filters by query', async () => {
mockState.octokit.rest.repos.listBranches
.mockResolvedValueOnce({ data: [{ name: 'main' }, { name: 'feat/x' }] })
.mockResolvedValueOnce({ data: [] });
const app = createApp();
const response = await request(app).get('/api/github/branches/search?directory=%2Ftmp%2Fwork&query=main');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
branches: ['main'],
});
expect(mockState.octokit.rest.repos.listBranches).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
page: 1,
});
});
});
describe('tags/search', () => {
test('maps tag names and filters by query', async () => {
mockState.octokit.rest.repos.listTags.mockResolvedValue({
data: [{ name: 'v1.0.0' }, { name: 'v1.1.0' }],
});
const app = createApp();
const response = await request(app).get('/api/github/tags/search?directory=%2Ftmp%2Fwork&query=v1.0');
expect(response.status).toBe(200);
expect(response.body).toEqual({
connected: true,
repo: { owner: 'owner', repo: 'repo', url: 'https://github.com/owner/repo' },
tags: ['v1.0.0'],
});
expect(mockState.octokit.rest.repos.listTags).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
per_page: 100,
});
});
});
});
@@ -0,0 +1,152 @@
# GitLab Module Documentation
## Purpose
- This module owns GitLab auth (Personal Access Token), raw REST v4 client access, remote-URL repo resolution, and GitLab issue / merge-request (MR) APIs for OpenChamber, including MR create/update/merge writes.
- 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, and create, edit, and merge merge requests.
- 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` (compatibility constant).
- `getGitLabDefaultBaseUrl()`: effective default base URL — configured `gitProviders.gitlab.apiBaseUrl` from `settings.json` if present, else `https://gitlab.com`. Used for stored-account fallback and the auth status/connect `defaultBaseUrl` fields.
### 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)`, `createIssueNote(path, iid, body)`, `updateIssue(path, iid, params)`, `mergeRequests(path, params)`, `mergeRequest(path, iid)`, `mergeRequestDiffs(path, iid, params)`, `createMergeRequest(path, body)`, `updateMergeRequest(path, iid, body)`, `mergeMergeRequest(path, iid, body)`, `createMrNote(path, iid, body)`, `approveMr(path, iid)`, `milestones(path, 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) -> effective default via `getGitLabDefaultBaseUrl()` (configured `settings.json` `gitProviders.gitlab.apiBaseUrl`, else `https://gitlab.com`).
- Per-project overrides: data routes resolve a directory-scoped API base via `getEffectiveProviderApiBaseUrl('gitlab', directory)` (in `packages/web/server/lib/git-providers/project-config.js`). A per-project `gitProviders.gitlab.apiBaseUrl` override (stored under `projects/<projectId>.json`) replaces the global default for that project's routes, and its host is accepted for directory-to-repo resolution (`resolveGitLabRepoFromDirectory`); a connected account whose host matches the remote keeps its own base URL. A forced `gitProviders.provider: 'gitlab'` accepts any remote host for directory resolution. Global routes (`auth/connect`, `auth/status`, `auth/activate`, `me`, `repo/branches`) stay global.
- Account id: `` `${host}:${username}` `` (e.g. `gitlab.com:alice`), falling back to `token:<first8>` when the username is missing.
- Auth header on every request: `PRIVATE-TOKEN: <pat>`.
## OAuth readiness
The stored entry shape (`accessToken`, `baseUrl`, `username`, `name`, `avatarUrl`, `webUrl`, `email`, `createdAt`, `current`) is intentionally generic. OAuth flows would slot in at two points:
1. `routes.js` — add `POST /api/gitlab/auth/start` / `auth/complete` endpoints next to the existing `auth/connect` (mirroring the GitHub device-flow routes), exchanging the OAuth grant for an access token.
2. `setGitLabAuth` — persists whatever `accessToken` + `user` shape the OAuth callback produces; no storage changes needed.
Nothing in the client or repo layers assumes the token came from a PAT.
## Client behavior
- Base URL joining: `{baseUrl}/api/v4{path}`. Project `:id` segments are URL-encoded with `encodeURIComponent` (e.g. `group/sub` -> `group%2Fsub`) and never double-encoded.
- Per-request timeout: 8000 ms via `AbortSignal.timeout`, unless the caller passes its own signal.
- ETag conditional-GET cache: keyed `token\nurl`, max 300 LRU entries; a `304` is replayed from cache as a `200`. GET only.
- Pagination: `x-page`, `x-next-page`, `x-total-pages`, and the `Link` header (`rel="next"`) are parsed into the returned `page` object (`hasMore` = a next page exists).
- Redirects: `301`/`302`/`308` with a `Location` header are followed exactly once (project moves) with `redirect: 'manual'`, preserving `PRIVATE-TOKEN` across the hop.
- Rate limits: a `429` records a module-level cooldown (honoring `Retry-After` / `RateLimit-Reset` when present) and surfaces `{ status: 429, error: 'GitLab rate limited' }`. While the cooldown is active, requests short-circuit without hitting the network.
- `request` never throws for HTTP error statuses — callers branch on `status`.
## API integration overview
- Issues/MRs are addressed project-scoped by **iid**.
- Issue list: `GET /projects/:id/issues?state=opened&scope=all&per_page=50&page=N&search=<query>`.
- Issue detail: `GET /projects/:id/issues/:issue_iid`.
- Issue notes: `GET /projects/:id/issues/:issue_iid/notes?per_page=100` (system notes are skipped; each note links as `{issue_web_url}#note_{id}`).
- MR list: `GET /projects/:id/merge_requests?state=opened&scope=all&per_page=50&page=N&search=<query>&source_branch=<branch>` (the route passes `sourceBranch` through to `source_branch` when present, matching local-branch MR-status UIs).
- 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 commits: `GET /projects/:id/merge_requests/:merge_request_iid/commits?per_page=100` (mapped to `{ sha, shortSha, message, summary, authorName, committedAt, parents }`).
- MR notes: `GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100`; the timeline route keeps `system: true` notes only and infers the event `type` from the note body text (best-effort heuristic, falls back to `'other'`).
- MR notes: `GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100`.
- MR create: `POST /projects/:id/merge_requests` with `{ source_branch, target_branch, title, description?, remove_source_branch }` (description omitted when absent; `remove_source_branch` defaults to `false`).
- MR update: `PUT /projects/:id/merge_requests/:merge_request_iid` with `{ title?, description?, state_event?, labels?, assignee_ids?, milestone_id? }` (undefined fields omitted; `state_event` is derived from `state`, milestone titles are resolved to ids).
- MR merge: `PUT /projects/:id/merge_requests/:merge_request_iid/merge` with `{ squash? }`.
- Issue comment write: `POST /projects/:id/issues/:issue_iid/notes` with `{ body }` (the route resolves the issue `web_url` first so the note links as `{issue_web_url}#note_{id}`).
- Issue update: `PUT /projects/:id/issues/:issue_iid` with `{ title?, description?, state_event?, labels?, assignee_ids?, milestone_id? }` (`state: 'open'|'closed'` maps to `state_event: 'reopen'|'close'`; labels/assignees are full-set replaces per GitLab semantics; `milestone` titles are resolved to ids and `null` clears).
- MR comment write: `POST /projects/:id/merge_requests/:merge_request_iid/notes` with `{ body }`.
- MR approve: `POST /projects/:id/merge_requests/:merge_request_iid/approve` (approve-only; GitLab has no request-changes event via this API — the facade capability reflects that).
- Milestones: `GET /projects/:id/milestones?state=all&per_page=100` (first page) for title-to-id resolution on issue/MR updates.
- 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&sourceBranch` -> `{ connected, repo?, mrs[], page, hasMore }` |
| GET | `/api/gitlab/mrs/context` | `?directory&number&diff&namespace&project` -> `{ connected, repo?, mr, comments[], files[], diff? }` |
| GET | `/api/gitlab/mrs/commits` | `?directory&number&namespace&project` -> `{ connected, repo?, commits[] }` |
| GET | `/api/gitlab/mrs/timeline` | `?directory&number&namespace&project` -> `{ connected, repo?, events[] }` (system notes only; event `type` inferred from note body text — best-effort heuristic) |
| POST | `/api/gitlab/mrs/create` | body `{ directory, title, sourceBranch, targetBranch, description?, removeSourceBranch? }` -> `{ connected, repo?, mr }`; `400` for missing fields, unresolvable repo, or a token without the `api` scope |
| PUT | `/api/gitlab/mrs/update` | body `{ directory, number, title?, description?, state?, labels?, assigneeIds?, milestone? }` -> `{ connected, repo?, mr }`; `404` when the MR does not exist; `400 'Milestone not found'` when a milestone title does not match |
| PUT | `/api/gitlab/mrs/merge` | body `{ directory, number, squash? }` -> `{ connected, merged: true }` on success; non-mergeable MRs -> the GitLab status (`405`/`406`/`409`/`422`) with `{ connected, merged: false, message }` |
| POST | `/api/gitlab/issues/comment` | body `{ directory, number, body, namespace?, project? }` -> `{ connected, repo?, comment }` |
| POST | `/api/gitlab/issues/create` | body `{ directory, title, body?, labels?, namespace?, project? }` -> `{ connected, repo?, issue }` |
| PUT | `/api/gitlab/issues/update` | body `{ directory, number, title?, body?, state?, labels?, assigneeIds?, milestone?, namespace?, project? }` -> `{ connected, repo?, issue }`; `400 'Milestone not found'` when a milestone title does not match |
| POST | `/api/gitlab/mrs/comment` | body `{ directory, number, body, namespace?, project? }` -> `{ connected, repo?, comment }` |
| POST | `/api/gitlab/mrs/approve` | body `{ directory, number, namespace?, project? }` -> `{ connected, repo?, approved: true }` |
| GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[], defaultBranch? }` (`defaultBranch` is `null` when the repo has no marked default branch or GitLab is disconnected) |
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. Write routes deliberately skip the route-level timeout (a timeout can orphan a write); the client's per-request timeout still bounds them.
## 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. Write routes reject an unresolvable repo with `400 { error: 'Unable to resolve GitLab repo from directory' }`.
- Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected.
- GitLab `403` on write routes means the token lacks the `api` scope; they respond `400 { error: 'Your GitLab token needs the api scope to ...' }`.
- Milestone titles on issue/MR updates are resolved against `GET /projects/:id/milestones`; an unmatched title yields `400 { error: 'Milestone not found' }` and `null` clears the milestone (`milestone_id: null`).
- MR merge rejections (`405`/`406`/`409`/`422` from GitLab) are surfaced as `{ connected, merged: false, message }` with the GitLab status so clients can show the message without treating it as a transport error (mirrors `github/pr/merge`).
- 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 further GitLab write operations, add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing issue/MR write routes and the GitHub PR write routes.
+325
View File
@@ -0,0 +1,325 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getProviderApiBaseUrl } from '../git-providers/config.js';
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');
// Kept for compatibility with existing consumers/tests; the effective fallback
// lives in the git-providers defaults (GIT_PROVIDER_DEFAULTS.gitlab).
export const DEFAULT_GITLAB_BASE_URL = 'https://gitlab.com';
/** Effective default GitLab base URL: configured settings.json value, else the built-in default. */
export function getGitLabDefaultBaseUrl() {
return getProviderApiBaseUrl('gitlab');
}
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) || getGitLabDefaultBaseUrl();
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 || getGitLabDefaultBaseUrl(),
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) || getGitLabDefaultBaseUrl();
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;
+180
View File
@@ -0,0 +1,180 @@
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,
getGitLabDefaultBaseUrl,
} = await import('./auth.js');
const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
afterEach(() => {
if (fs.existsSync(GITLAB_AUTH_FILE)) {
fs.unlinkSync(GITLAB_AUTH_FILE);
}
if (fs.existsSync(SETTINGS_FILE)) {
fs.unlinkSync(SETTINGS_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('getGitLabDefaultBaseUrl', () => {
test('falls back to the built-in default when nothing is configured', () => {
expect(getGitLabDefaultBaseUrl()).toBe(DEFAULT_GITLAB_BASE_URL);
});
test('returns the configured settings.json default when present', () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
gitProviders: { gitlab: { apiBaseUrl: 'https://gitlab.example.com' } },
}));
expect(getGitLabDefaultBaseUrl()).toBe('https://gitlab.example.com');
});
});
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);
});
});
+323
View File
@@ -0,0 +1,323 @@
import { getGitLabAuth, getGitLabDefaultBaseUrl } 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 || getGitLabDefaultBaseUrl()).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. Only follow
// same-origin redirects to avoid leaking the token to a different host.
let redirects = 0;
const baseHost = new URL(url).host;
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();
if (new URL(nextUrl).host !== baseHost) break;
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 }),
createIssueNote: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { method: 'POST', body: { body } }),
createIssue: (pathWithNamespace, params) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues`, { method: 'POST', body: params }),
updateIssue: (pathWithNamespace, iid, params) =>
request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`, { method: 'PUT', body: params }),
mergeRequests: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { query: params }),
mergeRequest: (pathWithNamespace, iid) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}`),
mergeRequestDiffs: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/diffs`, { query: params }),
mergeRequestCommits: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/commits`, { query: params }),
mergeRequestNotes: (pathWithNamespace, iid, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/notes`, { query: params }),
createMrNote: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/notes`, { method: 'POST', body: { body } }),
approveMr: (pathWithNamespace, iid) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/approve`, { method: 'POST' }),
milestones: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/milestones`, { query: params }),
createMergeRequest: (pathWithNamespace, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { method: 'POST', body }),
updateMergeRequest: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}`, { method: 'PUT', body }),
mergeMergeRequest: (pathWithNamespace, iid, body) =>
request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/merge`, { method: 'PUT', body }),
branches: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/repository/branches`, { query: params }),
// Project members (direct + inherited) are the assignable/mentionable user
// set. `members/all` includes inherited group members; `query` filters
// server-side by username/name/email.
members: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/members/all`, { query: params }),
labels: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/labels`, { query: params }),
tags: (pathWithNamespace, params = {}) =>
request(`/projects/${encodeProject(pathWithNamespace)}/repository/tags`, { query: params }),
};
}
function normalizeBaseForClient(baseUrl) {
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
return getGitLabDefaultBaseUrl();
}
return baseUrl.trim().replace(/\/+$/, '');
}
/** Picks the current account (from auth.js) token + base URL, or null. */
export function getGitLabClientOrNull() {
const auth = getGitLabAuth();
if (!auth?.accessToken) {
return null;
}
return createGitLabClient({ token: auth.accessToken, baseUrl: auth.baseUrl });
}
@@ -0,0 +1,375 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, afterEach, describe, expect, test, vi } from 'vitest';
// Isolate auth storage so getGitLabClientOrNull never reads a real account.
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitlab-client-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
const {
createGitLabClient,
getGitLabClientOrNull,
isGitLabRateLimited,
noteGitLabRateLimit,
} = await import('./client.js');
const jsonResponse = (data, { status = 200, headers = {} } = {}) =>
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } });
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('createGitLabClient request basics', () => {
test('calls {baseUrl}/api/v4{path} and sends PRIVATE-TOKEN', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 42, username: 'alice' }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-token', baseUrl: 'https://gitlab.com' });
const result = await client.user();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/user');
expect(options.headers['PRIVATE-TOKEN']).toBe('glpat-token');
expect(result).toMatchObject({ status: 200, data: { id: 42, username: 'alice' } });
expect(result.error).toBeUndefined();
});
test('joins a custom base URL without duplicating /api/v4', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.example.com/gitlab/' });
await client.issues('group/sub', { state: 'opened' });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.example.com/gitlab/api/v4/projects/group%2Fsub/issues?state=opened');
});
test('encodes project path namespaces exactly once', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.mergeRequest('a/b/c', 5);
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/a%2Fb%2Fc/merge_requests/5');
expect(String(url)).not.toContain('%252F');
});
test('serializes query params and omits empty ones', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.mergeRequests('g/p', { state: 'opened', per_page: 50, page: 2, search: '', sort: null });
const [url] = fetchMock.mock.calls[0];
const query = String(url).split('?')[1];
expect(query).toContain('state=opened');
expect(query).toContain('per_page=50');
expect(query).toContain('page=2');
expect(query).not.toContain('search');
expect(query).not.toContain('sort');
});
test('POST requests send a JSON body', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ ok: true }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.request('/some/action', { method: 'POST', body: { hello: 'world' } });
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe('POST');
expect(options.headers['content-type']).toBe('application/json');
expect(options.body).toBe(JSON.stringify({ hello: 'world' }));
});
test('surfaces error statuses without throwing', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ message: 'nope' }, { status: 401 }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.user();
expect(result.status).toBe(401);
expect(result.data).toEqual({ message: 'nope' });
});
test('attaches a caller signal when provided, else a timeout signal', async () => {
const fetchMock = vi.fn(async () => jsonResponse([]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const controller = new AbortController();
await client.branches('g/p', { per_page: 100 });
await client.request('/user', { signal: controller.signal });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][1].signal).toEqual(expect.any(AbortSignal));
expect(fetchMock.mock.calls[1][1].signal).toBe(controller.signal);
});
});
describe('pagination', () => {
test('parses x-page/x-next-page headers into the page object', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], {
headers: { 'x-page': '2', 'x-next-page': '3', 'x-total-pages': '5' },
}));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.issues('g/p', { page: 2 });
expect(result.page).toEqual({ page: 2, next: 3, total: 5, hasMore: true });
});
test('falls back to the Link rel=next header when x-next-page is absent', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], {
headers: { link: '<https://gitlab.com/api/v4/projects/g%2Fp/issues?page=3>; rel="next", <...>; rel="last"' },
}));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.issues('g/p', { page: 2 });
expect(result.page.hasMore).toBe(true);
expect(result.page.nextUrl).toBe('https://gitlab.com/api/v4/projects/g%2Fp/issues?page=3');
});
test('reports hasMore=false on the last page', async () => {
const fetchMock = vi.fn(async () => jsonResponse([], {
headers: { 'x-page': '5', 'x-next-page': '', 'x-total-pages': '5' },
}));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.issues('g/p', { page: 5 });
expect(result.page.hasMore).toBe(false);
});
});
describe('redirect handling', () => {
test('follows a project-move redirect exactly once, preserving auth headers', async () => {
const movedUrl = 'https://gitlab.com/api/v4/projects/new%2Fhome/issues';
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/projects/g%2Fp/issues')) {
return jsonResponse({}, { status: 301, headers: { location: '/api/v4/projects/new%2Fhome/issues' } });
}
if (String(url) === movedUrl) {
return jsonResponse([{ iid: 1 }]);
}
return jsonResponse({}, { status: 404 });
});
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' });
const result = await client.issues('g/p');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result.status).toBe(200);
expect(result.data).toEqual([{ iid: 1 }]);
const [, secondOptions] = fetchMock.mock.calls[1];
expect(secondOptions.headers['PRIVATE-TOKEN']).toBe('glpat-t');
});
});
describe('etag conditional cache', () => {
test('sends if-none-match and replays a 304 as a 200 with cached body', async () => {
const fetchMock = vi.fn(async (_url, options) => {
if (options.headers['if-none-match'] === '"v1"') {
return new Response(null, { status: 304 });
}
return jsonResponse({ ok: true }, { headers: { etag: '"v1"' } });
});
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' });
const first = await client.user();
expect(first.status).toBe(200);
expect(first.data).toEqual({ ok: true });
const second = await client.user();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1][1].headers['if-none-match']).toBe('"v1"');
expect(second.status).toBe(200);
expect(second.data).toEqual({ ok: true });
});
test('does not cache POST responses', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ ok: true }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' });
await client.request('/thing', { method: 'POST', body: {} });
await client.request('/thing', { method: 'POST', body: {} });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('merge request write methods', () => {
test('createMergeRequest POSTs a JSON body to the merge_requests endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ iid: 5, title: 'New MR' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.createMergeRequest('group/sub', {
source_branch: 'feat/x',
target_branch: 'main',
title: 'New MR',
});
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests');
expect(options.method).toBe('POST');
expect(options.headers['content-type']).toBe('application/json');
expect(JSON.parse(options.body)).toEqual({ source_branch: 'feat/x', target_branch: 'main', title: 'New MR' });
expect(result.status).toBe(201);
expect(result.data).toEqual({ iid: 5, title: 'New MR' });
});
test('updateMergeRequest PUTs a JSON body to the merge request endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ iid: 5, title: 'Updated' }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.updateMergeRequest('group/sub', 5, { title: 'Updated', description: 'Body text' });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/5');
expect(options.method).toBe('PUT');
expect(options.headers['content-type']).toBe('application/json');
expect(JSON.parse(options.body)).toEqual({ title: 'Updated', description: 'Body text' });
});
test('mergeMergeRequest PUTs a JSON body to the merge endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ iid: 5, state: 'merged' }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.mergeMergeRequest('group/sub', 5, { squash: true });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/5/merge');
expect(options.method).toBe('PUT');
expect(JSON.parse(options.body)).toEqual({ squash: true });
});
test('write methods surface error statuses without throwing', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ message: 'Method Not Allowed' }, { status: 405 }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.mergeMergeRequest('group/sub', 5, {});
expect(result.status).toBe(405);
expect(result.data).toEqual({ message: 'Method Not Allowed' });
});
});
describe('issue and review write methods', () => {
test('createIssueNote POSTs a body to the issue notes endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 5, body: 'hi' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
const result = await client.createIssueNote('group/sub', 7, 'Nice catch');
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/issues/7/notes');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
expect(result.status).toBe(201);
});
test('createMrNote POSTs a body to the MR notes endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 8, body: 'LGTM' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.createMrNote('group/sub', 12, 'LGTM');
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/12/notes');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ body: 'LGTM' });
});
test('updateIssue PUTs params to the issue endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ iid: 7, title: 'Updated' }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.updateIssue('group/sub', 7, { state_event: 'close', labels: ['bug'], milestone_id: 33 });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/issues/7');
expect(options.method).toBe('PUT');
expect(JSON.parse(options.body)).toEqual({ state_event: 'close', labels: ['bug'], milestone_id: 33 });
});
test('approveMr POSTs to the approve endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 1, state: 'approved' }));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.approveMr('group/sub', 12);
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/12/approve');
expect(options.method).toBe('POST');
});
test('milestones GETs the project milestones list', async () => {
const fetchMock = vi.fn(async () => jsonResponse([{ id: 33, title: 'v1.0' }]));
globalThis.fetch = fetchMock;
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
await client.milestones('group/sub', { state: 'all', per_page: 100 });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/milestones?state=all&per_page=100');
});
});
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();
});
});
+23
View File
@@ -0,0 +1,23 @@
export {
getGitLabAuth,
getGitLabAuthAccounts,
setGitLabAuth,
activateGitLabAuth,
clearGitLabAuth,
normalizeBaseUrl,
GITLAB_AUTH_FILE,
DEFAULT_GITLAB_BASE_URL,
getGitLabDefaultBaseUrl,
} from './auth.js';
export {
createGitLabClient,
getGitLabClientOrNull,
isGitLabRateLimited,
noteGitLabRateLimit,
} from './client.js';
export {
parseGitLabRemoteUrl,
resolveGitLabRepoFromDirectory,
} from './repo.js';
+140
View File
@@ -0,0 +1,140 @@
import { getRemoteUrl } from '../git/index.js';
import { getGitLabAuthAccounts, normalizeBaseUrl } from './auth.js';
import { getEffectiveProviderApiBaseUrl, getProjectProviderFromDirectory } from '../git-providers/project-config.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, options = {}) => {
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 (!options.allowAnyHost && !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 };
}
// A per-project API base override makes its host acceptable for directory
// resolution even when no connected account covers it.
const overrideBaseUrl = getEffectiveProviderApiBaseUrl('gitlab', directory);
let knownHosts;
if (overrideBaseUrl) {
knownHosts = acceptedHosts();
try {
const host = new URL(overrideBaseUrl).hostname.toLowerCase();
if (host) {
knownHosts.add(host);
}
} catch {
// ignore a malformed override base URL
}
}
// A forced gitlab provider (per-project override) accepts any remote host.
const forcedProvider = getProjectProviderFromDirectory(directory);
return {
repo: parseGitLabRemoteUrl(remoteUrl, knownHosts, { allowAnyHost: forcedProvider === 'gitlab' }),
remoteUrl,
};
}
+163
View File
@@ -0,0 +1,163 @@
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),
}));
// Per-project overrides only apply for the directory configured with one; all
// other directories fall through to the real (global-only) resolution.
vi.mock('../git-providers/project-config.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getEffectiveProviderApiBaseUrl: vi.fn((provider, directory) => {
if (directory === '/override/project') {
return provider === 'gitlab' ? 'https://gitlab.override.example' : actual.getEffectiveProviderApiBaseUrl(provider, directory);
}
return actual.getEffectiveProviderApiBaseUrl(provider, directory);
}),
getProjectProviderFromDirectory: vi.fn((directory) => {
if (directory === '/forced/project') {
return 'gitlab';
}
return actual.getProjectProviderFromDirectory(directory);
}),
};
});
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();
});
test('accepts the per-project override host for a directory with an override', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitlab.override.example:team/app.git');
const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/override/project');
expect(remoteUrl).toBe('git@gitlab.override.example:team/app.git');
expect(repo).toMatchObject({ namespace: 'team', project: 'app', host: 'gitlab.override.example' });
});
test('rejects the override host for a directory without an override', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitlab.override.example:team/app.git');
const { repo } = await resolveGitLabRepoFromDirectory('/some/project');
expect(repo).toBeNull();
});
test('accepts any remote host when the provider is forced to gitlab', async () => {
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitlab.internal.corp:team/app.git');
const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/forced/project');
expect(remoteUrl).toBe('git@gitlab.internal.corp:team/app.git');
expect(repo).toMatchObject({ namespace: 'team', project: 'app', host: 'gitlab.internal.corp' });
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -214,7 +214,7 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
- Returned API:
- `normalizePwaAppName(value, fallback?)`
- `sanitizeSettingsUpdate(payload)`
- `sanitizeSettingsUpdate(payload)` — whitelist of persisted keys; includes `gitProviders` (validated via `packages/web/server/lib/git-providers/config.js` `sanitizeGitProviders`), which therefore round-trips through GET/PUT `/api/config/settings`.
- `mergePersistedSettings(current, changes)`
- `formatSettingsResponse(settings)`
@@ -4,7 +4,10 @@ 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 { registerGiteaRoutes } from '../gitea/routes.js';
import { registerGitRoutes } from '../git/routes.js';
import { registerGitProviderRoutes } from '../git-providers/routes.js';
import { registerDevServerRoutes } from '../dev-servers/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
@@ -300,6 +303,9 @@ export const createFeatureRoutesRuntime = (dependencies) => {
registerWalkthroughRoutes(app, { getWalkthroughService });
registerSessionGoalRoutes(app);
registerGitHubRoutes(app);
registerGitLabRoutes(app);
registerGiteaRoutes(app);
registerGitProviderRoutes(app);
registerGitRoutes(app);
registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts });
registerMagicPromptRoutes(app, {
@@ -1,3 +1,4 @@
import { sanitizeGitProviders } from '../git-providers/config.js';
import { isAgentMemoryFeatureAvailable } from '../agent-memory/feature-flag.js';
export const createSettingsHelpers = (dependencies) => {
@@ -498,6 +499,10 @@ export const createSettingsHelpers = (dependencies) => {
const trimmed = candidate.gitModelId.trim();
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
}
const gitProviders = sanitizeGitProviders(candidate.gitProviders);
if (gitProviders) {
result.gitProviders = gitProviders;
}
if (typeof candidate.pwaAppName === 'string') {
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
}
@@ -497,6 +497,46 @@ describe('settings helpers', () => {
expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
expect(sanitized.recentModels).toEqual(payload.recentModels);
});
it('round-trips a valid gitProviders payload through sanitizeSettingsUpdate', () => {
const helpers = createTestHelpersWithRealSanitizers();
const payload = {
gitProviders: {
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
gitlab: { apiBaseUrl: 'gitlab.example.com', detectUrls: [] },
gitea: { apiBaseUrl: '', detectUrls: ['gitea.example.com'] },
},
};
expect(helpers.sanitizeSettingsUpdate(payload)).toEqual({
gitProviders: {
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
gitea: { detectUrls: ['gitea.example.com'] },
},
});
});
it('drops malformed gitProviders payloads entirely', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({ gitProviders: 'not-an-object' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ gitProviders: [] })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ gitProviders: null })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ gitProviders: { unknown: { apiBaseUrl: 'https://x.example.com' } } })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { apiBaseUrl: ' ' } } })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { detectUrls: 'github.example.com' } } })).toEqual({});
});
it('normalizes gitProviders apiBaseUrl scheme and strips trailing slashes', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({
gitProviders: { github: { apiBaseUrl: 'github.example.com/api/v3/' } },
})).toEqual({
gitProviders: { github: { apiBaseUrl: 'https://github.example.com/api/v3' } },
});
});
});
describe('session retention settings persistence', () => {
@@ -20,7 +20,7 @@ has to ask for it.
`PROMPT_VERSION`.
- `schema.js` — response schema, response normalization, tolerant JSON parsing.
- `store.js` — content-addressed cache entries plus mutable pointers.
- `pull-request.js` — PR diffs via the shared GitHub octokit helper.
- `pull-request.js` — PR/MR diffs; the provider (GitHub octokit or GitLab REST client) is chosen by the git remote.
- `model-settings.js` — the feature's own model override.
- `languages.js` — the languages the prose may be written in.
- `index.js` — orchestration.
@@ -53,7 +53,15 @@ written against staged code never silently re-anchors onto an unstaged edit.
|---|---|---|
| `working-tree` (`all` \| `staged` \| `working`) | `staged`, `working` | Untracked files are fetched individually because `git diff` omits them |
| `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded |
| `pr` | `pr:<number>` | GitHub returns the merge-base diff, matching the branch semantics |
| `pr` | `pr:<number>` | GitHub returns the merge-base diff; a GitLab remote concatenates the merge request's diffs instead — both match the branch semantics |
Provider selection lives in `pull-request.js`: the directory's git remote
decides. A GitLab remote resolves through `resolveGitLabRepoFromDirectory` and
fetches `merge_requests/:iid/diffs` pages (capped at 10), concatenating the
per-file diffs into one patch; anything else falls back to the GitHub pull
request API. A GitLab directory without a connected GitLab account fails with
`401 gitlab-not-connected`, and an MR with no diffs fails with
`404 empty-diff`, the same code an empty GitHub PR uses.
For the current-branch source, the UI takes the base from the default branch of
the current branch's tracking remote (`defaultBranches` in the branches
@@ -1,15 +1,24 @@
import { getOctokitOrNull } from '../github/octokit.js';
import { resolveGitHubRepoFromDirectory } from '../github/repo/index.js';
import { getGitLabClientOrNull } from '../gitlab/client.js';
import { createGitLabClient } from '../gitlab/client.js';
import { getGitLabAuth, getGitLabDefaultBaseUrl } from '../gitlab/auth.js';
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
import { resolveGitLabRepoFromDirectory } from '../gitlab/repo.js';
// GitLab diff pagination cap: never loop more than 10 pages of 100 files,
// mirroring gitlab/routes.js.
const GITLAB_DIFFS_MAX_PAGES = 10;
/**
* Raw unified diff for a pull request.
* Raw unified diff for a GitHub pull request.
*
* GitHub already returns the merge-base diff for a PR, so this matches the
* three-dot semantics used for local branch reviews: work merged in from the
* base branch is not part of it.
*/
export async function getPullRequestDiff(directory, number) {
const octokit = getOctokitOrNull();
async function getGitHubPullRequestDiff(directory, number) {
const octokit = getOctokitOrNull(directory);
if (!octokit) {
throw Object.assign(new Error('Connect a GitHub account to review pull requests'), {
statusCode: 401,
@@ -44,3 +53,86 @@ export async function getPullRequestDiff(directory, number) {
return { patch, meta: { owner: repo.owner, repo: repo.repo, number } };
}
/**
* Raw unified diff for a GitLab merge request.
*
* GitLab's merge request diffs endpoint returns one entry per file, so the
* pages are concatenated into a single patch. `repo` comes from the
* dispatcher's `resolveGitLabRepoFromDirectory` call and is never re-resolved
* here.
*/
async function getGitLabMergeRequestDiff(directory, repo, number) {
// Resolve per-project API base override, mirroring getClient() in routes.js.
const auth = getGitLabAuth();
if (!auth?.accessToken) {
throw Object.assign(new Error('Connect a GitLab account to review merge requests'), {
statusCode: 401,
code: 'gitlab-not-connected',
});
}
const effectiveBaseUrl = getEffectiveProviderApiBaseUrl('gitlab', directory) || getGitLabDefaultBaseUrl();
const client = effectiveBaseUrl !== getGitLabDefaultBaseUrl()
? createGitLabClient({ token: auth.accessToken, baseUrl: effectiveBaseUrl })
: getGitLabClientOrNull();
if (!client) {
throw Object.assign(new Error('Connect a GitLab account to review merge requests'), {
statusCode: 401,
code: 'gitlab-not-connected',
});
}
// The parser always populates both fields; this guards a malformed repo so
// the failure is explicit rather than a downstream TypeError.
if (!repo?.namespace || !repo?.project) {
throw Object.assign(new Error('This directory has no GitLab remote'), {
statusCode: 400,
code: 'no-gitlab-remote',
});
}
// The client URL-encodes the path internally; never pre-encode it.
const projectPath = `${repo.namespace}/${repo.project}`;
const diffs = [];
for (let page = 1; page <= GITLAB_DIFFS_MAX_PAGES; page += 1) {
const response = await client.mergeRequestDiffs(projectPath, number, { per_page: 100, page });
if (response.status !== 200 || !Array.isArray(response.data)) {
break;
}
diffs.push(...response.data);
// The page object is the authoritative signal; the 10-page cap above is
// what stops a server that lies about hasMore from looping forever.
if (!response.page?.hasMore) {
break;
}
}
const patch = diffs
.map((item) => (typeof item?.diff === 'string' ? item.diff : ''))
.filter(Boolean)
.join('\n');
if (!patch.trim()) {
throw Object.assign(new Error(`Merge request #${number} has no diff`), {
statusCode: 404,
code: 'empty-diff',
});
}
return { patch, meta: { namespace: repo.namespace, project: repo.project, number } };
}
/**
* Raw unified diff for a pull request or merge request.
*
* The provider is chosen by the repository's git remote: a GitLab remote uses
* the GitLab merge request API, anything else falls back to the GitHub pull
* request API.
*/
export async function getPullRequestDiff(directory, number) {
const { repo } = await resolveGitLabRepoFromDirectory(directory);
if (repo) {
return getGitLabMergeRequestDiff(directory, repo, number);
}
return getGitHubPullRequestDiff(directory, number);
}
@@ -2,10 +2,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../github/octokit.js', () => ({ getOctokitOrNull: vi.fn() }));
vi.mock('../github/repo/index.js', () => ({ resolveGitHubRepoFromDirectory: vi.fn() }));
vi.mock('../gitlab/client.js', () => ({
getGitLabClientOrNull: vi.fn(),
createGitLabClient: vi.fn(),
}));
vi.mock('../gitlab/auth.js', () => ({
getGitLabAuth: vi.fn(),
getGitLabDefaultBaseUrl: vi.fn(() => 'https://gitlab.com'),
}));
vi.mock('../git-providers/project-config.js', () => ({
getEffectiveProviderApiBaseUrl: vi.fn(() => null),
}));
vi.mock('../gitlab/repo.js', () => ({ resolveGitLabRepoFromDirectory: vi.fn() }));
const { getPullRequestDiff } = await import('./pull-request.js');
const { getOctokitOrNull } = await import('../github/octokit.js');
const { resolveGitHubRepoFromDirectory } = await import('../github/repo/index.js');
const { getGitLabClientOrNull, createGitLabClient } = await import('../gitlab/client.js');
const { getGitLabAuth } = await import('../gitlab/auth.js');
const { getEffectiveProviderApiBaseUrl } = await import('../git-providers/project-config.js');
const { resolveGitLabRepoFromDirectory } = await import('../gitlab/repo.js');
const PATCH = `diff --git a/src/a.ts b/src/a.ts
--- a/src/a.ts
@@ -14,6 +30,28 @@ const PATCH = `diff --git a/src/a.ts b/src/a.ts
+const added = true;
`;
const GITLAB_DIFF_ONE = `diff --git a/a.txt b/a.txt
--- a/a.txt
+++ b/a.txt
@@ -1,1 +1,2 @@
+hello
`;
const GITLAB_DIFF_TWO = `diff --git a/b.txt b/b.txt
--- a/b.txt
+++ b/b.txt
@@ -1 +1,2 @@
+world
`;
const GITLAB_REPO = {
namespace: 'acme',
project: 'widgets',
host: 'gitlab.com',
baseUrl: 'https://gitlab.com',
url: 'https://gitlab.com/acme/widgets',
};
describe('getPullRequestDiff', () => {
let request;
@@ -27,6 +65,11 @@ describe('getPullRequestDiff', () => {
repo: { owner: 'openchamber', repo: 'openchamber' },
remoteUrl: 'git@github.com:openchamber/openchamber.git',
});
// Default to a non-GitLab directory so the GitHub cases keep routing to
// the GitHub path.
resolveGitLabRepoFromDirectory.mockResolvedValue({ repo: null, remoteUrl: null });
getGitLabClientOrNull.mockReturnValue(null);
getGitLabAuth.mockReturnValue({ accessToken: 'test-token' });
});
afterEach(() => {
@@ -74,4 +117,82 @@ describe('getPullRequestDiff', () => {
statusCode: 404,
});
});
describe('with a GitLab repository', () => {
let mergeRequestDiffs;
beforeEach(() => {
mergeRequestDiffs = vi.fn().mockResolvedValue({
status: 200,
data: [{ diff: GITLAB_DIFF_ONE }],
page: { page: 1, next: null, total: 1, hasMore: false },
});
getGitLabClientOrNull.mockReturnValue({ mergeRequestDiffs });
resolveGitLabRepoFromDirectory.mockResolvedValue({
repo: GITLAB_REPO,
remoteUrl: 'git@gitlab.com:acme/widgets.git',
});
});
it('concatenates merge request diffs across pages into a single patch', async () => {
mergeRequestDiffs
.mockResolvedValueOnce({
status: 200,
data: [{ diff: GITLAB_DIFF_ONE }],
page: { page: 1, next: 2, total: 2, hasMore: true },
})
.mockResolvedValueOnce({
status: 200,
data: [{ diff: GITLAB_DIFF_TWO }],
page: { page: 2, next: null, total: 2, hasMore: false },
});
const result = await getPullRequestDiff('/repo', 7);
expect(result.patch).toBe(`${GITLAB_DIFF_ONE}\n${GITLAB_DIFF_TWO}`);
expect(result.meta).toEqual({ namespace: 'acme', project: 'widgets', number: 7 });
// The unencoded namespace/project path is passed to the client, which
// URL-encodes it internally.
expect(mergeRequestDiffs).toHaveBeenCalledTimes(2);
expect(mergeRequestDiffs).toHaveBeenNthCalledWith(1, 'acme/widgets', 7, { per_page: 100, page: 1 });
expect(mergeRequestDiffs).toHaveBeenNthCalledWith(2, 'acme/widgets', 7, { per_page: 100, page: 2 });
});
it('asks the user to connect GitLab before fetching diffs', async () => {
getGitLabAuth.mockReturnValue(null);
getGitLabClientOrNull.mockReturnValue(null);
await expect(getPullRequestDiff('/repo', 7)).rejects.toMatchObject({
code: 'gitlab-not-connected',
statusCode: 401,
});
expect(mergeRequestDiffs).not.toHaveBeenCalled();
});
it('treats an MR with no diff as missing rather than an empty review', async () => {
mergeRequestDiffs.mockResolvedValue({
status: 200,
data: [{ diff: ' ' }],
page: { page: 1, next: null, total: 1, hasMore: false },
});
await expect(getPullRequestDiff('/repo', 7)).rejects.toMatchObject({
code: 'empty-diff',
statusCode: 404,
});
});
it('reports a GitLab repo without namespace or project as having no remote', async () => {
resolveGitLabRepoFromDirectory.mockResolvedValue({
repo: { namespace: '', project: '' },
remoteUrl: 'git@gitlab.com:acme/widgets.git',
});
await expect(getPullRequestDiff('/repo', 7)).rejects.toMatchObject({
code: 'no-gitlab-remote',
statusCode: 400,
});
expect(mergeRequestDiffs).not.toHaveBeenCalled();
});
});
});