merge: resolve v1.22.2 conflicts with custom
Per Q4 resolution (documented on kanban t_83741c53): - .github/workflows/*: KEEP custom deletion (fork uses Gitea Actions under .gitea/workflows) - ChatInput.tsx: KEEP custom forge picker states + provider-aware linkedPr - WorkStatusContextSection.tsx: combine imports (WorkStatusPill + useConfigStore) - WorkStatusPrimaryGroup.tsx: upstream nested-git/bootstrap-gate base + custom GitLab/Gitea forge rows - NewWorktreeDialog.tsx: combine dialogs; keep custom MR/PR branch resolution - SettingsView.tsx: custom deps minus undefined openThirdPartyProviderSetup - search.ts + search.test.ts: KEEP upstream (enter-to-send, large-text-paste, first-party integrations) - tr.ts: drop 4 auto-merge duplicate gitView.empty.* keys
This commit is contained in:
@@ -884,6 +884,14 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
managedRoots,
|
||||
});
|
||||
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,48 @@ 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), CLI-backed 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/` but uses the **`tea` CLI** as its transport instead of raw `fetch`. Auth is passed via the `GITEA_SERVER_TOKEN` environment variable (never on argv). The `tea` binary path is env-overridable via `TEA_BIN`, defaulting to `/home/user/.local/bin/tea`.
|
||||
|
||||
## 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`: CLI-backed `tea api` client (process spawn with 8s timeout, `--include` for HTTP status/headers, `--paginate` for list endpoints, `--header` for raw diff Accept). Token is passed via `GITEA_SERVER_TOKEN` env var.
|
||||
- `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
|
||||
|
||||
- Transport: each call spawns a `tea api` process with `--include` for HTTP status/headers. Auth is passed via `GITEA_SERVER_TOKEN` env var (never on argv). Binary path: `TEA_BIN` env or `/home/user/.local/bin/tea`.
|
||||
- Base URL: the `baseUrl` parameter is passed to the client constructor for compatibility but `tea` resolves the instance from its own login config. The `--include` flag provides HTTP status codes and response headers.
|
||||
- Per-request timeout: 8000 ms via `AbortSignal.timeout` on the spawned process. The process is killed with `SIGKILL` on timeout.
|
||||
- Pagination: `--paginate` is passed for GET requests with query params, causing `tea` to fetch all pages in a single call. The returned `page` object is `null` since pagination is handled by the CLI.
|
||||
- Raw diffs: `--header 'Accept: text/plain'` is passed for the `.diff` endpoint to get raw text output.
|
||||
- `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).
|
||||
- ETag cache and rate-limit cooldown have been dropped with the CLI pivot. Each call spawns a fresh process, so there is no persistent connection for conditional requests or shared rate-limit state. `isGiteaRateLimited()` always returns `false`; `noteGiteaRateLimit()` is a no-op.
|
||||
|
||||
## 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 `tea` CLI handles authentication, base URL resolution, and pagination internally. The client does not maintain its own ETag cache or rate-limit cooldown — each call spawns a fresh process.
|
||||
- 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,269 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { getGiteaAuth } from './auth.js';
|
||||
import { getProviderApiBaseUrl } from '../git-providers/config.js';
|
||||
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
// NOTE: ETag conditional-GET cache and rate-limit cooldown have been dropped
|
||||
// with the pivot to CLI transports. Each call spawns a fresh `tea` process,
|
||||
// so there is no persistent connection to attach conditional headers to, and
|
||||
// rate-limit state cannot be shared across invocations. The tradeoff is higher
|
||||
// latency per call (process spawn overhead) and no 304 short-circuit, but
|
||||
// simpler state management and no module-level mutable cache. Callers that
|
||||
// relied on `isGiteaRateLimited()` will always see false (no cooldown active).
|
||||
|
||||
/**
|
||||
* Spawn a CLI binary and return { stdout, stderr, exitCode }.
|
||||
* Rejects if the process does not finish within REQUEST_TIMEOUT_MS.
|
||||
*/
|
||||
function spawnCli(bin, args, env, timeoutMs = REQUEST_TIMEOUT_MS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`CLI ${bin} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, exitCode: code ?? 1 });
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `Link` header value to extract rel="next" and rel="prev" URLs.
|
||||
* Returns { next, prev } with the raw URL strings (or null).
|
||||
*/
|
||||
function parseLinkHeader(linkHeader) {
|
||||
if (!linkHeader) return { next: null, prev: null };
|
||||
const result = { next: null, prev: null };
|
||||
const parts = linkHeader.split(',');
|
||||
for (const part of parts) {
|
||||
const match = part.match(/<([^>]+)>;\s*rel="(\w+)"/);
|
||||
if (match) {
|
||||
const [, url, rel] = match;
|
||||
if (rel === 'next') result.next = url;
|
||||
if (rel === 'prev') result.prev = url;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a `tea api` call and parse the response envelope.
|
||||
*
|
||||
* tea's `--include` flag writes HTTP status and response headers to **stderr**
|
||||
* and the response body to **stdout**. The client reads both streams:
|
||||
*
|
||||
* stderr: HTTP/2.0 200 OK\nHeader: value\n...\n\n
|
||||
* stdout: {"json": "body"}
|
||||
*
|
||||
* Auth is passed via `-H "Authorization: token <token>"` — the
|
||||
* `GITEA_SERVER_TOKEN` env var is ignored by tea (it uses its own config).
|
||||
*
|
||||
* Pagination: `--paginate` does NOT exist in tea. We parse the `Link` header
|
||||
* from stderr to derive `page` and `hasMore`.
|
||||
*/
|
||||
async function teaApiCall(endpoint, { method = 'GET', body, raw, teaBin, token }) {
|
||||
const args = ['api', '--include'];
|
||||
if (method !== 'GET') args.push('-X', method);
|
||||
if (raw) args.push('--header', 'Accept: text/plain');
|
||||
if (body !== undefined) args.push('--header', 'Content-Type: application/json', '-d', JSON.stringify(body));
|
||||
// Pass auth via header (C2 fix) — tea ignores GITEA_SERVER_TOKEN env.
|
||||
if (token) args.push('-H', `Authorization: token ${token}`);
|
||||
args.push(endpoint);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await spawnCli(teaBin, args, {});
|
||||
} catch (err) {
|
||||
return { status: 500, headers: {}, data: null, page: null, error: err.message };
|
||||
}
|
||||
|
||||
const { stdout, stderr } = result;
|
||||
|
||||
// Parse --include output from stderr: status line, headers, blank line.
|
||||
// stdout contains only the response body.
|
||||
let status = 200;
|
||||
const headers = {};
|
||||
|
||||
const stderrLines = stderr.split('\n');
|
||||
const statusMatch = stderrLines[0]?.match(/HTTP\/\S+\s+(\d+)/);
|
||||
if (statusMatch) {
|
||||
status = Number(statusMatch[1]);
|
||||
for (let i = 1; i < stderrLines.length; i++) {
|
||||
if (stderrLines[i].trim() === '') break;
|
||||
const colonIdx = stderrLines[i].indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
headers[stderrLines[i].slice(0, colonIdx).trim().toLowerCase()] = stderrLines[i].slice(colonIdx + 1).trim();
|
||||
}
|
||||
}
|
||||
} else if (stderr.trim() && !stdout.trim()) {
|
||||
// No --include output on stderr and no body — surface the stderr message.
|
||||
return { status: 500, headers: {}, data: null, page: null, error: stderr.trim() };
|
||||
}
|
||||
|
||||
const bodyText = stdout.trim();
|
||||
if (!bodyText) {
|
||||
return { status, headers, data: null, page: null };
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
return { status, headers, data: bodyText, page: null };
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(bodyText);
|
||||
} catch {
|
||||
data = bodyText;
|
||||
}
|
||||
|
||||
// Derive pagination info object matching the pre-pivot parsePageInfo shape.
|
||||
// routes.js reads resp.page?.hasMore, resp.page?.nextUrl, etc.
|
||||
const { next } = parseLinkHeader(headers.link);
|
||||
const listIsArray = Array.isArray(data);
|
||||
const totalRaw = headers['x-total-count'];
|
||||
const total = totalRaw ? Number(totalRaw) : null;
|
||||
let currentPage = null;
|
||||
if (listIsArray && next) {
|
||||
const pageMatch = next.match(/[?&]page=(\d+)/);
|
||||
if (pageMatch) {
|
||||
currentPage = Math.max(1, Number(pageMatch[1]) - 1);
|
||||
}
|
||||
}
|
||||
const pageInfo = {
|
||||
page: currentPage,
|
||||
next: next || null,
|
||||
total: total !== null && Number.isFinite(total) ? total : null,
|
||||
hasMore: listIsArray ? next !== null : false,
|
||||
nextUrl: next || undefined,
|
||||
};
|
||||
|
||||
return { status, headers, data, page: pageInfo };
|
||||
}
|
||||
|
||||
// ---- Rate-limit helpers (no-ops with CLI transport) ----
|
||||
export function noteGiteaRateLimit() { /* no-op: CLI processes are stateless */ }
|
||||
export function isGiteaRateLimited() { return false; }
|
||||
|
||||
// ---- Response helpers ----
|
||||
|
||||
// Build the relative API path for tea CLI. tea resolves the base URL from its
|
||||
// own login config, so we pass only the /api/v1/... portion.
|
||||
const apiPath = (path) => {
|
||||
const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : '';
|
||||
return `/api/v1${p}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a CLI-backed Gitea/Forgejo REST v1 client. Spawns `tea api` for each
|
||||
* request. `request` never throws for HTTP error statuses — it returns
|
||||
* `{ status, headers, data, page, hasMore }` so callers can branch on status
|
||||
* codes.
|
||||
*/
|
||||
export function createGiteaClient({ token, baseUrl }) {
|
||||
const effectiveBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim().replace(/\/+$/, '') : '';
|
||||
const teaBin = process.env.TEA_BIN || '/home/user/.local/bin/tea';
|
||||
|
||||
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 raw = options.raw === true;
|
||||
|
||||
// Build the relative endpoint path with query params baked in.
|
||||
let endpoint = apiPath(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) {
|
||||
endpoint += `${endpoint.includes('?') ? '&' : '?'}${qs.toString()}`;
|
||||
}
|
||||
|
||||
return teaApiCall(endpoint, { method, body, raw, teaBin, token });
|
||||
};
|
||||
|
||||
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 }),
|
||||
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);
|
||||
if (effectiveBaseUrl !== null && effectiveBaseUrl !== getProviderApiBaseUrl('gitea')) {
|
||||
baseUrl = effectiveBaseUrl;
|
||||
}
|
||||
}
|
||||
return createGiteaClient({ token: auth.accessToken, baseUrl });
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { EventEmitter } from 'events';
|
||||
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 });
|
||||
});
|
||||
|
||||
// Mock child_process.spawn to simulate real `tea api` output.
|
||||
// Real tea behavior:
|
||||
// - `--include` writes status+headers to stderr, body to stdout
|
||||
// - `--paginate` does NOT exist (tea rejects it)
|
||||
// - Auth via `-H "Authorization: token <token>"`, NOT via GITEA_SERVER_TOKEN env
|
||||
const originalSpawn = (await import('child_process')).spawn;
|
||||
let spawnMock = null;
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: (...args) => (spawnMock ? spawnMock(...args) : originalSpawn(...args)),
|
||||
}));
|
||||
|
||||
const {
|
||||
createGiteaClient,
|
||||
getGiteaClientOrNull,
|
||||
isGiteaRateLimited,
|
||||
noteGiteaRateLimit,
|
||||
} = await import('./client.js');
|
||||
|
||||
/**
|
||||
* Build real tea --include output: status+headers on stderr, body on stdout.
|
||||
*/
|
||||
const cliOutput = (data, { status = 200, headers = {} } = {}) => {
|
||||
const stderrLines = [`HTTP/2.0 ${status} OK`];
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
stderrLines.push(`${k}: ${v}`);
|
||||
}
|
||||
stderrLines.push('');
|
||||
return {
|
||||
stdout: typeof data === 'string' ? data : JSON.stringify(data),
|
||||
stderr: stderrLines.join('\n'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a vi.fn() mock spawn that emits stderr (headers) and stdout (body)
|
||||
* separately, matching real tea --include behavior.
|
||||
*/
|
||||
const mockSpawn = (output, { exitCode = 0 } = {}) => {
|
||||
const { stdout, stderr } = typeof output === 'object' && 'stdout' in output
|
||||
? output
|
||||
: cliOutput(output);
|
||||
return vi.fn((...args) => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', stdout);
|
||||
child.stderr.emit('data', stderr);
|
||||
child.emit('close', exitCode);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
spawnMock = null;
|
||||
});
|
||||
|
||||
describe('createGiteaClient request basics', () => {
|
||||
test('spawns tea api --include and passes auth via -H header (not env)', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ id: 42, login: 'alice' }));
|
||||
|
||||
const client = createGiteaClient({ token: 'gitea-token', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.user();
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const [bin, args, opts] = spawnMock.mock.calls[0];
|
||||
expect(bin).toBe('/home/user/.local/bin/tea');
|
||||
expect(args).toContain('--include');
|
||||
expect(args).toContain('-H');
|
||||
expect(args).toContain('Authorization: token gitea-token');
|
||||
expect(args).not.toContain('--paginate');
|
||||
// Auth is via header, NOT via GITEA_SERVER_TOKEN env (C2 fix).
|
||||
expect(opts.env.GITEA_SERVER_TOKEN).toBeUndefined();
|
||||
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 () => {
|
||||
spawnMock = mockSpawn(cliOutput([]));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com/gitea/' });
|
||||
await client.issues('owner', 'repo', { state: 'open' });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('/api/v1/repos/owner/repo/issues?state=open');
|
||||
});
|
||||
|
||||
test('serializes query params and omits empty ones', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([]));
|
||||
|
||||
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 [, args] = spawnMock.mock.calls[0];
|
||||
const endpoint = args[args.length - 1];
|
||||
expect(endpoint).toContain('state=open');
|
||||
expect(endpoint).toContain('limit=50');
|
||||
expect(endpoint).toContain('page=2');
|
||||
expect(endpoint).not.toContain('q');
|
||||
expect(endpoint).not.toContain('sort');
|
||||
});
|
||||
|
||||
test('POST requests pass the method flag and a JSON body', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ ok: true }, { status: 201 }));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.request('/some/action', { method: 'POST', body: { hello: 'world' } });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args).toContain('-d');
|
||||
expect(args).toContain(JSON.stringify({ hello: 'world' }));
|
||||
});
|
||||
|
||||
test('surfaces error statuses without throwing', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ message: 'nope' }, { status: 401 }));
|
||||
|
||||
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('raw requests pass Accept: text/plain header', async () => {
|
||||
spawnMock = mockSpawn(cliOutput('diff --git a/src/a.ts b/src/a.ts\n'));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.pullRequestDiff('owner', 'repo', 5);
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('--header');
|
||||
expect(args).toContain('Accept: text/plain');
|
||||
expect(args).toContain('/api/v1/repos/owner/repo/pulls/5.diff');
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data).toBe('diff --git a/src/a.ts b/src/a.ts');
|
||||
});
|
||||
|
||||
test('returns 500 on CLI process error', async () => {
|
||||
spawnMock = (...args) => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
child.emit('error', new Error('ENOENT'));
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.user();
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
test('returns 500 on non-zero exit with no stdout and no stderr status line', async () => {
|
||||
spawnMock = mockSpawn({ stdout: '', stderr: 'not logged in' }, { exitCode: 1 });
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.user();
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.error).toBe('not logged in');
|
||||
});
|
||||
|
||||
test('parses status code from stderr --include output (C3 fix)', async () => {
|
||||
// Simulate tea's real output: 403 on stderr, error body on stdout.
|
||||
spawnMock = mockSpawn(cliOutput({ message: 'forbidden' }, { status: 403 }));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.user();
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.data).toEqual({ message: 'forbidden' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
test('page is an object with hasMore=true when Link header has rel="next"', async () => {
|
||||
spawnMock = mockSpawn(
|
||||
cliOutput([{ id: 1 }], {
|
||||
headers: {
|
||||
'Link': '<https://gitea.example.com/api/v1/repos/o/r/issues?limit=1&page=2>; rel="next"',
|
||||
'X-Total-Count': '10',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.issues('o', 'r', { limit: 1, page: 1 });
|
||||
// page is the object shape that routes.js expects
|
||||
expect(result.page).toEqual(expect.objectContaining({
|
||||
hasMore: true,
|
||||
nextUrl: 'https://gitea.example.com/api/v1/repos/o/r/issues?limit=1&page=2',
|
||||
next: 'https://gitea.example.com/api/v1/repos/o/r/issues?limit=1&page=2',
|
||||
page: 1,
|
||||
total: 10,
|
||||
}));
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
});
|
||||
|
||||
test('page is an object with hasMore=false when no Link header', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([{ id: 1 }]));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.issues('o', 'r', { limit: 50 });
|
||||
expect(result.page).toEqual(expect.objectContaining({
|
||||
hasMore: false,
|
||||
next: null,
|
||||
total: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('page is an object for non-array responses too', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ id: 42 }));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.user();
|
||||
expect(result.page).toEqual(expect.objectContaining({
|
||||
hasMore: false,
|
||||
next: null,
|
||||
page: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('does NOT pass --paginate flag to tea (C1 fix)', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([]));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.issues('o', 'r', { state: 'open', page: 2 });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).not.toContain('--paginate');
|
||||
expect(args).not.toContain('-paginate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pull request write methods', () => {
|
||||
test('createPullRequest POSTs to the pulls endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ number: 5, title: 'New PR' }, { status: 201 }));
|
||||
|
||||
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 [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args).toContain(JSON.stringify({ title: 'New PR', head: 'feat/x', base: 'main' }));
|
||||
expect(args.some(a => typeof a === 'string' && a.includes('/repos/owner/repo/pulls'))).toBe(true);
|
||||
expect(result.status).toBe(201);
|
||||
expect(result.data).toEqual({ number: 5, title: 'New PR' });
|
||||
});
|
||||
|
||||
test('updatePullRequest PATCHes to the pull request endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ number: 5, title: 'Updated' }));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.updatePullRequest('owner', 'repo', 5, { title: 'Updated', body: 'Body text' });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('PATCH');
|
||||
expect(args).toContain(JSON.stringify({ title: 'Updated', body: 'Body text' }));
|
||||
});
|
||||
|
||||
test('mergePullRequest POSTs the merge style to the merge endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ merged: true }));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.mergePullRequest('owner', 'repo', 5, { Do: 'squash' });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args).toContain(JSON.stringify({ Do: 'squash' }));
|
||||
expect(args.some(a => typeof a === 'string' && a.includes('/pulls/5/merge'))).toBe(true);
|
||||
});
|
||||
|
||||
test('write methods surface error statuses without throwing', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ message: 'Conflict' }, { status: 409 }));
|
||||
|
||||
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 () => {
|
||||
spawnMock = mockSpawn(cliOutput({ id: 5, body: 'hi' }, { status: 201 }));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.createIssueComment('owner', 'repo', 7, 'Nice catch');
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args).toContain(JSON.stringify({ body: 'Nice catch' }));
|
||||
expect(args.some(a => typeof a === 'string' && a.includes('/issues/7/comments'))).toBe(true);
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
test('updateIssue PATCHes params to the issue endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ number: 7, title: 'Updated' }));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.updateIssue('owner', 'repo', 7, { state: 'closed', labels: ['bug'], milestone: 33 });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('PATCH');
|
||||
expect(args).toContain(JSON.stringify({ state: 'closed', labels: ['bug'], milestone: 33 }));
|
||||
});
|
||||
|
||||
test('createPullReview POSTs event/body to the reviews endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ id: 101, state: 'APPROVED' }, { status: 201 }));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.createPullReview('owner', 'repo', 12, { event: 'APPROVED', body: 'LGTM' });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args).toContain(JSON.stringify({ event: 'APPROVED', body: 'LGTM' }));
|
||||
});
|
||||
|
||||
test('milestones passes state and limit query params', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([{ id: 33, title: 'v1.0' }]));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.milestones('owner', 'repo', { state: 'all', limit: 50 });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
const endpoint = args[args.length - 1];
|
||||
expect(endpoint).toContain('state=all');
|
||||
expect(endpoint).toContain('limit=50');
|
||||
});
|
||||
|
||||
test('repoLabels passes limit query param', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([{ id: 1, name: 'bug', color: 'd73a4a' }]));
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.repoLabels('owner', 'repo', { limit: 100 });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
const endpoint = args[args.length - 1];
|
||||
expect(endpoint).toContain('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('isGiteaRateLimited returns false (no-op with CLI transport)', () => {
|
||||
expect(isGiteaRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
test('noteGiteaRateLimit is a no-op', () => {
|
||||
noteGiteaRateLimit({ headers: new Headers({ 'retry-after': '120' }) });
|
||||
expect(isGiteaRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
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,151 @@
|
||||
# GitLab Module Documentation
|
||||
|
||||
## Purpose
|
||||
|
||||
- This module owns GitLab auth (Personal Access Token), CLI-backed 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 via the **`glab` CLI** transport instead of raw `fetch`.
|
||||
|
||||
## 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`: CLI-backed `glab api` client (process spawn with 8s timeout, `--include` for HTTP status/headers, `--paginate` for list endpoints). Token is passed via `GITLAB_TOKEN` env var.
|
||||
- `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
|
||||
|
||||
- Transport: each call spawns a `glab api` process with `--include` for HTTP status/headers. Auth is passed via `GITLAB_TOKEN` env var (never on argv). Binary path: `GLAB_BIN` env or `/home/user/.local/bin/glab`.
|
||||
- Base URL: the `baseUrl` parameter is passed to the client constructor for compatibility but `glab` resolves the instance from its own config. The `--include` flag provides HTTP status codes and response headers.
|
||||
- Per-request timeout: 8000 ms via `AbortSignal.timeout` on the spawned process. The process is killed with `SIGKILL` on timeout.
|
||||
- Pagination: `--paginate` is passed for GET requests with query params, causing `glab` to fetch all pages in a single call. The returned `page` object is `null` since pagination is handled by the CLI.
|
||||
- `request` never throws for HTTP error statuses — callers branch on `status`.
|
||||
- ETag cache and rate-limit cooldown have been dropped with the CLI pivot. Each call spawns a fresh process, so there is no persistent connection for conditional requests or shared rate-limit state. `isGitLabRateLimited()` always returns `false`; `noteGitLabRateLimit()` is a no-op.
|
||||
|
||||
## 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 `glab` CLI handles authentication, base URL resolution, and pagination internally. The client does not maintain its own ETag cache or rate-limit cooldown — each call spawns a fresh process.
|
||||
- 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,273 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { getGitLabAuth, getGitLabDefaultBaseUrl } from './auth.js';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
// NOTE: ETag conditional-GET cache and rate-limit cooldown have been dropped
|
||||
// with the pivot to CLI transports. Each call spawns a fresh `glab` process,
|
||||
// so there is no persistent connection to attach conditional headers to, and
|
||||
// rate-limit state cannot be shared across invocations. The tradeoff is higher
|
||||
// latency per call (process spawn overhead) and no 304 short-circuit, but
|
||||
// simpler state management and no module-level mutable cache. Callers that
|
||||
// relied on `isGitLabRateLimited()` will always see false (no cooldown active).
|
||||
|
||||
/**
|
||||
* Spawn a CLI binary and return { stdout, stderr, exitCode }.
|
||||
* Rejects if the process does not finish within REQUEST_TIMEOUT_MS.
|
||||
*/
|
||||
function spawnCli(bin, args, env, timeoutMs = REQUEST_TIMEOUT_MS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`CLI ${bin} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr, exitCode: code ?? 1 });
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `Link` header value to extract rel="next" and rel="prev" URLs.
|
||||
* Returns { next, prev } with the raw URL strings (or null).
|
||||
*/
|
||||
function parseLinkHeader(linkHeader) {
|
||||
if (!linkHeader) return { next: null, prev: null };
|
||||
const result = { next: null, prev: null };
|
||||
const parts = linkHeader.split(',');
|
||||
for (const part of parts) {
|
||||
const match = part.match(/<([^>]+)>;\s*rel="(\w+)"/);
|
||||
if (match) {
|
||||
const [, url, rel] = match;
|
||||
if (rel === 'next') result.next = url;
|
||||
if (rel === 'prev') result.prev = url;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a `glab api` call and parse the response envelope.
|
||||
*
|
||||
* `glab api --include` outputs everything to **stdout**:
|
||||
* <status line: HTTP/1.1 200 OK>
|
||||
* <headers, one per line>
|
||||
* <empty line>
|
||||
* <JSON body>
|
||||
*
|
||||
* On non-zero exit, stderr gets a human-readable error summary (e.g.,
|
||||
* `glab: 401 Unauthorized (HTTP 401)`).
|
||||
*
|
||||
* GITLAB_TOKEN env var IS respected by glab (unlike tea's GITEA_SERVER_TOKEN).
|
||||
*
|
||||
* Pagination: `--paginate` does NOT exist in glab. We parse the `Link` header
|
||||
* to derive `page` and `hasMore`.
|
||||
*/
|
||||
async function glabApiCall(endpoint, { method = 'GET', body, raw, glabBin, token }) {
|
||||
const args = ['api', '--include'];
|
||||
if (method !== 'GET') args.push('-X', method);
|
||||
if (raw) args.push('--header', 'Accept: text/plain');
|
||||
if (body !== undefined) args.push('--header', 'Content-Type: application/json', '-d', JSON.stringify(body));
|
||||
args.push(endpoint);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await spawnCli(glabBin, args, { GITLAB_TOKEN: token });
|
||||
} catch (err) {
|
||||
return { status: 500, headers: {}, data: null, page: null, error: err.message };
|
||||
}
|
||||
|
||||
const { stdout, stderr, exitCode } = result;
|
||||
|
||||
// glab --include puts status+headers+body all on stdout.
|
||||
// On error, stdout may still have the full response, and stderr has the summary.
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
return { status: 500, headers: {}, data: null, page: null, error: stderr.trim() || `glab exited with code ${exitCode}` };
|
||||
}
|
||||
|
||||
// Parse --include output: status line, headers, blank line, body.
|
||||
const lines = stdout.split('\n');
|
||||
let status = 200;
|
||||
const headers = {};
|
||||
let bodyStart = 0;
|
||||
|
||||
const statusMatch = lines[0]?.match(/HTTP\/\S+\s+(\d+)/);
|
||||
if (statusMatch) {
|
||||
status = Number(statusMatch[1]);
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '') {
|
||||
bodyStart = i + 1;
|
||||
break;
|
||||
}
|
||||
const colonIdx = lines[i].indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
headers[lines[i].slice(0, colonIdx).trim().toLowerCase()] = lines[i].slice(colonIdx + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = lines.slice(bodyStart).join('\n').trim();
|
||||
if (!bodyText) {
|
||||
return { status, headers, data: null, page: null };
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
return { status, headers, data: bodyText, page: null };
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(bodyText);
|
||||
} catch {
|
||||
data = bodyText;
|
||||
}
|
||||
|
||||
// Derive pagination info object matching the pre-pivot parsePageInfo shape.
|
||||
// routes.js reads resp.page?.hasMore, resp.page?.nextUrl, etc.
|
||||
const { next } = parseLinkHeader(headers.link);
|
||||
const listIsArray = Array.isArray(data);
|
||||
const totalRaw = headers['x-total'] || headers['x-total-count'];
|
||||
const total = totalRaw ? Number(totalRaw) : null;
|
||||
let currentPage = null;
|
||||
if (listIsArray && next) {
|
||||
const pageMatch = next.match(/[?&]page=(\d+)/);
|
||||
if (pageMatch) {
|
||||
currentPage = Math.max(1, Number(pageMatch[1]) - 1);
|
||||
}
|
||||
}
|
||||
const pageInfo = {
|
||||
page: currentPage,
|
||||
next: next || null,
|
||||
total: total !== null && Number.isFinite(total) ? total : null,
|
||||
hasMore: listIsArray ? next !== null : false,
|
||||
nextUrl: next || undefined,
|
||||
};
|
||||
|
||||
return { status, headers, data, page: pageInfo };
|
||||
}
|
||||
|
||||
// ---- Rate-limit helpers (no-ops with CLI transport) ----
|
||||
export function noteGitLabRateLimit() { /* no-op: CLI processes are stateless */ }
|
||||
export function isGitLabRateLimited() { return false; }
|
||||
|
||||
// ---- Response helpers ----
|
||||
|
||||
const encodeProject = (pathWithNamespace) => encodeURIComponent(String(pathWithNamespace));
|
||||
|
||||
// Build the relative API path for glab CLI. glab resolves the base URL from its
|
||||
// own config, so we pass the path WITHOUT the /api/v4 prefix — glab adds it.
|
||||
const apiPath = (path) => {
|
||||
const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : '';
|
||||
return p;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a CLI-backed GitLab REST v4 client. Spawns `glab api` for each
|
||||
* request. `request` never throws for HTTP error statuses — it returns
|
||||
* `{ status, headers, data, page, hasMore }` so callers can branch on status
|
||||
* codes.
|
||||
*/
|
||||
export function createGitLabClient({ token, baseUrl }) {
|
||||
const effectiveBaseUrl = normalizeBaseForClient(baseUrl);
|
||||
const glabBin = process.env.GLAB_BIN || '/home/user/.local/bin/glab';
|
||||
|
||||
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;
|
||||
|
||||
// Build the relative endpoint path with query params baked in.
|
||||
let endpoint = apiPath(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) {
|
||||
endpoint += `${endpoint.includes('?') ? '&' : '?'}${qs.toString()}`;
|
||||
}
|
||||
|
||||
return glabApiCall(endpoint, { method, body, glabBin, token });
|
||||
};
|
||||
|
||||
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 }),
|
||||
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,372 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { EventEmitter } from 'events';
|
||||
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 });
|
||||
});
|
||||
|
||||
// Mock child_process.spawn to simulate real `glab api` output.
|
||||
// Real glab behavior:
|
||||
// - `--include` writes status+headers+body ALL to stdout (not stderr)
|
||||
// - On error, stderr gets human-readable summary (e.g. "glab: 401 Unauthorized (HTTP 401)")
|
||||
// - `--paginate` does NOT exist
|
||||
// - GITLAB_TOKEN env IS respected (unlike tea's GITEA_SERVER_TOKEN)
|
||||
// - Paths must NOT include /api/v4 prefix (glab adds it)
|
||||
const originalSpawn = (await import('child_process')).spawn;
|
||||
let spawnMock = null;
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: (...args) => (spawnMock ? spawnMock(...args) : originalSpawn(...args)),
|
||||
}));
|
||||
|
||||
const {
|
||||
createGitLabClient,
|
||||
getGitLabClientOrNull,
|
||||
isGitLabRateLimited,
|
||||
noteGitLabRateLimit,
|
||||
} = await import('./client.js');
|
||||
|
||||
/**
|
||||
* Build real glab --include output: status+headers+body all on stdout.
|
||||
*/
|
||||
const cliOutput = (data, { status = 200, headers = {} } = {}) => {
|
||||
const lines = [`HTTP/2.0 ${status} OK`];
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
lines.push(`${k}: ${v}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(typeof data === 'string' ? data : JSON.stringify(data));
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a vi.fn() mock spawn that delivers `output` on stdout and optionally
|
||||
* a stderr message, matching real glab --include behavior.
|
||||
*/
|
||||
const mockSpawn = (output, { exitCode = 0, stderr = '' } = {}) => {
|
||||
return vi.fn((...args) => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', output);
|
||||
if (stderr) child.stderr.emit('data', stderr);
|
||||
child.emit('close', exitCode);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
spawnMock = null;
|
||||
});
|
||||
|
||||
describe('createGitLabClient request basics', () => {
|
||||
test('spawns glab api --include and sends GITLAB_TOKEN env', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ id: 42, username: 'alice' }));
|
||||
|
||||
const client = createGitLabClient({ token: 'glpat-token', baseUrl: 'https://gitlab.com' });
|
||||
const result = await client.user();
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const [bin, args, opts] = spawnMock.mock.calls[0];
|
||||
expect(bin).toBe('/home/user/.local/bin/glab');
|
||||
// C4 fix: path has NO /api/v4 prefix — glab adds it.
|
||||
expect(args).toEqual(['api', '--include', '/user']);
|
||||
expect(opts.env.GITLAB_TOKEN).toBe('glpat-token');
|
||||
expect(result).toMatchObject({ status: 200, data: { id: 42, username: 'alice' } });
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('joins a custom base URL path without /api/v4 prefix (C4 fix)', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([]));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.example.com/gitlab/' });
|
||||
await client.issues('group/sub', { state: 'opened' });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
const endpoint = args[args.length - 1];
|
||||
// C4 fix: path does NOT include /api/v4 — glab adds it automatically.
|
||||
expect(endpoint).toContain('/projects/group%2Fsub/issues?state=opened');
|
||||
expect(endpoint).not.toContain('/api/v4');
|
||||
});
|
||||
|
||||
test('encodes project path namespaces exactly once', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([]));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.mergeRequest('a/b/c', 5);
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
const endpoint = args[args.length - 1];
|
||||
expect(endpoint).toContain('a%2Fb%2Fc/merge_requests/5');
|
||||
expect(endpoint).not.toContain('%252F');
|
||||
});
|
||||
|
||||
test('serializes query params and omits empty ones', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([]));
|
||||
|
||||
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 [, args] = spawnMock.mock.calls[0];
|
||||
const endpoint = args[args.length - 1];
|
||||
expect(endpoint).toContain('state=opened');
|
||||
expect(endpoint).toContain('per_page=50');
|
||||
expect(endpoint).toContain('page=2');
|
||||
expect(endpoint).not.toContain('search');
|
||||
expect(endpoint).not.toContain('sort');
|
||||
});
|
||||
|
||||
test('POST requests pass the method flag and a JSON body', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ ok: true }, { status: 201 }));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.request('/some/action', { method: 'POST', body: { hello: 'world' } });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args).toContain('-d');
|
||||
expect(args).toContain(JSON.stringify({ hello: 'world' }));
|
||||
});
|
||||
|
||||
test('surfaces error statuses without throwing', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ message: 'nope' }, { status: 401 }));
|
||||
|
||||
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('returns 500 on CLI process error', async () => {
|
||||
spawnMock = (...args) => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
child.emit('error', new Error('ENOENT'));
|
||||
});
|
||||
return child;
|
||||
};
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
const result = await client.user();
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
test('returns 500 on non-zero exit with no stdout', async () => {
|
||||
spawnMock = mockSpawn('', { exitCode: 1, stderr: 'glab: 401 Unauthorized (HTTP 401)' });
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
const result = await client.user();
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.error).toBe('glab: 401 Unauthorized (HTTP 401)');
|
||||
});
|
||||
|
||||
test('does NOT pass --paginate flag to glab', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([]));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.issues('g/p', { state: 'opened' });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).not.toContain('--paginate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
test('page is an object with hasMore=true when Link header has rel="next"', async () => {
|
||||
spawnMock = mockSpawn(
|
||||
cliOutput([{ id: 1 }], {
|
||||
headers: {
|
||||
'Link': '<https://gitlab.com/api/v4/projects/g%2Fp/issues?page=2>; rel="next"',
|
||||
'X-Total': '10',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
const result = await client.issues('g/p', { per_page: 1 });
|
||||
expect(result.page).toEqual(expect.objectContaining({
|
||||
hasMore: true,
|
||||
nextUrl: 'https://gitlab.com/api/v4/projects/g%2Fp/issues?page=2',
|
||||
next: 'https://gitlab.com/api/v4/projects/g%2Fp/issues?page=2',
|
||||
page: 1,
|
||||
total: 10,
|
||||
}));
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
});
|
||||
|
||||
test('page is an object with hasMore=false when no Link header', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([{ id: 1 }]));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
const result = await client.issues('g/p', { per_page: 50 });
|
||||
expect(result.page).toEqual(expect.objectContaining({
|
||||
hasMore: false,
|
||||
next: null,
|
||||
total: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('page is an object for non-array responses too', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ id: 42 }));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
const result = await client.user();
|
||||
expect(result.page).toEqual(expect.objectContaining({
|
||||
hasMore: false,
|
||||
next: null,
|
||||
page: null,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge request write methods', () => {
|
||||
test('createMergeRequest POSTs a JSON body to the merge_requests endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ iid: 5, title: 'New MR' }, { status: 201 }));
|
||||
|
||||
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 [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args).toContain(JSON.stringify({ source_branch: 'feat/x', target_branch: 'main', title: 'New MR' }));
|
||||
expect(args.some(a => typeof a === 'string' && a.includes('/merge_requests'))).toBe(true);
|
||||
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 () => {
|
||||
spawnMock = mockSpawn(cliOutput({ iid: 5, title: 'Updated' }));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.updateMergeRequest('group/sub', 5, { title: 'Updated', description: 'Body text' });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('PUT');
|
||||
expect(args).toContain(JSON.stringify({ title: 'Updated', description: 'Body text' }));
|
||||
});
|
||||
|
||||
test('mergeMergeRequest PUTs a JSON body to the merge endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ iid: 5, state: 'merged' }));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.mergeMergeRequest('group/sub', 5, { squash: true });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('PUT');
|
||||
expect(args).toContain(JSON.stringify({ squash: true }));
|
||||
expect(args.some(a => typeof a === 'string' && a.includes('/merge'))).toBe(true);
|
||||
});
|
||||
|
||||
test('write methods surface error statuses without throwing', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ message: 'Method Not Allowed' }, { status: 405 }));
|
||||
|
||||
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 () => {
|
||||
spawnMock = mockSpawn(cliOutput({ id: 5, body: 'hi' }, { status: 201 }));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
const result = await client.createIssueNote('group/sub', 7, 'Nice catch');
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args).toContain(JSON.stringify({ body: 'Nice catch' }));
|
||||
expect(args.some(a => typeof a === 'string' && a.includes('/issues/7/notes'))).toBe(true);
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
test('createMrNote POSTs a body to the MR notes endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ id: 8, body: 'LGTM' }, { status: 201 }));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.createMrNote('group/sub', 12, 'LGTM');
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args).toContain(JSON.stringify({ body: 'LGTM' }));
|
||||
expect(args.some(a => typeof a === 'string' && a.includes('/merge_requests/12/notes'))).toBe(true);
|
||||
});
|
||||
|
||||
test('updateIssue PUTs params to the issue endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ iid: 7, title: 'Updated' }));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.updateIssue('group/sub', 7, { state_event: 'close', labels: ['bug'], milestone_id: 33 });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('PUT');
|
||||
expect(args).toContain(JSON.stringify({ state_event: 'close', labels: ['bug'], milestone_id: 33 }));
|
||||
});
|
||||
|
||||
test('approveMr POSTs to the approve endpoint', async () => {
|
||||
spawnMock = mockSpawn(cliOutput({ id: 1, state: 'approved' }));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.approveMr('group/sub', 12);
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
expect(args).toContain('-X');
|
||||
expect(args).toContain('POST');
|
||||
expect(args.some(a => typeof a === 'string' && a.includes('/approve'))).toBe(true);
|
||||
});
|
||||
|
||||
test('milestones passes state and per_page query params', async () => {
|
||||
spawnMock = mockSpawn(cliOutput([{ id: 33, title: 'v1.0' }]));
|
||||
|
||||
const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' });
|
||||
await client.milestones('group/sub', { state: 'all', per_page: 100 });
|
||||
|
||||
const [, args] = spawnMock.mock.calls[0];
|
||||
const endpoint = args[args.length - 1];
|
||||
expect(endpoint).toContain('state=all');
|
||||
expect(endpoint).toContain('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('isGitLabRateLimited returns false (no-op with CLI transport)', () => {
|
||||
expect(isGitLabRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
test('noteGitLabRateLimit is a no-op', () => {
|
||||
noteGitLabRateLimit({ headers: new Headers({ 'retry-after': '120' }) });
|
||||
expect(isGitLabRateLimited()).toBe(false);
|
||||
});
|
||||
|
||||
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
@@ -218,7 +218,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)`
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { registerWalkthroughRoutes } from '../walkthrough/routes.js';
|
||||
import { registerSessionGoalRoutes } from '../session-goal/routes.js';
|
||||
import { registerGitHubRoutes } from '../github/routes.js';
|
||||
import { registerLinearRoutes } from '../linear/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';
|
||||
@@ -306,6 +309,9 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
registerSessionGoalRoutes(app);
|
||||
registerGitHubRoutes(app);
|
||||
registerLinearRoutes(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';
|
||||
import {
|
||||
DEFAULT_INPUT_HISTORY_LIMIT,
|
||||
@@ -510,6 +511,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);
|
||||
}
|
||||
|
||||
@@ -674,6 +674,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