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:
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { reportSettingsSaveState } from '@/lib/persistence';
|
||||
import { ProjectSettingsSubsection } from '@/components/sections/projects/ProjectSettingsSubsection';
|
||||
import { SettingsStackedField } from '@/components/sections/shared/SettingsSection';
|
||||
import { getProjectGitProviders, saveProjectGitProviders } from '@/lib/projectGitProviders';
|
||||
import {
|
||||
useGitProviderDomainsStore,
|
||||
type GitProviderApiBaseUrls,
|
||||
type GitProviderName,
|
||||
} from '@/stores/useGitProviderDomainsStore';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
|
||||
const GIT_PROVIDERS: GitProviderName[] = ['github', 'gitlab', 'gitea'];
|
||||
|
||||
const EMPTY_API_BASE_URLS: GitProviderApiBaseUrls = { github: '', gitlab: '', gitea: '' };
|
||||
|
||||
/**
|
||||
* Read the per-provider `apiBaseUrl` overrides out of an untyped server
|
||||
* `gitProviders` payload. Unknown or malformed entries collapse to ''.
|
||||
*/
|
||||
const readProjectApiBaseUrls = (gitProviders: unknown): GitProviderApiBaseUrls => {
|
||||
const result: GitProviderApiBaseUrls = { ...EMPTY_API_BASE_URLS };
|
||||
if (!gitProviders || typeof gitProviders !== 'object' || Array.isArray(gitProviders)) {
|
||||
return result;
|
||||
}
|
||||
const config = gitProviders as Record<string, unknown>;
|
||||
for (const provider of GIT_PROVIDERS) {
|
||||
const entry = config[provider];
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
const apiBaseUrl = (entry as Record<string, unknown>).apiBaseUrl;
|
||||
result[provider] = typeof apiBaseUrl === 'string' ? apiBaseUrl.trim() : '';
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the full per-project `gitProviders` object for the server. The server
|
||||
* replaces the whole `gitProviders` key on PUT, so every provider is sent
|
||||
* together; providers with an empty override are omitted.
|
||||
*/
|
||||
const buildGitProvidersPayload = (
|
||||
drafts: GitProviderApiBaseUrls,
|
||||
): Partial<Record<GitProviderName, { apiBaseUrl: string }>> => {
|
||||
const payload: Partial<Record<GitProviderName, { apiBaseUrl: string }>> = {};
|
||||
for (const provider of GIT_PROVIDERS) {
|
||||
const url = drafts[provider].trim();
|
||||
if (url) {
|
||||
payload[provider] = { apiBaseUrl: url };
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
type ProjectGitProvidersSectionProps = {
|
||||
projectRef: ProjectRef;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-project git provider API base URL overrides. Each provider's override is
|
||||
* persisted through the project-scoped `/api/projects/:id/git-providers` route;
|
||||
* empty overrides fall back to the global server settings value. Commits on
|
||||
* blur or Enter and re-hydrates the detection store so a new host applies
|
||||
* immediately.
|
||||
*/
|
||||
export const ProjectGitProvidersSection: React.FC<ProjectGitProvidersSectionProps> = ({ projectRef }) => {
|
||||
const { t } = useI18n();
|
||||
const globalApiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls);
|
||||
const [drafts, setDrafts] = React.useState<GitProviderApiBaseUrls>({ ...EMPTY_API_BASE_URLS });
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const hasEditedRef = React.useRef(false);
|
||||
const committedSnapshotRef = React.useRef('');
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
hasEditedRef.current = false;
|
||||
setIsLoading(true);
|
||||
void (async () => {
|
||||
const { gitProviders } = await getProjectGitProviders(projectRef.id);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const loaded = readProjectApiBaseUrls(gitProviders);
|
||||
// Never clobber an edit the user started before the read resolved.
|
||||
if (!hasEditedRef.current) {
|
||||
setDrafts(loaded);
|
||||
committedSnapshotRef.current = JSON.stringify(buildGitProvidersPayload(loaded));
|
||||
}
|
||||
setIsLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectRef.id]);
|
||||
|
||||
const commit = React.useCallback(() => {
|
||||
const payload = buildGitProvidersPayload(drafts);
|
||||
const snapshot = JSON.stringify(payload);
|
||||
if (snapshot === committedSnapshotRef.current) {
|
||||
// Blur with no real change: drop incidental whitespace from the drafts.
|
||||
setDrafts({
|
||||
github: drafts.github.trim(),
|
||||
gitlab: drafts.gitlab.trim(),
|
||||
gitea: drafts.gitea.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const previousSnapshot = committedSnapshotRef.current;
|
||||
reportSettingsSaveState('saving');
|
||||
void saveProjectGitProviders(projectRef.id, payload).then((ok) => {
|
||||
if (ok) {
|
||||
committedSnapshotRef.current = snapshot;
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer(projectRef.id, payload);
|
||||
reportSettingsSaveState('saved');
|
||||
} else {
|
||||
// Keep the pre-failure snapshot so an unchanged blur can retry.
|
||||
committedSnapshotRef.current = previousSnapshot;
|
||||
reportSettingsSaveState('error');
|
||||
}
|
||||
});
|
||||
}, [drafts, projectRef.id]);
|
||||
|
||||
return (
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.projects.page.gitProviders.title')}
|
||||
info={t('settings.projects.page.gitProviders.description')}
|
||||
settingsItem="projects.git-providers"
|
||||
>
|
||||
{GIT_PROVIDERS.map((provider) => {
|
||||
const isEmpty = drafts[provider].trim().length === 0;
|
||||
// Show what the project inherits when no override is set: the global
|
||||
// setting when present, otherwise the provider's default placeholder.
|
||||
const inheritedUrl =
|
||||
globalApiBaseUrls[provider] || t(`settings.${provider}.page.apiBaseUrl.placeholder`);
|
||||
return (
|
||||
<SettingsStackedField
|
||||
key={provider}
|
||||
label={t(`settings.${provider}.page.apiBaseUrl.label`)}
|
||||
settingsItem={`projects.git-providers.${provider}`}
|
||||
descriptionPlacement="after"
|
||||
description={
|
||||
isEmpty && !isLoading
|
||||
? t('settings.projects.page.gitProviders.inheritsGlobal', { url: inheritedUrl })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={drafts[provider]}
|
||||
onChange={(event) => {
|
||||
hasEditedRef.current = true;
|
||||
setDrafts((prev) => ({ ...prev, [provider]: event.target.value }));
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
placeholder={t(`settings.${provider}.page.apiBaseUrl.placeholder`)}
|
||||
aria-label={t(`settings.${provider}.page.apiBaseUrl.label`)}
|
||||
className="h-9"
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
);
|
||||
})}
|
||||
</ProjectSettingsSubsection>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
||||
import { ProjectGitProvidersSection } from '@/components/sections/projects/ProjectGitProvidersSection';
|
||||
import { ProjectIdentityFields } from '@/components/sections/projects/ProjectIdentityFields';
|
||||
import {
|
||||
useProjectIdentityForm,
|
||||
@@ -47,6 +48,7 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<ProjectIdentityFields form={form} />
|
||||
<ProjectGitProvidersSection projectRef={projectRef} />
|
||||
<ProjectActionsSection projectRef={projectRef} />
|
||||
{showWorktrees ? <WorktreeSectionContent projectRef={projectRef} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildGitProviderHosts, detectGitProvider, type GitProviderHosts } from './gitProvider';
|
||||
import {
|
||||
mergeGitProviderApiBaseUrls,
|
||||
resolveProjectApiBaseUrls,
|
||||
resolveProjectIdForDirectory,
|
||||
} from './projectGitProviders';
|
||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||
|
||||
const EMPTY_HOSTS: GitProviderHosts = { github: [], gitlab: [], gitea: [] };
|
||||
|
||||
@@ -206,3 +212,123 @@ describe('buildGitProviderHosts', () => {
|
||||
expect(hosts.github).toEqual(['github.example.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProjectIdForDirectory', () => {
|
||||
const projects = [
|
||||
{ id: 'parent', path: '/workspace' },
|
||||
{ id: 'child', path: '/workspace/app' },
|
||||
];
|
||||
|
||||
test('matches the longest containing project path', () => {
|
||||
expect(resolveProjectIdForDirectory('/workspace/app/sub', projects)).toBe('child');
|
||||
expect(resolveProjectIdForDirectory('/workspace/app', projects)).toBe('child');
|
||||
expect(resolveProjectIdForDirectory('/workspace', projects)).toBe('parent');
|
||||
expect(resolveProjectIdForDirectory('/workspace/other', projects)).toBe('parent');
|
||||
});
|
||||
|
||||
test('matches a root project for any directory', () => {
|
||||
expect(resolveProjectIdForDirectory('/workspace/app', [{ id: 'root', path: '/' }])).toBe('root');
|
||||
});
|
||||
|
||||
test('normalizes windows drive casing before matching', () => {
|
||||
expect(resolveProjectIdForDirectory('C:\\repo\\sub', [{ id: 'proj', path: 'c:\\repo' }])).toBe('proj');
|
||||
});
|
||||
|
||||
test('falls back to a path-derived project id when nothing matches', () => {
|
||||
const directory = '/some/unregistered/repo';
|
||||
expect(resolveProjectIdForDirectory(directory, [])).toBe(createProjectIdFromPath(directory));
|
||||
expect(resolveProjectIdForDirectory(directory, projects)).toBe(createProjectIdFromPath(directory));
|
||||
});
|
||||
|
||||
test('an external worktree resolves to its owning project via the worktree map', () => {
|
||||
const worktreesByProject = new Map([
|
||||
['/workspace', [{ path: '/other/repo-worktrees/slug' }]],
|
||||
]);
|
||||
// Worktree lives outside the project path and nothing contains it, but the
|
||||
// map still ties it to the owning project.
|
||||
expect(resolveProjectIdForDirectory('/other/repo-worktrees/slug', projects, worktreesByProject)).toBe('parent');
|
||||
});
|
||||
|
||||
test('the worktree map lookup wins over containment when both would match', () => {
|
||||
const worktreesByProject = new Map([
|
||||
// Directory is inside the `child` project but declared as a worktree of
|
||||
// the shallower `parent` project — the map is authoritative.
|
||||
['/workspace', [{ path: '/workspace/app/sub' }]],
|
||||
]);
|
||||
expect(resolveProjectIdForDirectory('/workspace/app/sub', projects, worktreesByProject)).toBe('parent');
|
||||
});
|
||||
|
||||
test('a worktree whose owning project is not registered falls through to path fallback', () => {
|
||||
const worktreesByProject = new Map([
|
||||
['/unknown/owner', [{ path: '/other/worktrees/slug' }]],
|
||||
]);
|
||||
expect(resolveProjectIdForDirectory('/other/worktrees/slug', projects, worktreesByProject))
|
||||
.toBe(createProjectIdFromPath('/other/worktrees/slug'));
|
||||
});
|
||||
|
||||
test('resolution is unchanged when worktreesByProject is omitted', () => {
|
||||
expect(resolveProjectIdForDirectory('/workspace/app/sub', projects)).toBe('child');
|
||||
expect(resolveProjectIdForDirectory('/some/unregistered/repo', projects)).toBe(createProjectIdFromPath('/some/unregistered/repo'));
|
||||
expect(resolveProjectIdForDirectory(null, projects)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for empty or sibling-only directories without a matching project', () => {
|
||||
expect(resolveProjectIdForDirectory('', projects)).toBeNull();
|
||||
expect(resolveProjectIdForDirectory(null, projects)).toBeNull();
|
||||
expect(resolveProjectIdForDirectory(' ', projects)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('project override detection overlay', () => {
|
||||
const projects = [{ id: 'proj', path: '/repo' }];
|
||||
const overrides = { proj: { github: '', gitlab: 'https://git.self.example.com', gitea: '' } };
|
||||
|
||||
test('resolveProjectApiBaseUrls returns the owning project override', () => {
|
||||
expect(resolveProjectApiBaseUrls('/repo/sub', projects, overrides)).toEqual(overrides.proj);
|
||||
expect(resolveProjectApiBaseUrls('/repo', projects, overrides)).toEqual(overrides.proj);
|
||||
});
|
||||
|
||||
test('resolveProjectApiBaseUrls returns undefined without a resolvable project', () => {
|
||||
expect(resolveProjectApiBaseUrls('/unknown', [], overrides)).toBe(undefined);
|
||||
expect(resolveProjectApiBaseUrls('/unknown', projects, {})).toBe(undefined);
|
||||
expect(resolveProjectApiBaseUrls(null, projects, overrides)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('resolveProjectApiBaseUrls forwards the worktree map to project resolution', () => {
|
||||
const worktreesByProject = new Map([
|
||||
['/repo', [{ path: '/elsewhere/worktrees/pr-42' }]],
|
||||
]);
|
||||
expect(resolveProjectApiBaseUrls('/elsewhere/worktrees/pr-42', projects, overrides, worktreesByProject)).toEqual(overrides.proj);
|
||||
});
|
||||
|
||||
test('mergeGitProviderApiBaseUrls: project values win, empty slots fall back to global', () => {
|
||||
const global = { github: 'https://github.example.com/api/v3', gitlab: '', gitea: 'https://gitea.example.com' };
|
||||
const override = { github: '', gitlab: 'https://git.self.example.com', gitea: '' };
|
||||
expect(mergeGitProviderApiBaseUrls(override, global)).toEqual({
|
||||
github: 'https://github.example.com/api/v3',
|
||||
gitlab: 'https://git.self.example.com',
|
||||
gitea: 'https://gitea.example.com',
|
||||
});
|
||||
expect(mergeGitProviderApiBaseUrls(undefined, global)).toEqual(global);
|
||||
});
|
||||
|
||||
test('a project override host that is not global classifies the remote as the provider', () => {
|
||||
const override = resolveProjectApiBaseUrls('/repo/sub', projects, overrides);
|
||||
const effective = mergeGitProviderApiBaseUrls(override, emptyInput.apiBaseUrls);
|
||||
const hosts = buildGitProviderHosts({ ...emptyInput, apiBaseUrls: effective });
|
||||
// The override host is added to detection even though nothing global or
|
||||
// account-derived knows it.
|
||||
expect(hosts.gitlab).toEqual(['git.self.example.com']);
|
||||
expect(hosts.github).toEqual([]);
|
||||
expect(detectGitProvider(['git@git.self.example.com:group/repo.git'], hosts)).toBe('gitlab');
|
||||
expect(detectGitProvider(['https://git.self.example.com/group/repo.git'], hosts)).toBe('gitlab');
|
||||
});
|
||||
|
||||
test('global api base urls keep working when the project has no override', () => {
|
||||
const globalApi = { github: '', gitlab: 'https://gitlab.example.com', gitea: '' };
|
||||
const override = resolveProjectApiBaseUrls('/repo/sub', projects, {});
|
||||
const effective = mergeGitProviderApiBaseUrls(override, globalApi);
|
||||
const hosts = buildGitProviderHosts({ ...emptyInput, apiBaseUrls: effective });
|
||||
expect(detectGitProvider(['git@gitlab.example.com:group/repo.git'], hosts)).toBe('gitlab');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import { parseGitHost } from '@/lib/gitHost';
|
||||
import { getRemotes } from '@/lib/gitApi';
|
||||
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
||||
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import {
|
||||
mergeGitProviderApiBaseUrls,
|
||||
resolveProjectApiBaseUrls,
|
||||
} from '@/lib/projectGitProviders';
|
||||
import {
|
||||
useGitProviderDomainsStore,
|
||||
normalizeProviderDomain,
|
||||
@@ -134,8 +140,20 @@ export const detectGitProvider = (fetchUrls: string[], hosts: GitProviderHosts):
|
||||
const RESOLVE_CACHE_TTL_MS = 60_000;
|
||||
const resolveCache = new Map<string, { at: number; provider: GitProvider | null }>();
|
||||
|
||||
/**
|
||||
* Per-directory detection is memoized for RESOLVE_CACHE_TTL_MS. The cache key
|
||||
* includes the effective detection host sets, so a project override (or any
|
||||
* api base/domain/account change) that alters the hosts invalidates the cached
|
||||
* classification immediately instead of serving a stale provider for up to a
|
||||
* minute. The serialized key is stable across renders because
|
||||
* `buildGitProviderHosts` emits deterministic, deduped host lists.
|
||||
*/
|
||||
const resolveCacheKey = (directory: string, hosts: GitProviderHosts): string =>
|
||||
`${directory}|${JSON.stringify(hosts)}`;
|
||||
|
||||
export const resolveGitProvider = async (directory: string, hosts: GitProviderHosts): Promise<GitProvider | null> => {
|
||||
const cached = resolveCache.get(directory);
|
||||
const cacheKey = resolveCacheKey(directory, hosts);
|
||||
const cached = resolveCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < RESOLVE_CACHE_TTL_MS) {
|
||||
return cached.provider;
|
||||
}
|
||||
@@ -146,7 +164,7 @@ export const resolveGitProvider = async (directory: string, hosts: GitProviderHo
|
||||
} catch {
|
||||
provider = null;
|
||||
}
|
||||
resolveCache.set(directory, { at: Date.now(), provider });
|
||||
resolveCache.set(cacheKey, { at: Date.now(), provider });
|
||||
return provider;
|
||||
};
|
||||
|
||||
@@ -162,9 +180,19 @@ export const useGitProvider = (directory: string | null | undefined): GitProvide
|
||||
const giteaAccounts = useGiteaAuthStore((state) => state.status?.accounts);
|
||||
const domains = useGitProviderDomainsStore((state) => state.domains);
|
||||
const apiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls);
|
||||
const projectApiBaseUrls = useGitProviderDomainsStore((state) => state.projectApiBaseUrls);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const hosts = useMemo<GitProviderHosts>(
|
||||
() => buildGitProviderHosts({ domains, apiBaseUrls, gitlabAccounts, giteaAccounts }),
|
||||
[domains, apiBaseUrls, gitlabAccounts, giteaAccounts],
|
||||
() => {
|
||||
// Precedence per provider: project override > global server settings.
|
||||
// The merged api base urls flow into buildGitProviderHosts, whose
|
||||
// apiBaseHost handling then auto-adds the override host to detection.
|
||||
const projectOverride = resolveProjectApiBaseUrls(directory, projects, projectApiBaseUrls, worktreesByProject);
|
||||
const effectiveApiBaseUrls = mergeGitProviderApiBaseUrls(projectOverride, apiBaseUrls);
|
||||
return buildGitProviderHosts({ domains, apiBaseUrls: effectiveApiBaseUrls, gitlabAccounts, giteaAccounts });
|
||||
},
|
||||
[directory, projects, projectApiBaseUrls, worktreesByProject, domains, apiBaseUrls, gitlabAccounts, giteaAccounts],
|
||||
);
|
||||
const [provider, setProvider] = useState<GitProvider | null>(null);
|
||||
|
||||
|
||||
@@ -1124,6 +1124,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': 'Benutzerdefiniertes Symbol bereits für dieses Projekt festgelegt',
|
||||
'settings.projects.page.toast.iconDiscovered': 'Projekt-Symbol gefunden',
|
||||
'settings.projects.page.toast.saveFailed': 'Fehler beim Speichern der Projekteinstellungen',
|
||||
'settings.projects.page.gitProviders.title': 'API-Basis-URLs der Git-Anbieter',
|
||||
'settings.projects.page.gitProviders.description': 'Überschreibt die globale API-Basis-URL für dieses Projekt. Ist kein Wert gesetzt, wird die globale Einstellung verwendet.',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': 'Erbt: {url}',
|
||||
'settings.usage.sidebar.title': 'Nutzung',
|
||||
'settings.usage.sidebar.total': 'Gesamt {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Automatisches Aktualisieren umschalten',
|
||||
|
||||
@@ -1186,6 +1186,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': 'Custom icon already set for this project',
|
||||
'settings.projects.page.toast.iconDiscovered': 'Project icon discovered',
|
||||
'settings.projects.page.toast.saveFailed': 'Failed to save project settings',
|
||||
'settings.projects.page.gitProviders.title': 'Git Provider API Base URLs',
|
||||
'settings.projects.page.gitProviders.description': 'Override the global API base URL for this project. When unset, the global setting is used.',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': 'Inherits: {url}',
|
||||
'settings.usage.sidebar.title': 'Usage',
|
||||
'settings.usage.sidebar.total': 'Total {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Toggle auto refresh',
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
"settings.projects.page.toast.customIconAlreadySet": "Icono personalizado ya establecido para este proyecto",
|
||||
"settings.projects.page.toast.iconDiscovered": "Icono del proyecto descubierto",
|
||||
"settings.projects.page.toast.saveFailed": "Error al guardar la configuración del proyecto",
|
||||
"settings.projects.page.gitProviders.title": "URLs base de la API de los proveedores de Git",
|
||||
"settings.projects.page.gitProviders.description": "Anula la URL base de la API global para este proyecto. Si no se define, se usa la configuración global.",
|
||||
"settings.projects.page.gitProviders.inheritsGlobal": "Hereda: {url}",
|
||||
"settings.usage.sidebar.title": "Uso",
|
||||
"settings.usage.sidebar.total": "Total {count}",
|
||||
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Alternar refresco automático",
|
||||
|
||||
@@ -1072,6 +1072,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': 'Icône personnalisée déjà définie pour ce projet',
|
||||
'settings.projects.page.toast.iconDiscovered': 'Icône de projet découverte',
|
||||
'settings.projects.page.toast.saveFailed': 'Échec de l\'enregistrement des paramètres du projet',
|
||||
'settings.projects.page.gitProviders.title': 'URL de base de l\'API des fournisseurs Git',
|
||||
'settings.projects.page.gitProviders.description': 'Remplace l\'URL de base de l\'API globale pour ce projet. Si non défini, la valeur globale est utilisée.',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': 'Hérite de : {url}',
|
||||
'settings.usage.sidebar.title': 'Usage',
|
||||
'settings.usage.sidebar.total': 'Total {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Activer l\'actualisation automatique',
|
||||
|
||||
@@ -1187,6 +1187,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': 'このプロジェクトには既にカスタムアイコンが設定されています',
|
||||
'settings.projects.page.toast.iconDiscovered': 'プロジェクトアイコンを検出しました',
|
||||
'settings.projects.page.toast.saveFailed': 'プロジェクト設定の保存に失敗しました',
|
||||
'settings.projects.page.gitProviders.title': 'Git プロバイダーの API ベース URL',
|
||||
'settings.projects.page.gitProviders.description': 'このプロジェクトの API ベース URL をグローバル設定で上書きします。未設定の場合はグローバル設定が使用されます。',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': '継承: {url}',
|
||||
'settings.usage.sidebar.title': '使用量',
|
||||
'settings.usage.sidebar.total': '合計 {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '自動更新の切替',
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': '이 프로젝트에는 이미 사용자 정의 아이콘이 설정되어 있습니다',
|
||||
'settings.projects.page.toast.iconDiscovered': '프로젝트 아이콘을 찾았습니다',
|
||||
'settings.projects.page.toast.saveFailed': '프로젝트 설정 저장에 실패했습니다',
|
||||
'settings.projects.page.gitProviders.title': 'Git 공급자 API 기본 URL',
|
||||
'settings.projects.page.gitProviders.description': '이 프로젝트의 API 기본 URL을 전역 설정으로 재정의합니다. 설정하지 않으면 전역 설정이 사용됩니다.',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': '상속: {url}',
|
||||
'settings.usage.sidebar.title': '사용량',
|
||||
'settings.usage.sidebar.total': '총 {count}개',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '자동 새로고침 토글',
|
||||
|
||||
@@ -1448,6 +1448,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.discoverIconFailed': 'Nie udało się wykryć ikony projektu',
|
||||
'settings.projects.page.toast.iconDiscovered': 'Wykryto ikonę projektu',
|
||||
'settings.projects.page.toast.saveFailed': 'Nie udało się zapisać ustawień projektu',
|
||||
'settings.projects.page.gitProviders.title': 'Adresy URL API dostawców Git',
|
||||
'settings.projects.page.gitProviders.description': 'Zastępuje globalny adres URL API dla tego projektu. Jeśli nie ustawiono, używany jest globalny adres URL.',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': 'Dziedziczy: {url}',
|
||||
'settings.projects.page.toast.iconRemoved': 'Ikona projektu została usunięta',
|
||||
'settings.projects.page.toast.iconUpdated': 'Ikona projektu została zaktualizowana',
|
||||
'settings.projects.page.toast.removeIconFailed': 'Nie udało się usunąć ikony projektu',
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
"settings.projects.page.toast.customIconAlreadySet": "Ícone personalizado já definido para este projeto",
|
||||
"settings.projects.page.toast.iconDiscovered": "Ícone do projeto descoberto",
|
||||
"settings.projects.page.toast.saveFailed": "Falha ao salvar as configurações do projeto",
|
||||
"settings.projects.page.gitProviders.title": "URLs base da API dos provedores de Git",
|
||||
"settings.projects.page.gitProviders.description": "Substitui a URL base da API global para este projeto. Quando não definido, usa a configuração global.",
|
||||
"settings.projects.page.gitProviders.inheritsGlobal": "Herda: {url}",
|
||||
"settings.usage.sidebar.title": "Uso",
|
||||
"settings.usage.sidebar.total": "Total {count}",
|
||||
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Alternar atualização automática",
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
"settings.projects.page.toast.customIconAlreadySet": "Спеціальна піктограма вже встановлена для цього проєкту",
|
||||
"settings.projects.page.toast.iconDiscovered": "Виявлено значок проєкту",
|
||||
"settings.projects.page.toast.saveFailed": "Не вдалося зберегти налаштування проєкту",
|
||||
"settings.projects.page.gitProviders.title": "Базові URL-адреси API постачальників Git",
|
||||
"settings.projects.page.gitProviders.description": "Перевизначає глобальну базову URL-адресу API для цього проєкту. Якщо не задано, використовується глобальне значення.",
|
||||
"settings.projects.page.gitProviders.inheritsGlobal": "Успадковує: {url}",
|
||||
"settings.usage.sidebar.title": "Використання",
|
||||
"settings.usage.sidebar.total": "Усього {count}",
|
||||
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Увімкнути автоматичне оновлення",
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': '该项目已设置自定义图标',
|
||||
'settings.projects.page.toast.iconDiscovered': '项目图标已发现',
|
||||
'settings.projects.page.toast.saveFailed': '保存项目设置失败',
|
||||
'settings.projects.page.gitProviders.title': 'Git 提供商的 API 基础 URL',
|
||||
'settings.projects.page.gitProviders.description': '为此项目覆盖全局 API 基础 URL。未设置时使用全局配置。',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': '继承:{url}',
|
||||
'settings.usage.sidebar.title': '用量',
|
||||
'settings.usage.sidebar.total': '总计 {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '切换自动刷新',
|
||||
|
||||
@@ -1061,6 +1061,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': '該專案已設定自訂圖示',
|
||||
'settings.projects.page.toast.iconDiscovered': '專案圖示已發現',
|
||||
'settings.projects.page.toast.saveFailed': '儲存專案設定失敗',
|
||||
'settings.projects.page.gitProviders.title': 'Git 提供者的 API 基礎 URL',
|
||||
'settings.projects.page.gitProviders.description': '為此專案覆寫全域 API 基礎 URL。未設定時使用全域設定。',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': '繼承:{url}',
|
||||
'settings.usage.sidebar.title': '用量',
|
||||
'settings.usage.sidebar.total': '總計 {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '切換自動重新整理',
|
||||
|
||||
@@ -40,6 +40,8 @@ interface OpenChamberConfig {
|
||||
projectActions?: OpenChamberProjectAction[];
|
||||
projectActionsPrimaryId?: string;
|
||||
draftStarters?: DraftStarterRef[];
|
||||
/** Written by the server via the git-providers route; the client must preserve it. */
|
||||
gitProviders?: unknown;
|
||||
}
|
||||
|
||||
type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows';
|
||||
@@ -620,9 +622,10 @@ async function readOpenChamberConfig(project: ProjectRef): Promise<OpenChamberCo
|
||||
/**
|
||||
* Write the per-user config for a project.
|
||||
*
|
||||
* Server owns `version` and `scheduledTasks` keys; client reads them via their
|
||||
* dedicated route and never round-trips them through this config write path to
|
||||
* avoid a read-then-write race clobbering a concurrent server update.
|
||||
* Server owns `version`, `scheduledTasks`, and `gitProviders` keys; client
|
||||
* reads them via their dedicated routes and never round-trips them through
|
||||
* this config write path to avoid a read-then-write race clobbering a
|
||||
* concurrent server update.
|
||||
*/
|
||||
async function writeOpenChamberConfig(
|
||||
project: ProjectRef,
|
||||
@@ -661,6 +664,7 @@ async function writeOpenChamberConfig(
|
||||
const serverOwned: Record<string, unknown> = {};
|
||||
if (existing.version !== undefined) serverOwned.version = existing.version;
|
||||
if (existing.scheduledTasks !== undefined) serverOwned.scheduledTasks = existing.scheduledTasks;
|
||||
if (existing.gitProviders !== undefined) serverOwned.gitProviders = existing.gitProviders;
|
||||
|
||||
const content = JSON.stringify({
|
||||
...existing,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
normalizeApiBaseUrl,
|
||||
normalizeDomainList,
|
||||
} from '@/stores/useGitProviderDomainsStore';
|
||||
import { getProjectGitProviders, resolveProjectIdForDirectory } from '@/lib/projectGitProviders';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import {
|
||||
DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
isFollowUpBehavior,
|
||||
@@ -1847,6 +1851,30 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
} catch (error) {
|
||||
console.warn('applyGitProviderSettings failed:', error);
|
||||
}
|
||||
try {
|
||||
// Per-project git provider api base url overrides are also
|
||||
// server-authoritative. Resolve the active project with the same
|
||||
// directory resolution used by git provider detection (so hydration and
|
||||
// detection key by the same project id) and fetch its override
|
||||
// best-effort; failures are ignored and prior state is preserved.
|
||||
const projectsState = useProjectsStore.getState();
|
||||
const activeProjectId = projectsState.getActiveProject()?.id ?? null;
|
||||
const projectId = activeProjectId
|
||||
?? resolveProjectIdForDirectory(
|
||||
useDirectoryStore.getState().currentDirectory,
|
||||
projectsState.projects,
|
||||
useSessionUIStore.getState().availableWorktreesByProject,
|
||||
);
|
||||
if (projectId) {
|
||||
void getProjectGitProviders(projectId)
|
||||
.then(({ gitProviders }) => {
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer(projectId, gitProviders);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('applyProjectGitProviderSettings failed:', error);
|
||||
}
|
||||
const migrationPatch: Partial<DesktopSettings> = {};
|
||||
if (shouldPersistCraftGoalMigration) {
|
||||
if (authoritativeSettings.draftStarters) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import type { GitProviderApiBaseUrls } from '@/stores/useGitProviderDomainsStore';
|
||||
|
||||
const gitProvidersRoute = (projectId: string): string =>
|
||||
`/api/projects/${encodeURIComponent(projectId)}/git-providers`;
|
||||
|
||||
/**
|
||||
* Read the server-authoritative per-project git provider config for a project.
|
||||
* Fails closed: any non-OK, malformed, or unparseable response collapses to
|
||||
* `{ gitProviders: {} }` and this never throws. Callers treat the empty object
|
||||
* as "no project override", which is the conservative default — an unreachable
|
||||
* server must never widen provider detection.
|
||||
*/
|
||||
export const getProjectGitProviders = async (projectId: string): Promise<{ gitProviders: unknown }> => {
|
||||
if (!projectId) {
|
||||
return { gitProviders: {} };
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch(gitProvidersRoute(projectId), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { gitProviders: {} };
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as { gitProviders?: unknown } | null;
|
||||
if (!payload || typeof payload !== 'object' || !('gitProviders' in payload)) {
|
||||
return { gitProviders: {} };
|
||||
}
|
||||
return { gitProviders: payload.gitProviders };
|
||||
} catch {
|
||||
return { gitProviders: {} };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the full per-project git provider config. The server replaces the
|
||||
* whole `gitProviders` key on PUT, so every provider must be sent together.
|
||||
* Returns true only on an OK response; never throws.
|
||||
*/
|
||||
export const saveProjectGitProviders = async (projectId: string, gitProviders: unknown): Promise<boolean> => {
|
||||
if (!projectId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch(gitProvidersRoute(projectId), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ gitProviders }),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type ResolvableProject = { id: string; path: string };
|
||||
|
||||
/** Same shape as `useSessionUIStore`'s `availableWorktreesByProject`. */
|
||||
export type WorktreesByProject = ReadonlyMap<string, ReadonlyArray<{ path?: string }>>;
|
||||
|
||||
const isNormalizedPathWithinProject = (directory: string, projectPath: string): boolean => {
|
||||
if (directory === projectPath) return true;
|
||||
if (projectPath === '/') return directory.startsWith('/');
|
||||
return directory.startsWith(`${projectPath}/`);
|
||||
};
|
||||
|
||||
/**
|
||||
* A directory that is a known worktree resolves to its owning project, no
|
||||
* matter where the worktree lives (git worktrees can be created outside the
|
||||
* project root). Mirrors the sidebar's `worktreeInfoByPath` lookup: exact
|
||||
* normalized path match, then exact normalized owning-project match.
|
||||
*/
|
||||
const resolveWorktreeOwner = (
|
||||
normalizedDirectory: string,
|
||||
worktreesByProject: WorktreesByProject,
|
||||
projects: ResolvableProject[],
|
||||
): ResolvableProject | null => {
|
||||
for (const [projectPath, worktrees] of worktreesByProject) {
|
||||
const normalizedProjectPath = normalizePath(projectPath);
|
||||
if (!normalizedProjectPath) continue;
|
||||
for (const worktree of worktrees) {
|
||||
if (normalizePath(worktree.path) !== normalizedDirectory) continue;
|
||||
const exact = projects.find((project) => normalizePath(project.path) === normalizedProjectPath);
|
||||
if (exact) return exact;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve which project owns a directory, mirroring the sidebar's
|
||||
* `findProjectForDirectory` precedence: a known worktree directory resolves to
|
||||
* its owning project first (worktrees may live outside the project root),
|
||||
* then the longest normalized project path that contains the directory wins.
|
||||
* When no registered project matches, fall back to the deterministic
|
||||
* path-derived project id (`createProjectIdFromPath`) so overrides still key
|
||||
* correctly for unregistered/not-yet-indexed directories. Returns null for an
|
||||
* empty directory.
|
||||
*/
|
||||
export const resolveProjectIdForDirectory = (
|
||||
directory: string | null | undefined,
|
||||
projects: ResolvableProject[],
|
||||
worktreesByProject?: WorktreesByProject,
|
||||
): string | null => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!normalizedDirectory) return null;
|
||||
if (worktreesByProject) {
|
||||
const worktreeOwner = resolveWorktreeOwner(normalizedDirectory, worktreesByProject, projects);
|
||||
if (worktreeOwner) return worktreeOwner.id;
|
||||
}
|
||||
let best: { id: string; normalizedPath: string } | null = null;
|
||||
for (const project of projects) {
|
||||
const normalizedPath = normalizePath(project.path);
|
||||
if (!normalizedPath) continue;
|
||||
if (!isNormalizedPathWithinProject(normalizedDirectory, normalizedPath)) continue;
|
||||
if (!best || normalizedPath.length > best.normalizedPath.length) {
|
||||
best = { id: project.id, normalizedPath };
|
||||
}
|
||||
}
|
||||
if (best) return best.id;
|
||||
const fallbackId = createProjectIdFromPath(directory ?? '');
|
||||
return fallbackId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-project api base url override for the directory's owning project, or
|
||||
* undefined when the directory has no resolvable project id.
|
||||
*/
|
||||
export const resolveProjectApiBaseUrls = (
|
||||
directory: string | null | undefined,
|
||||
projects: ResolvableProject[],
|
||||
projectApiBaseUrls: Record<string, GitProviderApiBaseUrls>,
|
||||
worktreesByProject?: WorktreesByProject,
|
||||
): GitProviderApiBaseUrls | undefined => {
|
||||
const projectId = resolveProjectIdForDirectory(directory, projects, worktreesByProject);
|
||||
return projectId ? projectApiBaseUrls[projectId] : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge a project-scoped override over the global settings api base urls.
|
||||
* Non-empty project values win; empty override slots fall back to the global
|
||||
* settings value (precedence: project override > global server settings).
|
||||
*/
|
||||
export const mergeGitProviderApiBaseUrls = (
|
||||
projectOverride: GitProviderApiBaseUrls | undefined,
|
||||
globalApiBaseUrls: GitProviderApiBaseUrls,
|
||||
): GitProviderApiBaseUrls => ({
|
||||
github: projectOverride?.github || globalApiBaseUrls.github,
|
||||
gitlab: projectOverride?.gitlab || globalApiBaseUrls.gitlab,
|
||||
gitea: projectOverride?.gitea || globalApiBaseUrls.gitea,
|
||||
});
|
||||
@@ -611,6 +611,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
titleKey: 'settings.openchamber.worktrees.setup.waitForCommands',
|
||||
keywords: ['worktree', 'setup commands', 'bootstrap', 'wait'],
|
||||
},
|
||||
{
|
||||
id: 'projects.git-providers',
|
||||
page: 'projects',
|
||||
titleKey: 'settings.projects.page.gitProviders.title',
|
||||
descriptionKey: 'settings.projects.page.gitProviders.description',
|
||||
keywords: ['github', 'gitlab', 'gitea', 'api base url', 'self-hosted', 'override', 'enterprise', 'server'],
|
||||
},
|
||||
{
|
||||
id: 'remote-instances.client-auth',
|
||||
page: 'remote-instances',
|
||||
|
||||
@@ -5,6 +5,7 @@ const resetDomains = () => {
|
||||
useGitProviderDomainsStore.setState({
|
||||
domains: { github: [], gitlab: [], gitea: [] },
|
||||
apiBaseUrls: { github: '', gitlab: '', gitea: '' },
|
||||
projectApiBaseUrls: {},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -135,3 +136,85 @@ describe('useGitProviderDomainsStore', () => {
|
||||
expect(domains.gitea).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGitProviderDomainsStore per-project overrides', () => {
|
||||
test('hydrateProjectFromServer stores a normalized per-project override', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {
|
||||
github: { apiBaseUrl: 'https://git.self.example.com/api/v3/', detectUrls: ['git.self.example.com'] },
|
||||
});
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({
|
||||
path_proj: { github: 'https://git.self.example.com/api/v3', gitlab: '', gitea: '' },
|
||||
});
|
||||
});
|
||||
|
||||
test('hydrateProjectFromServer drops the key when all api base urls are empty', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {
|
||||
github: { apiBaseUrl: 'https://git.self.example.com/api/v3' },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {
|
||||
github: { apiBaseUrl: '' },
|
||||
});
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({});
|
||||
});
|
||||
|
||||
test('hydrateProjectFromServer with malformed config stores nothing', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', { github: 'not an object' });
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', undefined);
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({});
|
||||
});
|
||||
|
||||
test('hydrateProjectFromServer keeps unrelated projects and global slices untouched', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().setDomains('github', ['github.example.com']);
|
||||
useGitProviderDomainsStore.getState().setApiBaseUrl('github', 'https://github.example.com/api/v3');
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_a', {
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_b', {
|
||||
gitea: { apiBaseUrl: 'https://gitea.example.com' },
|
||||
});
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({
|
||||
path_a: { github: '', gitlab: 'https://gitlab.example.com', gitea: '' },
|
||||
path_b: { github: '', gitlab: '', gitea: 'https://gitea.example.com' },
|
||||
});
|
||||
// Global slices are untouched by per-project hydration.
|
||||
const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState();
|
||||
expect(domains.github).toEqual(['github.example.com']);
|
||||
expect(apiBaseUrls.github).toBe('https://github.example.com/api/v3');
|
||||
});
|
||||
|
||||
test('clearProjectGitProviders removes only the given project key', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_a', {
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_b', {
|
||||
gitea: { apiBaseUrl: 'https://gitea.example.com' },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().clearProjectGitProviders('path_a');
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({
|
||||
path_b: { github: '', gitlab: '', gitea: 'https://gitea.example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
test('hydrateProjectFromServer does not disturb global hydrateFromServer behavior', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateFromServer({
|
||||
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_a', {
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
});
|
||||
const state = useGitProviderDomainsStore.getState();
|
||||
expect(state.apiBaseUrls.github).toBe('https://github.example.com/api/v3');
|
||||
expect(state.domains.github).toEqual(['github.example.com']);
|
||||
expect(state.projectApiBaseUrls.path_a).toEqual({
|
||||
github: '',
|
||||
gitlab: 'https://gitlab.example.com',
|
||||
gitea: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,10 +103,20 @@ const normalizeGitProvidersConfig = (config: unknown): {
|
||||
type GitProviderDomainsStore = {
|
||||
domains: GitProviderDomains;
|
||||
apiBaseUrls: GitProviderApiBaseUrls;
|
||||
/**
|
||||
* Per-project api base url overrides, keyed by project id. Server-authoritative
|
||||
* and intentionally NOT persisted (the server owns the config file); hydrated
|
||||
* on demand and cleared when the override is removed server-side.
|
||||
*/
|
||||
projectApiBaseUrls: Record<string, GitProviderApiBaseUrls>;
|
||||
setDomains: (provider: GitProviderName, domains: string[]) => void;
|
||||
setApiBaseUrl: (provider: GitProviderName, url: string) => void;
|
||||
/** Apply the server's `gitProviders` settings, keeping the server authoritative. */
|
||||
hydrateFromServer: (gitProvidersConfig?: unknown) => void;
|
||||
/** Store one project's server-authoritative git provider override; drops the key when empty. */
|
||||
hydrateProjectFromServer: (projectId: string, config?: unknown) => void;
|
||||
/** Remove a project's override entry. */
|
||||
clearProjectGitProviders: (projectId: string) => void;
|
||||
};
|
||||
|
||||
export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
|
||||
@@ -114,6 +124,7 @@ export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
|
||||
(set, get) => ({
|
||||
domains: EMPTY_DOMAINS,
|
||||
apiBaseUrls: EMPTY_API_BASE_URLS,
|
||||
projectApiBaseUrls: {},
|
||||
setDomains: (provider, domains) => {
|
||||
set({
|
||||
domains: {
|
||||
@@ -146,6 +157,36 @@ export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
|
||||
}
|
||||
set({ domains, apiBaseUrls } as Partial<GitProviderDomainsStore>);
|
||||
},
|
||||
hydrateProjectFromServer: (projectId, config) => {
|
||||
if (!projectId) return;
|
||||
const { apiBaseUrls } = normalizeGitProvidersConfig(config);
|
||||
const hasAny = Boolean(apiBaseUrls.github || apiBaseUrls.gitlab || apiBaseUrls.gitea);
|
||||
const current = get().projectApiBaseUrls[projectId];
|
||||
const unchanged = hasAny
|
||||
? current !== undefined
|
||||
&& current.github === apiBaseUrls.github
|
||||
&& current.gitlab === apiBaseUrls.gitlab
|
||||
&& current.gitea === apiBaseUrls.gitea
|
||||
: current === undefined;
|
||||
if (unchanged) return;
|
||||
set((state) => {
|
||||
const next = { ...state.projectApiBaseUrls };
|
||||
if (hasAny) {
|
||||
next[projectId] = apiBaseUrls;
|
||||
} else {
|
||||
delete next[projectId];
|
||||
}
|
||||
return { projectApiBaseUrls: next };
|
||||
});
|
||||
},
|
||||
clearProjectGitProviders: (projectId) => {
|
||||
if (!projectId || get().projectApiBaseUrls[projectId] === undefined) return;
|
||||
set((state) => {
|
||||
const next = { ...state.projectApiBaseUrls };
|
||||
delete next[projectId];
|
||||
return { projectApiBaseUrls: next };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: DOMAINS_STORAGE_KEY,
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { getVSCodeBootstrapConfig, isVSCodeRuntime } from './utils/vscodeRuntime';
|
||||
import { getProjectGitProviders } from '@/lib/projectGitProviders';
|
||||
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
|
||||
|
||||
/** Pick a color key that's least used among existing projects */
|
||||
const pickAutoColor = (projects: ProjectEntry[]): string => {
|
||||
@@ -677,6 +679,14 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
|
||||
opencodeClient.setDirectory(target.path);
|
||||
useDirectoryStore.getState().setDirectory(target.path, { showOverlay: false });
|
||||
|
||||
// Best-effort: hydrate the newly active project's git provider override
|
||||
// so detection picks up its self-hosted api base host right away.
|
||||
void getProjectGitProviders(id)
|
||||
.then(({ gitProviders }) => {
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer(id, gitProviders);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
|
||||
setActiveProjectIdOnly: (id: string) => {
|
||||
|
||||
@@ -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.
|
||||
- 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
### 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()`: client for the current account, or `null`.
|
||||
- `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`)
|
||||
@@ -48,6 +48,7 @@
|
||||
- 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`). 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.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getGiteaAuth } from './auth.js';
|
||||
import { getProviderApiBaseUrl } from '../git-providers/config.js';
|
||||
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
|
||||
|
||||
// Per-request timeout for every Gitea call. Self-hosted instances can hang
|
||||
// under load; bounding each request lets the caller fail fast and serve
|
||||
@@ -306,11 +308,21 @@ export function createGiteaClient({ token, baseUrl }) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Picks the current account (from auth.js) token + base URL, or null. */
|
||||
export function getGiteaClientOrNull() {
|
||||
/** 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;
|
||||
}
|
||||
return createGiteaClient({ token: auth.accessToken, baseUrl: auth.baseUrl });
|
||||
let baseUrl = auth.baseUrl;
|
||||
if (directory) {
|
||||
const effectiveBaseUrl = getEffectiveProviderApiBaseUrl('gitea', directory);
|
||||
// Only a per-project override replaces the account's base URL; without one
|
||||
// the effective value is just the global default, which stored accounts
|
||||
// (an explicit baseUrl is required) already outrank.
|
||||
if (effectiveBaseUrl !== null && effectiveBaseUrl !== getProviderApiBaseUrl('gitea')) {
|
||||
baseUrl = effectiveBaseUrl;
|
||||
}
|
||||
}
|
||||
return createGiteaClient({ token: auth.accessToken, baseUrl });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getRemoteUrl } from '../git/index.js';
|
||||
import { getGiteaAuthAccounts, normalizeBaseUrl } from './auth.js';
|
||||
import { getEffectiveProviderApiBaseUrl } 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
|
||||
@@ -117,8 +118,23 @@ export async function resolveGiteaRepoFromDirectory(directory, remoteName = 'ori
|
||||
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
|
||||
}
|
||||
}
|
||||
return {
|
||||
repo: parseGiteaRemoteUrl(remoteUrl),
|
||||
repo: parseGiteaRemoteUrl(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 === 'gitea' ? 'https://gitea.override.example' : actual.getEffectiveProviderApiBaseUrl(provider, directory);
|
||||
}
|
||||
return actual.getEffectiveProviderApiBaseUrl(provider, directory);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { parseGiteaRemoteUrl, resolveGiteaRepoFromDirectory } = await import('./repo.js');
|
||||
const { getRemoteUrl } = await import('../git/index.js');
|
||||
const { setGiteaAuth, clearGiteaAuth } = await import('./auth.js');
|
||||
@@ -122,4 +137,17 @@ describe('resolveGiteaRepoFromDirectory', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,9 +210,9 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
return giteaLibraries;
|
||||
};
|
||||
|
||||
const getClient = async () => {
|
||||
const getClient = async (directory) => {
|
||||
const { getGiteaClientOrNull } = await getGiteaLibraries();
|
||||
return getGiteaClientOrNull();
|
||||
return getGiteaClientOrNull(directory);
|
||||
};
|
||||
|
||||
// Resolve which Gitea repo a request targets. A directory-local git remote
|
||||
@@ -397,7 +397,7 @@ export function registerGiteaRoutes(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 });
|
||||
}
|
||||
@@ -449,7 +449,7 @@ export function registerGiteaRoutes(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 });
|
||||
}
|
||||
@@ -491,7 +491,7 @@ export function registerGiteaRoutes(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: [] });
|
||||
}
|
||||
@@ -530,7 +530,7 @@ export function registerGiteaRoutes(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 });
|
||||
}
|
||||
@@ -582,7 +582,7 @@ export function registerGiteaRoutes(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 });
|
||||
}
|
||||
@@ -632,7 +632,7 @@ export function registerGiteaRoutes(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 });
|
||||
}
|
||||
@@ -718,7 +718,7 @@ export function registerGiteaRoutes(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, prs: [], page: effectivePage, hasMore: false });
|
||||
}
|
||||
@@ -806,7 +806,7 @@ export function registerGiteaRoutes(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, pr: null, comments: [], files: [] });
|
||||
}
|
||||
@@ -917,7 +917,7 @@ export function registerGiteaRoutes(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: [] });
|
||||
}
|
||||
@@ -973,7 +973,7 @@ export function registerGiteaRoutes(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, reviews: [] });
|
||||
}
|
||||
@@ -1026,7 +1026,7 @@ export function registerGiteaRoutes(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, statuses: [] });
|
||||
}
|
||||
@@ -1096,7 +1096,7 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
? req.body.description
|
||||
: undefined;
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1152,7 +1152,7 @@ export function registerGiteaRoutes(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 });
|
||||
}
|
||||
@@ -1214,7 +1214,7 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
}
|
||||
const method = ['merge', 'squash', 'rebase'].includes(req.body?.method) ? req.body.method : 'merge';
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1270,7 +1270,7 @@ export function registerGiteaRoutes(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 });
|
||||
}
|
||||
@@ -1322,7 +1322,7 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'event must be APPROVED, REQUEST_CHANGES, or COMMENT' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1438,7 +1438,7 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return res.json({ connected: false, labels: [] });
|
||||
}
|
||||
@@ -1488,7 +1488,7 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
if (!directory && !requestedRepo) {
|
||||
return { error: 'directory or owner/repo is required' };
|
||||
}
|
||||
const client = await getClient();
|
||||
const client = await getClient(directory);
|
||||
if (!client) {
|
||||
return { client: null };
|
||||
}
|
||||
|
||||
@@ -124,11 +124,21 @@ describe('Gitea auth routes', () => {
|
||||
expect(response.body).toEqual({ error: 'accessToken is required' });
|
||||
});
|
||||
|
||||
test('auth/connect requires a base URL (no default instance)', async () => {
|
||||
test('auth/connect uses the built-in default base URL when none is provided', async () => {
|
||||
const fetchMock = scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app).post('/api/gitea/auth/connect').send({ accessToken: 'gitea-valid' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'baseUrl is required and must be a valid URL' });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://codeberg.org/api/v1/user');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
user: { username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice', email: 'alice@example.com' },
|
||||
});
|
||||
expect(response.body.accounts).toEqual([
|
||||
{ id: 'codeberg.org:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice' }, baseUrl: 'https://codeberg.org', current: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('auth/connect normalizes a scheme-less base URL', async () => {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
### 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
|
||||
@@ -52,6 +52,10 @@ Per-provider settings come from `~/.config/openchamber/settings.json` under `git
|
||||
- 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`
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -78,12 +79,12 @@ export function createOctokit(token, baseUrl) {
|
||||
});
|
||||
}
|
||||
|
||||
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, getProviderApiBaseUrl('github'));
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -590,7 +590,7 @@ export function registerGitHubRoutes(app) {
|
||||
};
|
||||
|
||||
const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -781,7 +781,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.status(401).json({ error: 'GitHub not connected' });
|
||||
}
|
||||
@@ -976,7 +976,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.status(401).json({ error: 'GitHub not connected' });
|
||||
}
|
||||
@@ -1113,7 +1113,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.status(401).json({ error: 'GitHub not connected' });
|
||||
}
|
||||
@@ -1159,7 +1159,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.status(401).json({ error: 'GitHub not connected' });
|
||||
}
|
||||
@@ -1216,7 +1216,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1277,7 +1277,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1361,7 +1361,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1422,7 +1422,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false, isFork: false, upstream: null });
|
||||
}
|
||||
@@ -1530,7 +1530,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false, users: [] });
|
||||
}
|
||||
@@ -1576,7 +1576,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false, labels: [] });
|
||||
}
|
||||
@@ -1621,7 +1621,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false, milestones: [] });
|
||||
}
|
||||
@@ -1667,7 +1667,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false, branches: [] });
|
||||
}
|
||||
@@ -1721,7 +1721,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false, tags: [] });
|
||||
}
|
||||
@@ -1770,7 +1770,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1879,7 +1879,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1947,7 +1947,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -1991,7 +1991,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -2052,7 +2052,7 @@ export function registerGitHubRoutes(app) {
|
||||
: undefined;
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -2122,7 +2122,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -2243,7 +2243,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -2390,7 +2390,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -2748,7 +2748,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
@@ -2805,7 +2805,7 @@ export function registerGitHubRoutes(app) {
|
||||
}
|
||||
|
||||
const { getOctokitOrNull } = await getGitHubLibraries();
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { registerGitHubRoutes } from '../github/routes.js';
|
||||
import { registerGitLabRoutes } from '../gitlab/routes.js';
|
||||
import { registerGiteaRoutes } from '../gitea/routes.js';
|
||||
import { registerGitRoutes } from '../git/routes.js';
|
||||
import { registerGitProviderRoutes } from '../git-providers/routes.js';
|
||||
import { registerDevServerRoutes } from '../dev-servers/routes.js';
|
||||
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
|
||||
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
|
||||
@@ -301,6 +302,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
registerGitHubRoutes(app);
|
||||
registerGitLabRoutes(app);
|
||||
registerGiteaRoutes(app);
|
||||
registerGitProviderRoutes(app);
|
||||
registerGitRoutes(app);
|
||||
registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts });
|
||||
registerMagicPromptRoutes(app, {
|
||||
|
||||
@@ -15,7 +15,7 @@ const GITLAB_DIFFS_MAX_PAGES = 10;
|
||||
* base branch is not part of it.
|
||||
*/
|
||||
async function getGitHubPullRequestDiff(directory, number) {
|
||||
const octokit = getOctokitOrNull();
|
||||
const octokit = getOctokitOrNull(directory);
|
||||
if (!octokit) {
|
||||
throw Object.assign(new Error('Connect a GitHub account to review pull requests'), {
|
||||
statusCode: 401,
|
||||
|
||||
Reference in New Issue
Block a user