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:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -28,3 +28,8 @@ export {
|
||||
parseGitHubRemoteUrl,
|
||||
resolveGitHubRepoFromDirectory,
|
||||
} from './repo/index.js';
|
||||
|
||||
export {
|
||||
getProviderApiBaseUrl,
|
||||
githubWebOriginFromApiBase,
|
||||
} from '../git-providers/config.js';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user