feat(web,ui): per-project git provider API base URL overrides
Per provider (github|gitlab|gitea) a project override stored in ~/.config/openchamber/projects/<projectId>.json under gitProviders wins over the global settings.json value (precedence: project override > global > built-in default). Server forge routes resolve the override per request directory (worktree-aware via git-common-dir + containment + path fallback, 60s TTL cache); the override host is also accepted for remote parsing and client detection. New GET/PUT /api/projects/:projectId/git-providers route; client openchamberConfig preserves the server-owned gitProviders key; Projects page gains a Git provider API base URLs section; detection store hydrates per-project overrides (memory-only, server-authoritative).
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
- 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. 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>`.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getRemoteUrl } from '../git/index.js';
|
||||
import { getGitLabAuthAccounts, normalizeBaseUrl } from './auth.js';
|
||||
import { getEffectiveProviderApiBaseUrl } 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.
|
||||
@@ -115,8 +116,23 @@ export async function resolveGitLabRepoFromDirectory(directory, remoteName = 'or
|
||||
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
|
||||
}
|
||||
}
|
||||
return {
|
||||
repo: parseGitLabRemoteUrl(remoteUrl),
|
||||
repo: parseGitLabRemoteUrl(remoteUrl, knownHosts),
|
||||
remoteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,21 @@ 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);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { parseGitLabRemoteUrl, resolveGitLabRepoFromDirectory } = await import('./repo.js');
|
||||
const { getRemoteUrl } = await import('../git/index.js');
|
||||
const { setGitLabAuth, clearGitLabAuth } = await import('./auth.js');
|
||||
@@ -119,4 +134,17 @@ describe('resolveGitLabRepoFromDirectory', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
|
||||
|
||||
// Route-level budget for composite GitLab calls (lists, comments, MR context).
|
||||
// The client bounds each individual request at 8s; this caps the whole route
|
||||
// so a slow self-hosted instance cannot hold a response (and a client socket)
|
||||
@@ -287,9 +289,36 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return gitlabLibraries;
|
||||
};
|
||||
|
||||
const getClient = async () => {
|
||||
const { getGitLabClientOrNull } = await getGitLabLibraries();
|
||||
return getGitLabClientOrNull();
|
||||
const hostFromBaseUrl = (baseUrl) => {
|
||||
try {
|
||||
return new URL(baseUrl).hostname || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getClient = async (directory) => {
|
||||
const { getGitLabClientOrNull, createGitLabClient, getGitLabAuth, getGitLabDefaultBaseUrl } = await getGitLabLibraries();
|
||||
const auth = getGitLabAuth();
|
||||
if (!auth?.accessToken) {
|
||||
return null;
|
||||
}
|
||||
const effectiveBaseUrl = directory ? getEffectiveProviderApiBaseUrl('gitlab', directory) : null;
|
||||
// No project override: the account's own base URL keeps driving requests
|
||||
// exactly as before.
|
||||
if (!effectiveBaseUrl || effectiveBaseUrl === getGitLabDefaultBaseUrl()) {
|
||||
return getGitLabClientOrNull();
|
||||
}
|
||||
// A per-project override is in play. A connected account whose host
|
||||
// matches the remote still wins; otherwise the override serves as the API
|
||||
// base (it makes its host acceptable even with no account covering it).
|
||||
const accountHost = hostFromBaseUrl(auth.baseUrl);
|
||||
const { resolveGitLabRepoFromDirectory } = await getGitLabLibraries();
|
||||
const { repo } = await resolveGitLabRepoFromDirectory(directory).catch(() => ({ repo: null }));
|
||||
if (repo?.host && accountHost && accountHost === repo.host) {
|
||||
return getGitLabClientOrNull();
|
||||
}
|
||||
return createGitLabClient({ token: auth.accessToken, baseUrl: effectiveBaseUrl });
|
||||
};
|
||||
|
||||
// Resolve which GitLab project a request targets. A directory-local git
|
||||
@@ -474,7 +503,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
|
||||
const searchQuery = asString(req.query?.query);
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false, issues: [], page: effectivePage, hasMore: false });
|
||||
}
|
||||
@@ -522,7 +551,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'number is required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false, issue: null });
|
||||
}
|
||||
@@ -564,7 +593,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'number is required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false, comments: [] });
|
||||
}
|
||||
@@ -619,7 +648,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -682,7 +711,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
? req.body.labels.filter((label) => typeof label === 'string' && label.length > 0)
|
||||
: undefined;
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -732,7 +761,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'directory and number are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -828,7 +857,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
const searchQuery = asString(req.query?.query);
|
||||
const sourceBranch = asString(req.query?.sourceBranch);
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false, mrs: [], page: effectivePage, hasMore: false });
|
||||
}
|
||||
@@ -880,7 +909,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'number is required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false, mr: null, comments: [], files: [] });
|
||||
}
|
||||
@@ -997,7 +1026,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'number is required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false, commits: [] });
|
||||
}
|
||||
@@ -1051,7 +1080,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'number is required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false, events: [] });
|
||||
}
|
||||
@@ -1113,7 +1142,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
? req.body.removeSourceBranch
|
||||
: false;
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1170,7 +1199,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
const title = asString(req.body?.title);
|
||||
const description = typeof req.body?.description === 'string' ? req.body.description : undefined;
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1261,7 +1290,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
}
|
||||
const squash = typeof req.body?.squash === 'boolean' ? req.body.squash : undefined;
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1318,7 +1347,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1377,7 +1406,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'directory and number are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1428,7 +1457,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
||||
if (!directory && !requestedProject) {
|
||||
return { error: 'directory or namespace/project is required' };
|
||||
}
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return { client: null };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user