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:
@@ -4,12 +4,15 @@
|
||||
|
||||
- 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`: the single module file, exporting the helpers directly.
|
||||
- `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
|
||||
## 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`).
|
||||
@@ -21,6 +24,22 @@
|
||||
- `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); `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`.
|
||||
- `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`:
|
||||
@@ -37,6 +56,25 @@
|
||||
- `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": {
|
||||
"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.
|
||||
- `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).
|
||||
@@ -46,5 +84,5 @@
|
||||
|
||||
## Notes for contributors
|
||||
|
||||
- Readers must never throw: `readGitProvidersConfig` and `githubWebOriginFromApiBase` fail closed.
|
||||
- No new dependencies.
|
||||
- Readers must never throw: `readGitProvidersConfig`, `readProjectJson`, and `githubWebOriginFromApiBase` fail closed.
|
||||
- No new dependencies.
|
||||
@@ -0,0 +1,318 @@
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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). Returns undefined when nothing valid remains.
|
||||
*/
|
||||
export function sanitizeProjectGitProviders(payload) {
|
||||
const sanitized = sanitizeGitProviders(payload);
|
||||
if (!sanitized) {
|
||||
return undefined;
|
||||
}
|
||||
const result = {};
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,349 @@
|
||||
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,
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user