diff --git a/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx b/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx new file mode 100644 index 00000000..9115cefa --- /dev/null +++ b/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx @@ -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; + for (const provider of GIT_PROVIDERS) { + const entry = config[provider]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + const apiBaseUrl = (entry as Record).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> => { + const payload: Partial> = {}; + 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 = ({ projectRef }) => { + const { t } = useI18n(); + const globalApiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls); + const [drafts, setDrafts] = React.useState({ ...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 ( + + {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 ( + + { + 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" + /> + + ); + })} + + ); +}; \ No newline at end of file diff --git a/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx b/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx index 4b9c4092..9336f603 100644 --- a/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx +++ b/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx @@ -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 = ({ return (
+ {showWorktrees ? : null}
diff --git a/packages/ui/src/lib/gitProvider.test.ts b/packages/ui/src/lib/gitProvider.test.ts index 87586736..ac1b10d8 100644 --- a/packages/ui/src/lib/gitProvider.test.ts +++ b/packages/ui/src/lib/gitProvider.test.ts @@ -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'); + }); +}); diff --git a/packages/ui/src/lib/gitProvider.ts b/packages/ui/src/lib/gitProvider.ts index 0d5c2ea0..1a21128f 100644 --- a/packages/ui/src/lib/gitProvider.ts +++ b/packages/ui/src/lib/gitProvider.ts @@ -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(); +/** + * 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 => { - 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( - () => 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(null); diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 8d0f0e97..7dd840b5 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 6158e738..3962a831 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index e1f69a96..ba65f7aa 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 3c5df11d..4dd7bc97 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 1c1e0678..85037e32 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -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': '自動更新の切替', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 93267651..0108dee3 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -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': '자동 새로고침 토글', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index bd8ffb55..1c1b1288 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index ab757b51..f39689de 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 1907e61a..b218c73d 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -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": "Увімкнути автоматичне оновлення", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 94edcb27..1cb51746 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -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': '切换自动刷新', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index fa2def92..784864cc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -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': '切換自動重新整理', diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index f17a4b20..d92061a6 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -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 = {}; 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, diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 10edbdf8..e236f58e 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -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 => { } 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 = {}; if (shouldPersistCraftGoalMigration) { if (authoritativeSettings.draftStarters) { diff --git a/packages/ui/src/lib/projectGitProviders.ts b/packages/ui/src/lib/projectGitProviders.ts new file mode 100644 index 00000000..43b88556 --- /dev/null +++ b/packages/ui/src/lib/projectGitProviders.ts @@ -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 => { + 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>; + +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, + 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, +}); \ No newline at end of file diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 7486dbb4..67e2b09d 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -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', diff --git a/packages/ui/src/stores/useGitProviderDomainsStore.test.ts b/packages/ui/src/stores/useGitProviderDomainsStore.test.ts index c5ea58f8..d5581966 100644 --- a/packages/ui/src/stores/useGitProviderDomainsStore.test.ts +++ b/packages/ui/src/stores/useGitProviderDomainsStore.test.ts @@ -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: '', + }); + }); +}); diff --git a/packages/ui/src/stores/useGitProviderDomainsStore.ts b/packages/ui/src/stores/useGitProviderDomainsStore.ts index f177feb6..17ee1a5a 100644 --- a/packages/ui/src/stores/useGitProviderDomainsStore.ts +++ b/packages/ui/src/stores/useGitProviderDomainsStore.ts @@ -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; 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()( @@ -114,6 +124,7 @@ export const useGitProviderDomainsStore = create()( (set, get) => ({ domains: EMPTY_DOMAINS, apiBaseUrls: EMPTY_API_BASE_URLS, + projectApiBaseUrls: {}, setDomains: (provider, domains) => { set({ domains: { @@ -146,6 +157,36 @@ export const useGitProviderDomainsStore = create()( } set({ domains, apiBaseUrls } as Partial); }, + 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, diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 0f37f137..045be109 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -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()( 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) => { diff --git a/packages/web/server/lib/git-providers/DOCUMENTATION.md b/packages/web/server/lib/git-providers/DOCUMENTATION.md index 38176854..00c7ca6b 100644 --- a/packages/web/server/lib/git-providers/DOCUMENTATION.md +++ b/packages/web/server/lib/git-providers/DOCUMENTATION.md @@ -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/.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/.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/.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/.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/.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/.json` → `getProjectProviderApiBaseUrl`) > global `settings.json` (`getProviderApiBaseUrl`) > built-in default (`GIT_PROVIDER_DEFAULTS`). + ## Consumers - `packages/web/server/lib/github/octokit.js`, `device-flow.js`, `routes.js`, `repo/index.js`, `pr-status.js`, `repo/fork-detection.js`: GitHub Enterprise support (Octokit `baseUrl`, device-flow web origin, remote parsing, fallback URLs). @@ -46,5 +84,5 @@ ## Notes for contributors -- Readers must never throw: `readGitProvidersConfig` and `githubWebOriginFromApiBase` fail closed. -- No new dependencies. +- Readers must never throw: `readGitProvidersConfig`, `readProjectJson`, and `githubWebOriginFromApiBase` fail closed. +- No new dependencies. \ No newline at end of file diff --git a/packages/web/server/lib/git-providers/project-config.js b/packages/web/server/lib/git-providers/project-config.js new file mode 100644 index 00000000..d47d2f18 --- /dev/null +++ b/packages/web/server/lib/git-providers/project-config.js @@ -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/.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/.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; +} \ No newline at end of file diff --git a/packages/web/server/lib/git-providers/project-config.test.js b/packages/web/server/lib/git-providers/project-config.test.js new file mode 100644 index 00000000..fee558a1 --- /dev/null +++ b/packages/web/server/lib/git-providers/project-config.test.js @@ -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({}); + }); +}); \ No newline at end of file diff --git a/packages/web/server/lib/git-providers/routes.js b/packages/web/server/lib/git-providers/routes.js new file mode 100644 index 00000000..2a13495a --- /dev/null +++ b/packages/web/server/lib/git-providers/routes.js @@ -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 }); + } + }); +} \ No newline at end of file diff --git a/packages/web/server/lib/gitea/DOCUMENTATION.md b/packages/web/server/lib/gitea/DOCUMENTATION.md index 869a9173..30272b0a 100644 --- a/packages/web/server/lib/gitea/DOCUMENTATION.md +++ b/packages/web/server/lib/gitea/DOCUMENTATION.md @@ -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/.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:` when the username is missing. - Auth header on every request: `Authorization: token `. - Gitea's `GET /user` uses `login`/`full_name`/`html_url`; `setGiteaAuth` accepts both that and the `username`/`web_url` variants. diff --git a/packages/web/server/lib/gitea/client.js b/packages/web/server/lib/gitea/client.js index 9445dc44..3ac96569 100644 --- a/packages/web/server/lib/gitea/client.js +++ b/packages/web/server/lib/gitea/client.js @@ -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 }); } diff --git a/packages/web/server/lib/gitea/repo.js b/packages/web/server/lib/gitea/repo.js index d5a2f564..8231dd33 100644 --- a/packages/web/server/lib/gitea/repo.js +++ b/packages/web/server/lib/gitea/repo.js @@ -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, }; } diff --git a/packages/web/server/lib/gitea/repo.test.js b/packages/web/server/lib/gitea/repo.test.js index 1d2e6d9a..918b4312 100644 --- a/packages/web/server/lib/gitea/repo.test.js +++ b/packages/web/server/lib/gitea/repo.test.js @@ -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(); + }); }); diff --git a/packages/web/server/lib/gitea/routes.js b/packages/web/server/lib/gitea/routes.js index 58af7032..48d69507 100644 --- a/packages/web/server/lib/gitea/routes.js +++ b/packages/web/server/lib/gitea/routes.js @@ -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 }; } diff --git a/packages/web/server/lib/gitea/routes.test.js b/packages/web/server/lib/gitea/routes.test.js index 150344c0..5baa4353 100644 --- a/packages/web/server/lib/gitea/routes.test.js +++ b/packages/web/server/lib/gitea/routes.test.js @@ -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 () => { diff --git a/packages/web/server/lib/github/DOCUMENTATION.md b/packages/web/server/lib/github/DOCUMENTATION.md index 135d4860..518240b9 100644 --- a/packages/web/server/lib/github/DOCUMENTATION.md +++ b/packages/web/server/lib/github/DOCUMENTATION.md @@ -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/.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` diff --git a/packages/web/server/lib/github/octokit.js b/packages/web/server/lib/github/octokit.js index 2c4d686e..7fd5134f 100644 --- a/packages/web/server/lib/github/octokit.js +++ b/packages/web/server/lib/github/octokit.js @@ -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')); } diff --git a/packages/web/server/lib/github/octokit.test.js b/packages/web/server/lib/github/octokit.test.js new file mode 100644 index 00000000..68bf51cb --- /dev/null +++ b/packages/web/server/lib/github/octokit.test.js @@ -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); + }); +}); \ No newline at end of file diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index 62ea10a8..cf26ecb9 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -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 }); } diff --git a/packages/web/server/lib/gitlab/DOCUMENTATION.md b/packages/web/server/lib/gitlab/DOCUMENTATION.md index 9016946d..b7f90b2c 100644 --- a/packages/web/server/lib/gitlab/DOCUMENTATION.md +++ b/packages/web/server/lib/gitlab/DOCUMENTATION.md @@ -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/.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:` when the username is missing. - Auth header on every request: `PRIVATE-TOKEN: `. diff --git a/packages/web/server/lib/gitlab/repo.js b/packages/web/server/lib/gitlab/repo.js index c83b691a..20e759b3 100644 --- a/packages/web/server/lib/gitlab/repo.js +++ b/packages/web/server/lib/gitlab/repo.js @@ -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, }; } diff --git a/packages/web/server/lib/gitlab/repo.test.js b/packages/web/server/lib/gitlab/repo.test.js index 0695a8a3..c7158678 100644 --- a/packages/web/server/lib/gitlab/repo.test.js +++ b/packages/web/server/lib/gitlab/repo.test.js @@ -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(); + }); }); diff --git a/packages/web/server/lib/gitlab/routes.js b/packages/web/server/lib/gitlab/routes.js index 18d061c2..9744edc2 100644 --- a/packages/web/server/lib/gitlab/routes.js +++ b/packages/web/server/lib/gitlab/routes.js @@ -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 }; } diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 42ed23fb..317ae5d4 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -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, { diff --git a/packages/web/server/lib/walkthrough/pull-request.js b/packages/web/server/lib/walkthrough/pull-request.js index 39d0ce2c..22afef4d 100644 --- a/packages/web/server/lib/walkthrough/pull-request.js +++ b/packages/web/server/lib/walkthrough/pull-request.js @@ -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,