From 0fc857959e0aca77878be6e23e97d3a8d5d37213 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Sat, 15 Aug 2026 12:26:24 +0000 Subject: [PATCH] feat(web,ui): per-provider git forge API base URL and detection URLs Configure a default API base URL per git provider (github/gitlab/gitea) in settings.json gitProviders, with GitHub Enterprise support (Octokit baseUrl + device-flow web origin derived from the API base), and replace the client-side custom-domains list with server-persisted detection URL chips (SSH/HTTPS forms normalized to hosts). The configured API base host auto-counts as a detection host. Settings round-trip through the existing /api/config/settings sanitizer; the UI store hydrates from server settings with a one-time localStorage migration. --- .../sections/openchamber/GitHubSettings.tsx | 8 +- .../sections/openchamber/GitLabSettings.tsx | 15 +- .../sections/openchamber/GiteaSettings.tsx | 15 +- .../sections/shared/CustomDomainsInput.tsx | 50 ----- .../shared/ProviderApiBaseUrlInput.tsx | 98 +++++++++ .../shared/ProviderDetectUrlsInput.tsx | 118 ++++++++++ packages/ui/src/hooks/useForgeProvider.ts | 18 +- packages/ui/src/lib/desktop.ts | 8 + packages/ui/src/lib/gitProvider.test.ts | 72 ++++++- packages/ui/src/lib/gitProvider.ts | 73 ++++++- .../ui/src/lib/i18n/messages/de.settings.ts | 30 ++- .../ui/src/lib/i18n/messages/en.settings.ts | 30 ++- .../ui/src/lib/i18n/messages/es.settings.ts | 30 ++- .../ui/src/lib/i18n/messages/fr.settings.ts | 30 ++- .../ui/src/lib/i18n/messages/ja.settings.ts | 30 ++- .../ui/src/lib/i18n/messages/ko.settings.ts | 30 ++- .../ui/src/lib/i18n/messages/pl.settings.ts | 30 ++- .../src/lib/i18n/messages/pt-BR.settings.ts | 30 ++- .../ui/src/lib/i18n/messages/uk.settings.ts | 30 ++- .../src/lib/i18n/messages/zh-CN.settings.ts | 30 ++- .../src/lib/i18n/messages/zh-TW.settings.ts | 30 ++- packages/ui/src/lib/linkedIssues.test.ts | 13 +- packages/ui/src/lib/linkedIssues.ts | 39 ++-- .../ui/src/lib/linkedSessionMatches.test.ts | 5 +- packages/ui/src/lib/persistence.ts | 53 +++++ packages/ui/src/lib/settings/search.ts | 42 ++++ packages/ui/src/stores/useConfigStore.ts | 19 ++ .../stores/useGitProviderDomainsStore.test.ts | 98 +++++++++ .../src/stores/useGitProviderDomainsStore.ts | 110 +++++++++- .../vscode/src/bridge-settings-runtime.ts | 45 ++++ .../server/lib/git-providers/DOCUMENTATION.md | 48 +++++ .../web/server/lib/git-providers/config.js | 203 ++++++++++++++++++ .../server/lib/git-providers/config.test.js | 176 +++++++++++++++ .../web/server/lib/gitea/DOCUMENTATION.md | 9 +- packages/web/server/lib/gitea/auth.js | 14 +- packages/web/server/lib/gitea/index.js | 1 + packages/web/server/lib/gitea/routes.js | 7 +- packages/web/server/lib/gitea/routes.test.js | 44 +++- .../web/server/lib/github/DOCUMENTATION.md | 14 +- packages/web/server/lib/github/device-flow.js | 14 +- packages/web/server/lib/github/index.js | 5 + packages/web/server/lib/github/octokit.js | 11 +- packages/web/server/lib/github/pr-status.js | 7 +- .../server/lib/github/repo/fork-detection.js | 7 +- packages/web/server/lib/github/repo/index.js | 35 ++- packages/web/server/lib/github/routes.js | 18 +- .../web/server/lib/gitlab/DOCUMENTATION.md | 5 +- packages/web/server/lib/gitlab/auth.js | 14 +- packages/web/server/lib/gitlab/auth.test.js | 19 ++ packages/web/server/lib/gitlab/client.js | 6 +- packages/web/server/lib/gitlab/index.js | 1 + packages/web/server/lib/gitlab/routes.js | 22 +- .../web/server/lib/opencode/DOCUMENTATION.md | 2 +- .../server/lib/opencode/settings-helpers.js | 6 + .../lib/opencode/settings-helpers.test.js | 40 ++++ 55 files changed, 1685 insertions(+), 272 deletions(-) delete mode 100644 packages/ui/src/components/sections/shared/CustomDomainsInput.tsx create mode 100644 packages/ui/src/components/sections/shared/ProviderApiBaseUrlInput.tsx create mode 100644 packages/ui/src/components/sections/shared/ProviderDetectUrlsInput.tsx create mode 100644 packages/web/server/lib/git-providers/DOCUMENTATION.md create mode 100644 packages/web/server/lib/git-providers/config.js create mode 100644 packages/web/server/lib/git-providers/config.test.js diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index fe3aaced..a8b9e68e 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -11,7 +11,8 @@ import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { Icon } from "@/components/icon/Icon"; import { SettingsSection, SettingsGroupTitle } from '@/components/sections/shared/SettingsSection'; -import { CustomDomainsInput } from '@/components/sections/shared/CustomDomainsInput'; +import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput'; +import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput'; type GitHubUser = { login: string; @@ -446,7 +447,10 @@ export const GitHubSettings: React.FC = () => { )} - +
+ + +
diff --git a/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx b/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx index 18f7a1a4..23ea2919 100644 --- a/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx @@ -11,7 +11,9 @@ import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { Icon } from '@/components/icon/Icon'; import { SettingsSection } from '@/components/sections/shared/SettingsSection'; -import { CustomDomainsInput } from '@/components/sections/shared/CustomDomainsInput'; +import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput'; +import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput'; +import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore'; const getBaseUrlHost = (baseUrl?: string | null): string => { if (!baseUrl) return ''; @@ -34,7 +36,11 @@ export const GitLabSettings: React.FC = () => { const [isBusy, setIsBusy] = React.useState(false); const [accessToken, setAccessToken] = React.useState(''); - const [baseUrl, setBaseUrl] = React.useState(''); + // Prefill the connect form with the server-side default API base URL when the + // user has not typed one yet; the per-account base URL still wins on connect. + const [baseUrl, setBaseUrl] = React.useState( + useGitProviderDomainsStore((state) => state.apiBaseUrls.gitlab), + ); React.useEffect(() => { (async () => { @@ -290,7 +296,10 @@ export const GitLabSettings: React.FC = () => { )} - +
+ + +
); }; diff --git a/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx b/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx index 18e8eb4f..f1e5e294 100644 --- a/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx @@ -11,7 +11,9 @@ import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { Icon } from '@/components/icon/Icon'; import { SettingsSection } from '@/components/sections/shared/SettingsSection'; -import { CustomDomainsInput } from '@/components/sections/shared/CustomDomainsInput'; +import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput'; +import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput'; +import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore'; const getBaseUrlHost = (baseUrl?: string | null): string => { if (!baseUrl) return ''; @@ -34,7 +36,11 @@ export const GiteaSettings: React.FC = () => { const [isBusy, setIsBusy] = React.useState(false); const [accessToken, setAccessToken] = React.useState(''); - const [baseUrl, setBaseUrl] = React.useState(''); + // Prefill the connect form with the server-side default API base URL when the + // user has not typed one yet; the per-account base URL still wins on connect. + const [baseUrl, setBaseUrl] = React.useState( + useGitProviderDomainsStore((state) => state.apiBaseUrls.gitea), + ); React.useEffect(() => { (async () => { @@ -300,7 +306,10 @@ export const GiteaSettings: React.FC = () => { )} - +
+ + +
); }; diff --git a/packages/ui/src/components/sections/shared/CustomDomainsInput.tsx b/packages/ui/src/components/sections/shared/CustomDomainsInput.tsx deleted file mode 100644 index b84933e6..00000000 --- a/packages/ui/src/components/sections/shared/CustomDomainsInput.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import { Input } from '@/components/ui/input'; -import { useI18n } from '@/lib/i18n'; -import { useGitProviderDomainsStore, type GitProviderName } from '@/stores/useGitProviderDomainsStore'; - -/** - * Comma-separated custom-domain input for a git provider. Commits (normalizes, - * dedupes, persists) on blur or Enter; the field reflects the persisted, - * normalized list joined by ', '. - */ -export const CustomDomainsInput: React.FC<{ provider: GitProviderName }> = ({ provider }) => { - const { t } = useI18n(); - const domains = useGitProviderDomainsStore((state) => state.domains[provider]); - const setDomains = useGitProviderDomainsStore((state) => state.setDomains); - const [value, setValue] = React.useState(domains.join(', ')); - - React.useEffect(() => { - setValue(domains.join(', ')); - }, [domains]); - - const commit = React.useCallback(() => { - setDomains(provider, value.split(',').map((entry) => entry.trim()).filter(Boolean)); - }, [provider, setDomains, value]); - - return ( -
- - setValue(event.target.value)} - onBlur={commit} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - commit(); - } - }} - placeholder={t(`settings.${provider}.page.customDomains.placeholder`)} - className="h-9 max-w-[24rem]" - /> - - {t(`settings.${provider}.page.customDomains.description`)} - -
- ); -}; diff --git a/packages/ui/src/components/sections/shared/ProviderApiBaseUrlInput.tsx b/packages/ui/src/components/sections/shared/ProviderApiBaseUrlInput.tsx new file mode 100644 index 00000000..7bde84e7 --- /dev/null +++ b/packages/ui/src/components/sections/shared/ProviderApiBaseUrlInput.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Input } from '@/components/ui/input'; +import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { reportSettingsSaveState } from '@/lib/persistence'; +import { SettingsStackedField } from '@/components/sections/shared/SettingsSection'; +import { + useGitProviderDomainsStore, + type GitProviderName, +} from '@/stores/useGitProviderDomainsStore'; + +/** + * Persist the full `gitProviders` settings object. The server replaces the + * whole `gitProviders` key on PUT, so every provider must be sent together — + * sending a single provider alone would wipe the others. + */ +// eslint-disable-next-line react-refresh/only-export-components +export const saveGitProvidersConfig = async (): Promise => { + const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState(); + const payload = { + gitProviders: { + github: { apiBaseUrl: apiBaseUrls.github, detectUrls: domains.github }, + gitlab: { apiBaseUrl: apiBaseUrls.gitlab, detectUrls: domains.gitlab }, + gitea: { apiBaseUrl: apiBaseUrls.gitea, detectUrls: domains.gitea }, + }, + }; + reportSettingsSaveState('saving'); + try { + const response = await runtimeFetch('/api/config/settings', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(response.statusText); + } + reportSettingsSaveState('saved'); + } catch (error) { + console.warn('Failed to persist git provider settings:', error); + reportSettingsSaveState('error'); + } +}; + +/** + * Server-side default API base URL for a git provider's API calls. A per-account + * base URL still wins once an account is connected. Commits (updates the store + * cache optimistically and persists the full gitProviders object) on blur or + * Enter; the settings round-trip re-hydrates the store afterwards. + */ +export const ProviderApiBaseUrlInput: React.FC<{ provider: GitProviderName }> = ({ provider }) => { + const { t } = useI18n(); + const storedValue = useGitProviderDomainsStore((state) => state.apiBaseUrls[provider]); + const setApiBaseUrl = useGitProviderDomainsStore((state) => state.setApiBaseUrl); + const [draft, setDraft] = React.useState(storedValue); + + React.useEffect(() => { + setDraft(storedValue); + }, [storedValue]); + + const commit = React.useCallback(() => { + const next = draft.trim(); + if (next !== storedValue) { + setApiBaseUrl(provider, next); + void saveGitProvidersConfig(); + } else { + // Blur with no real change: drop incidental whitespace from the draft. + setDraft(storedValue); + } + }, [draft, provider, setApiBaseUrl, storedValue]); + + return ( + + setDraft(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" + /> + + ); +}; diff --git a/packages/ui/src/components/sections/shared/ProviderDetectUrlsInput.tsx b/packages/ui/src/components/sections/shared/ProviderDetectUrlsInput.tsx new file mode 100644 index 00000000..bb020293 --- /dev/null +++ b/packages/ui/src/components/sections/shared/ProviderDetectUrlsInput.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/icon/Icon'; +import { Input } from '@/components/ui/input'; +import { useI18n } from '@/lib/i18n'; +import { SettingsStackedField } from '@/components/sections/shared/SettingsSection'; +import { + normalizeProviderDomain, + useGitProviderDomainsStore, + type GitProviderName, +} from '@/stores/useGitProviderDomainsStore'; +import { saveGitProvidersConfig } from './ProviderApiBaseUrlInput'; + +/** + * Detection URLs for a git provider: an SSH or HTTPS URL typed into the field + * becomes a chip (displayed as its bare hostname) on Enter or comma, and the X + * on a chip removes it. The hosts feed provider autodetection of repo remotes. + * Each change updates the store cache optimistically and persists the full + * gitProviders object; the settings round-trip re-hydrates the store. + */ +export const ProviderDetectUrlsInput: React.FC<{ provider: GitProviderName }> = ({ provider }) => { + const { t } = useI18n(); + const domains = useGitProviderDomainsStore((state) => state.domains[provider]); + const setDomains = useGitProviderDomainsStore((state) => state.setDomains); + const [draft, setDraft] = React.useState(''); + const [invalid, setInvalid] = React.useState(false); + + const commitDraft = React.useCallback(() => { + const raw = draft.trim(); + if (!raw) { + setDraft(''); + setInvalid(false); + return; + } + const next = [...domains]; + let added = false; + for (const part of raw.split(',')) { + const host = normalizeProviderDomain(part); + if (host && !next.includes(host)) { + next.push(host); + added = true; + } + } + if (added) { + setDomains(provider, next); + setDraft(''); + setInvalid(false); + void saveGitProvidersConfig(); + } else { + // Unparseable input: keep the draft so the user can fix it. + setInvalid(true); + } + }, [draft, domains, provider, setDomains]); + + const removeChip = React.useCallback((host: string) => { + setDomains(provider, domains.filter((entry) => entry !== host)); + void saveGitProvidersConfig(); + }, [domains, provider, setDomains]); + + return ( + +
+ {domains.length > 0 ? ( +
+ {domains.map((host) => ( + + {host} + + + ))} +
+ ) : null} + { + setDraft(event.target.value); + setInvalid(false); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + commitDraft(); + } + }} + onBlur={commitDraft} + placeholder={t(`settings.${provider}.page.detectUrls.placeholder`)} + aria-label={t('settings.gitProviders.detectUrls.add')} + aria-invalid={invalid || undefined} + className="h-9" + /> + {invalid ? ( +

+ {t('settings.gitProviders.detectUrls.invalid')} +

+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/hooks/useForgeProvider.ts b/packages/ui/src/hooks/useForgeProvider.ts index bb5fdccb..0c8f8679 100644 --- a/packages/ui/src/hooks/useForgeProvider.ts +++ b/packages/ui/src/hooks/useForgeProvider.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { resolveGitProvider, useGitProvider } from '@/lib/gitProvider'; +import { resolveGitProvider, useGitProvider, buildGitProviderHosts } from '@/lib/gitProvider'; import type { GitProviderHosts } from '@/lib/gitProvider'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; @@ -10,20 +10,16 @@ import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore'; /** - * Provider-host sets derived from the connected accounts and the - * user-configured custom domains, mirroring the `hosts` memo inside - * `useGitProvider` so the imperative resolver classifies directories the same - * way the hook does. + * Provider-host sets derived from the connected accounts, the configured api + * base urls and the user-configured custom domains, mirroring the `hosts` memo + * inside `useGitProvider` so the imperative resolver classifies directories the + * same way the hook does. */ const buildProviderHosts = (): GitProviderHosts => { const gitlabAccounts = useGitLabAuthStore.getState().status?.accounts; const giteaAccounts = useGiteaAuthStore.getState().status?.accounts; - const domains = useGitProviderDomainsStore.getState().domains; - return { - github: domains.github, - gitlab: [...(gitlabAccounts ?? []).map((account) => account.baseUrl), ...domains.gitlab], - gitea: [...(giteaAccounts ?? []).map((account) => account.baseUrl), ...domains.gitea], - }; + const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState(); + return buildGitProviderHosts({ domains, apiBaseUrls, gitlabAccounts, giteaAccounts }); }; /** diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index cae8fad2..d9ec01ad 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -217,6 +217,14 @@ export type DesktopSettings = { sttModel?: string; sttLocalModel?: string; sttLanguage?: string; + // Per-provider git forge configuration (server-side settings.json): the API + // base URL for provider API calls and the bare hosts that auto-detect the + // provider. Server-authoritative; the client stores only a localStorage cache. + gitProviders?: { + github?: { apiBaseUrl?: string; detectUrls?: string[] }; + gitlab?: { apiBaseUrl?: string; detectUrls?: string[] }; + gitea?: { apiBaseUrl?: string; detectUrls?: string[] }; + }; // Global draft welcome starters (pinned commands/skills), persisted to settings.json draftStarters?: DraftStarterRef[]; draftStartersVisible?: boolean; diff --git a/packages/ui/src/lib/gitProvider.test.ts b/packages/ui/src/lib/gitProvider.test.ts index aa0b9a62..7ac98cb9 100644 --- a/packages/ui/src/lib/gitProvider.test.ts +++ b/packages/ui/src/lib/gitProvider.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import { detectGitProvider, type GitProviderHosts } from './gitProvider'; +import { buildGitProviderHosts, detectGitProvider, type GitProviderHosts } from './gitProvider'; const EMPTY_HOSTS: GitProviderHosts = { github: [], gitlab: [], gitea: [] }; +const emptyInput = { + domains: { github: [], gitlab: [], gitea: [] }, + apiBaseUrls: { github: '', gitlab: '', gitea: '' }, +}; + describe('detectGitProvider', () => { test('returns null with no remotes', () => { expect(detectGitProvider([], EMPTY_HOSTS)).toBeNull(); @@ -135,3 +140,68 @@ describe('detectGitProvider', () => { expect(detectGitProvider(['https://GITHUB.com/owner/repo.git'], EMPTY_HOSTS)).toBe('github'); }); }); + +describe('buildGitProviderHosts', () => { + test('adds no hosts when nothing is configured', () => { + expect(buildGitProviderHosts(emptyInput)).toEqual(EMPTY_HOSTS); + }); + + test('auto-adds the github api base host so the provider is detected', () => { + const hosts = buildGitProviderHosts({ + ...emptyInput, + apiBaseUrls: { github: 'https://github.example.com/api/v3', gitlab: '', gitea: '' }, + }); + expect(hosts.github).toEqual(['github.example.com']); + expect(detectGitProvider(['git@github.example.com:owner/repo.git'], hosts)).toBe('github'); + }); + + test('a github api base of api.github.com maps to github.com and changes nothing', () => { + const hosts = buildGitProviderHosts({ + ...emptyInput, + apiBaseUrls: { github: 'https://api.github.com', gitlab: '', gitea: '' }, + }); + // github.com is already a built-in detection host; no behavior change. + expect(hosts.github).toEqual(['github.com']); + expect(detectGitProvider(['git@github.com:owner/repo.git'], hosts)).toBe('github'); + // The api host itself is not a web/remote host. + expect(detectGitProvider(['git@api.github.com:owner/repo.git'], hosts)).toBe('other'); + }); + + test('auto-adds the gitlab api base host so the provider is detected', () => { + const hosts = buildGitProviderHosts({ + ...emptyInput, + apiBaseUrls: { github: '', gitlab: 'https://gitlab.example.com', gitea: '' }, + }); + expect(hosts.gitlab).toEqual(['gitlab.example.com']); + expect(detectGitProvider(['git@gitlab.example.com:group/project.git'], hosts)).toBe('gitlab'); + }); + + test('auto-adds the gitea api base host so the provider is detected', () => { + const hosts = buildGitProviderHosts({ + ...emptyInput, + apiBaseUrls: { github: '', gitlab: '', gitea: 'https://gitea.example.com' }, + }); + expect(hosts.gitea).toEqual(['gitea.example.com']); + expect(detectGitProvider(['git@gitea.example.com:owner/repo.git'], hosts)).toBe('gitea'); + }); + + test('combines account base urls, api base host and custom domains, normalized and deduped', () => { + const hosts = buildGitProviderHosts({ + domains: { github: [], gitlab: ['https://gitlab.example.com', 'gitlab.internal'], gitea: ['codeberg.org'] }, + apiBaseUrls: { github: '', gitlab: 'https://gitlab.example.com/', gitea: '' }, + gitlabAccounts: [{ baseUrl: 'https://gitlab.example.com' }, { baseUrl: 'ssh://git@gl.other.example.com/x' }], + giteaAccounts: [{ baseUrl: 'https://gitea.example.com' }], + }); + expect(hosts.gitlab).toEqual(['gitlab.example.com', 'gl.other.example.com', 'gitlab.internal']); + expect(hosts.gitea).toEqual(['gitea.example.com', 'codeberg.org']); + expect(hosts.github).toEqual([]); + }); + + test('dedupes the api base host against configured domains', () => { + const hosts = buildGitProviderHosts({ + domains: { github: ['github.example.com'], gitlab: [], gitea: [] }, + apiBaseUrls: { github: 'https://github.example.com/api/v3', gitlab: '', gitea: '' }, + }); + expect(hosts.github).toEqual(['github.example.com']); + }); +}); diff --git a/packages/ui/src/lib/gitProvider.ts b/packages/ui/src/lib/gitProvider.ts index 5d0777fc..e82b3a7a 100644 --- a/packages/ui/src/lib/gitProvider.ts +++ b/packages/ui/src/lib/gitProvider.ts @@ -3,15 +3,20 @@ import { parseGitHost } from '@/lib/gitHost'; import { getRemotes } from '@/lib/gitApi'; import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; -import { useGitProviderDomainsStore, normalizeProviderDomain } from '@/stores/useGitProviderDomainsStore'; +import { + useGitProviderDomainsStore, + normalizeProviderDomain, + type GitProviderApiBaseUrls, + type GitProviderDomains, +} from '@/stores/useGitProviderDomainsStore'; export type GitProvider = 'github' | 'gitlab' | 'gitea' | 'other'; /** * Per-provider hostname sets used for detection: custom user-configured - * domains (from the domains store) plus account-derived base-URL hostnames. - * Built-in defaults (github.com, gitlab.com) are applied inside the detection - * logic and never need to be present here. + * domains (from the domains store), account-derived base-URL hostnames, and the + * configured api base host. Built-in defaults (github.com, gitlab.com) are + * applied inside the detection logic and never need to be present here. */ export type GitProviderHosts = { github: string[]; @@ -32,6 +37,57 @@ const normalizeHostList = (hosts: string[] | undefined): string[] => { return result; }; +const GITHUB_API_HOST = 'api.github.com'; + +/** + * Hostname of a configured api base URL, or null when unset/unparseable. + * GitHub Enterprise remotes point at the web host, not the api subdomain, so an + * `api.github.com` api base maps back to `github.com` (the built-in web host). + */ +const apiBaseHost = (apiBaseUrl: string | undefined): string | null => { + const host = normalizeProviderDomain(apiBaseUrl ?? ''); + if (!host) return null; + return host === GITHUB_API_HOST ? 'github.com' : host; +}; + +/** + * Build the per-provider detection host sets. Each provider gets its configured + * api base host (auto-added; github's `api.github.com` maps to `github.com`), + * plus the account-derived base-URL hosts (gitlab/gitea) and the custom domains + * from the domains store. All entries are normalized and deduped. + */ +export const buildGitProviderHosts = (input: { + domains: GitProviderDomains; + apiBaseUrls: GitProviderApiBaseUrls; + gitlabAccounts?: Array<{ baseUrl?: string }>; + giteaAccounts?: Array<{ baseUrl?: string }>; +}): GitProviderHosts => { + const githubApiHost = apiBaseHost(input.apiBaseUrls.github); + const gitlabApiHost = apiBaseHost(input.apiBaseUrls.gitlab); + const giteaApiHost = apiBaseHost(input.apiBaseUrls.gitea); + + return { + github: normalizeHostList([ + ...(githubApiHost ? [githubApiHost] : []), + ...input.domains.github, + ]), + gitlab: normalizeHostList([ + ...(input.gitlabAccounts ?? []) + .map((account) => account.baseUrl) + .filter((url): url is string => Boolean(url)), + ...(gitlabApiHost ? [gitlabApiHost] : []), + ...input.domains.gitlab, + ]), + gitea: normalizeHostList([ + ...(input.giteaAccounts ?? []) + .map((account) => account.baseUrl) + .filter((url): url is string => Boolean(url)), + ...(giteaApiHost ? [giteaApiHost] : []), + ...input.domains.gitea, + ]), + }; +}; + /** * Classify a repository by the hosts of its remotes. Returns null when there * are no remotes to inspect. Built-in defaults apply always: `github.com` is @@ -104,13 +160,10 @@ export const useGitProvider = (directory: string | null | undefined): GitProvide const gitlabAccounts = useGitLabAuthStore((state) => state.status?.accounts); const giteaAccounts = useGiteaAuthStore((state) => state.status?.accounts); const domains = useGitProviderDomainsStore((state) => state.domains); + const apiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls); const hosts = useMemo( - () => ({ - github: domains.github, - gitlab: [...(gitlabAccounts ?? []).map((account) => account.baseUrl), ...domains.gitlab], - gitea: [...(giteaAccounts ?? []).map((account) => account.baseUrl), ...domains.gitea], - }), - [domains, gitlabAccounts, giteaAccounts], + () => buildGitProviderHosts({ domains, apiBaseUrls, gitlabAccounts, giteaAccounts }), + [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 833adcf5..a6edc9f3 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1635,9 +1635,12 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI Fallback aktiviert', 'settings.github.page.toast.ghCliDisabled': 'gh CLI Fallback deaktiviert', 'settings.github.page.toast.ghCliUpdateFailed': 'Fehler beim Aktualisieren der gh CLI Einstellung', - 'settings.github.page.customDomains.label': 'Benutzerdefinierte Domains', - 'settings.github.page.customDomains.description': 'Repositories auf diesen Domains werden als GitHub behandelt.', - 'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com', + 'settings.github.page.apiBaseUrl.label': 'API-Basis-URL', + 'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3', + 'settings.github.page.apiBaseUrl.description': 'Standard-Basis-URL für API-Aufrufe. Für GitHub Enterprise verwende die Adresse deines Servers, z. B. https://github.example.com/api/v3.', + 'settings.github.page.detectUrls.label': 'Erkennungs-URLs', + 'settings.github.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.github.page.detectUrls.description': 'Repositories, die per SSH oder HTTPS von diesen Hosts geklont wurden, werden als GitHub erkannt.', 'settings.gitlab.page.title': 'GitLab Personal Access Token', 'settings.gitlab.page.description': 'Fügen Sie ein GitLab Personal Access Token ein, um eine Verbindung herzustellen. Legen Sie die Basis-URL fest, wenn Sie eine selbst gehostete GitLab-Instanz verwenden.', 'settings.gitlab.page.tooltip.connectAccount': 'Verbinden Sie ein GitLab-Konto für Issue- und Merge-Request-Workflows in der App.', @@ -1645,9 +1648,12 @@ export const settingsDict = { 'settings.gitlab.page.accessToken.placeholder': 'Fügen Sie Ihr GitLab Personal Access Token ein', 'settings.gitlab.page.baseUrl.label': 'Basis-URL (optional)', 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', - 'settings.gitlab.page.customDomains.label': 'Benutzerdefinierte Domains', - 'settings.gitlab.page.customDomains.description': 'Repositories auf diesen Domains werden als GitLab behandelt.', - 'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.com', + 'settings.gitlab.page.apiBaseUrl.label': 'API-Basis-URL', + 'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com', + 'settings.gitlab.page.apiBaseUrl.description': 'Standard-Basis-URL für API-Aufrufe. Für selbst gehostete Instanzen verwende die Adresse deines Servers, z. B. https://gitlab.example.com.', + 'settings.gitlab.page.detectUrls.label': 'Erkennungs-URLs', + 'settings.gitlab.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitlab.page.detectUrls.description': 'Repositories, die per SSH oder HTTPS von diesen Hosts geklont wurden, werden als GitLab erkannt.', 'settings.gitlab.page.actions.connect': 'GitLab verbinden', 'settings.gitlab.page.actions.disconnect': 'Trennen', 'settings.gitlab.page.actions.switch': 'Wechseln zu', @@ -1687,9 +1693,15 @@ export const settingsDict = { 'settings.gitea.page.toast.disconnectFailed': 'Trennen von Gitea fehlgeschlagen', 'settings.gitea.page.toast.accountSwitched': 'Gitea-Konto gewechselt', 'settings.gitea.page.toast.accountSwitchFailed': 'Wechsel des Gitea-Kontos fehlgeschlagen', - 'settings.gitea.page.customDomains.label': 'Benutzerdefinierte Domains', - 'settings.gitea.page.customDomains.description': 'Repositories auf diesen Domains werden als Gitea oder Forgejo behandelt.', - 'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.com', + 'settings.gitea.page.apiBaseUrl.label': 'API-Basis-URL', + 'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com', + 'settings.gitea.page.apiBaseUrl.description': 'Standard-Basis-URL für API-Aufrufe. Für selbst gehostete Instanzen verwende die Adresse deines Servers, z. B. https://gitea.example.com.', + 'settings.gitea.page.detectUrls.label': 'Erkennungs-URLs', + 'settings.gitea.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitea.page.detectUrls.description': 'Repositories, die per SSH oder HTTPS von diesen Hosts geklont wurden, werden als Gitea oder Forgejo erkannt.', + 'settings.gitProviders.detectUrls.add': 'Erkennungs-URL hinzufügen', + 'settings.gitProviders.detectUrls.remove': '{host} entfernen', + 'settings.gitProviders.detectUrls.invalid': 'Gib eine gültige SSH- oder HTTPS-URL bzw. einen gültigen Hostnamen ein.', 'settings.notifications.page.delivery.title': 'Benachrichtigungsübermittlung', 'settings.notifications.page.delivery.enableAria': 'Benachrichtigungen aktivieren', 'settings.notifications.page.delivery.enableLabel': 'Benachrichtigungen aktivieren', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 007a6584..6b12697d 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1701,9 +1701,12 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI fallback enabled', 'settings.github.page.toast.ghCliDisabled': 'gh CLI fallback disabled', 'settings.github.page.toast.ghCliUpdateFailed': 'Failed to update gh CLI setting', - 'settings.github.page.customDomains.label': 'Custom domains', - 'settings.github.page.customDomains.description': 'Repos hosted on these domains are treated as GitHub.', - 'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com', + 'settings.github.page.apiBaseUrl.label': 'API base URL', + 'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3', + 'settings.github.page.apiBaseUrl.description': 'Default base URL for API calls. For GitHub Enterprise, use your server address, e.g. https://github.example.com/api/v3.', + 'settings.github.page.detectUrls.label': 'Detection URLs', + 'settings.github.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.github.page.detectUrls.description': 'Repos cloned over SSH or HTTPS from these hosts are recognized as GitHub.', 'settings.gitlab.page.title': 'GitLab Personal Access Token', 'settings.gitlab.page.description': 'Paste a GitLab personal access token to connect. Set the base URL when using a self-hosted GitLab instance.', 'settings.gitlab.page.tooltip.connectAccount': 'Connect a GitLab account for in-app issue and merge request workflows.', @@ -1711,9 +1714,12 @@ export const settingsDict = { 'settings.gitlab.page.accessToken.placeholder': 'Paste your GitLab personal access token', 'settings.gitlab.page.baseUrl.label': 'Base URL (optional)', 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', - 'settings.gitlab.page.customDomains.label': 'Custom domains', - 'settings.gitlab.page.customDomains.description': 'Repos hosted on these domains are treated as GitLab.', - 'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.com', + 'settings.gitlab.page.apiBaseUrl.label': 'API base URL', + 'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com', + 'settings.gitlab.page.apiBaseUrl.description': 'Default base URL for API calls. For self-hosted instances, use your server address, e.g. https://gitlab.example.com.', + 'settings.gitlab.page.detectUrls.label': 'Detection URLs', + 'settings.gitlab.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitlab.page.detectUrls.description': 'Repos cloned over SSH or HTTPS from these hosts are recognized as GitLab.', 'settings.gitlab.page.actions.connect': 'Connect GitLab', 'settings.gitlab.page.actions.disconnect': 'Disconnect', 'settings.gitlab.page.actions.switch': 'Switch to', @@ -1753,9 +1759,15 @@ export const settingsDict = { 'settings.gitea.page.toast.disconnectFailed': 'Failed to disconnect Gitea', 'settings.gitea.page.toast.accountSwitched': 'Gitea account switched', 'settings.gitea.page.toast.accountSwitchFailed': 'Failed to switch Gitea account', - 'settings.gitea.page.customDomains.label': 'Custom domains', - 'settings.gitea.page.customDomains.description': 'Repos hosted on these domains are treated as Gitea or Forgejo.', - 'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.com', + 'settings.gitea.page.apiBaseUrl.label': 'API base URL', + 'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com', + 'settings.gitea.page.apiBaseUrl.description': 'Default base URL for API calls. For self-hosted instances, use your server address, e.g. https://gitea.example.com.', + 'settings.gitea.page.detectUrls.label': 'Detection URLs', + 'settings.gitea.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitea.page.detectUrls.description': 'Repos cloned over SSH or HTTPS from these hosts are recognized as Gitea or Forgejo.', + 'settings.gitProviders.detectUrls.add': 'Add a detection URL', + 'settings.gitProviders.detectUrls.remove': 'Remove {host}', + 'settings.gitProviders.detectUrls.invalid': 'Enter a valid SSH or HTTPS URL or hostname.', 'settings.notifications.page.delivery.title': 'Notification Delivery', 'settings.notifications.page.delivery.enableAria': 'Enable notifications', 'settings.notifications.page.delivery.enableLabel': 'Enable Notifications', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 616f04c8..fb4ad670 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1678,9 +1678,12 @@ export const settingsDict = { "settings.github.page.toast.ghCliEnabled": "Respaldo de gh CLI activado", "settings.github.page.toast.ghCliDisabled": "Respaldo de gh CLI desactivado", "settings.github.page.toast.ghCliUpdateFailed": "No se pudo actualizar la configuración de gh CLI", - "settings.github.page.customDomains.label": "Dominios personalizados", - "settings.github.page.customDomains.description": "Los repositorios alojados en estos dominios se tratarán como GitHub.", - "settings.github.page.customDomains.placeholder": "github.example.com, git.mycompany.com", + "settings.github.page.apiBaseUrl.label": "URL base de la API", + "settings.github.page.apiBaseUrl.placeholder": "https://github.example.com/api/v3", + "settings.github.page.apiBaseUrl.description": "URL base por defecto para las llamadas a la API. Para GitHub Enterprise, usa la dirección de tu servidor, p. ej. https://github.example.com/api/v3.", + "settings.github.page.detectUrls.label": "URL de detección", + "settings.github.page.detectUrls.placeholder": "ssh://git@git.example.com:2222, https://git.example.com", + "settings.github.page.detectUrls.description": "Los repositorios clonados por SSH o HTTPS desde estos hosts se reconocen como GitHub.", "settings.gitlab.page.title": "Token de acceso personal de GitLab", "settings.gitlab.page.description": "Pega un token de acceso personal de GitLab para conectarte. Establece la URL base cuando uses una instancia de GitLab autoalojada.", "settings.gitlab.page.tooltip.connectAccount": "Conecta una cuenta de GitLab para los flujos de trabajo de issues y merge requests en la aplicación.", @@ -1688,9 +1691,12 @@ export const settingsDict = { "settings.gitlab.page.accessToken.placeholder": "Pega tu token de acceso personal de GitLab", "settings.gitlab.page.baseUrl.label": "URL base (opcional)", "settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com", - "settings.gitlab.page.customDomains.label": "Dominios personalizados", - "settings.gitlab.page.customDomains.description": "Los repositorios alojados en estos dominios se tratarán como GitLab.", - "settings.gitlab.page.customDomains.placeholder": "gitlab.example.com, git.company.com", + "settings.gitlab.page.apiBaseUrl.label": "URL base de la API", + "settings.gitlab.page.apiBaseUrl.placeholder": "https://gitlab.example.com", + "settings.gitlab.page.apiBaseUrl.description": "URL base por defecto para las llamadas a la API. Para instancias autoalojadas, usa la dirección de tu servidor, p. ej. https://gitlab.example.com.", + "settings.gitlab.page.detectUrls.label": "URL de detección", + "settings.gitlab.page.detectUrls.placeholder": "ssh://git@git.example.com:2222, https://git.example.com", + "settings.gitlab.page.detectUrls.description": "Los repositorios clonados por SSH o HTTPS desde estos hosts se reconocen como GitLab.", "settings.gitlab.page.actions.connect": "Conectar GitLab", "settings.gitlab.page.actions.disconnect": "Desconectar", "settings.gitlab.page.actions.switch": "Cambiar a", @@ -1730,9 +1736,15 @@ export const settingsDict = { "settings.gitea.page.toast.disconnectFailed": "No se pudo desconectar Gitea", "settings.gitea.page.toast.accountSwitched": "Cuenta de Gitea cambiada", "settings.gitea.page.toast.accountSwitchFailed": "No se pudo cambiar la cuenta de Gitea", - "settings.gitea.page.customDomains.label": "Dominios personalizados", - "settings.gitea.page.customDomains.description": "Los repositorios alojados en estos dominios se tratarán como Gitea o Forgejo.", - "settings.gitea.page.customDomains.placeholder": "gitea.example.com, git.company.com", + "settings.gitea.page.apiBaseUrl.label": "URL base de la API", + "settings.gitea.page.apiBaseUrl.placeholder": "https://gitea.example.com", + "settings.gitea.page.apiBaseUrl.description": "URL base por defecto para las llamadas a la API. Para instancias autoalojadas, usa la dirección de tu servidor, p. ej. https://gitea.example.com.", + "settings.gitea.page.detectUrls.label": "URL de detección", + "settings.gitea.page.detectUrls.placeholder": "ssh://git@git.example.com:2222, https://git.example.com", + "settings.gitea.page.detectUrls.description": "Los repositorios clonados por SSH o HTTPS desde estos hosts se reconocen como Gitea o Forgejo.", + "settings.gitProviders.detectUrls.add": "Añadir una URL de detección", + "settings.gitProviders.detectUrls.remove": "Quitar {host}", + "settings.gitProviders.detectUrls.invalid": "Introduce una URL SSH o HTTPS o un nombre de host válido.", "settings.notifications.page.delivery.title": "Entrega de notificaciones", "settings.notifications.page.delivery.enableAria": "Habilitar notificaciones", "settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index c7c024f9..8c9efad2 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1596,9 +1596,12 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'Solution de secours gh CLI activée', 'settings.github.page.toast.ghCliDisabled': 'Solution de secours gh CLI désactivée', 'settings.github.page.toast.ghCliUpdateFailed': 'Échec de la mise à jour du paramètre gh CLI', - 'settings.github.page.customDomains.label': 'Domaines personnalisés', - 'settings.github.page.customDomains.description': 'Les dépôts hébergés sur ces domaines sont traités comme GitHub.', - 'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com', + 'settings.github.page.apiBaseUrl.label': 'URL de base de l’API', + 'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3', + 'settings.github.page.apiBaseUrl.description': 'URL de base par défaut pour les appels API. Pour GitHub Enterprise, utilisez l’adresse de votre serveur, ex. https://github.example.com/api/v3.', + 'settings.github.page.detectUrls.label': 'URL de détection', + 'settings.github.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.github.page.detectUrls.description': 'Les dépôts clonés en SSH ou HTTPS depuis ces hôtes sont reconnus comme GitHub.', 'settings.gitlab.page.title': 'Jeton d\'accès personnel GitLab', 'settings.gitlab.page.description': 'Collez un jeton d\'accès personnel GitLab pour vous connecter. Définissez l\'URL de base si vous utilisez une instance GitLab auto-hébergée.', 'settings.gitlab.page.tooltip.connectAccount': 'Connectez un compte GitLab pour les workflows d\'issues et de merge requests dans l\'application.', @@ -1606,9 +1609,12 @@ export const settingsDict = { 'settings.gitlab.page.accessToken.placeholder': 'Collez votre jeton d\'accès personnel GitLab', 'settings.gitlab.page.baseUrl.label': 'URL de base (facultatif)', 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', - 'settings.gitlab.page.customDomains.label': 'Domaines personnalisés', - 'settings.gitlab.page.customDomains.description': 'Les dépôts hébergés sur ces domaines sont traités comme GitLab.', - 'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.com', + 'settings.gitlab.page.apiBaseUrl.label': 'URL de base de l’API', + 'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com', + 'settings.gitlab.page.apiBaseUrl.description': 'URL de base par défaut pour les appels API. Pour les instances auto-hébergées, utilisez l’adresse de votre serveur, ex. https://gitlab.example.com.', + 'settings.gitlab.page.detectUrls.label': 'URL de détection', + 'settings.gitlab.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitlab.page.detectUrls.description': 'Les dépôts clonés en SSH ou HTTPS depuis ces hôtes sont reconnus comme GitLab.', 'settings.gitlab.page.actions.connect': 'Connecter GitLab', 'settings.gitlab.page.actions.disconnect': 'Déconnecter', 'settings.gitlab.page.actions.switch': 'Passer à', @@ -1648,9 +1654,15 @@ export const settingsDict = { 'settings.gitea.page.toast.disconnectFailed': 'Échec de la déconnexion du Gitea', 'settings.gitea.page.toast.accountSwitched': 'Le compte Gitea a changé', 'settings.gitea.page.toast.accountSwitchFailed': 'Échec du changement de compte Gitea', - 'settings.gitea.page.customDomains.label': 'Domaines personnalisés', - 'settings.gitea.page.customDomains.description': 'Les dépôts hébergés sur ces domaines sont traités comme Gitea ou Forgejo.', - 'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.com', + 'settings.gitea.page.apiBaseUrl.label': 'URL de base de l’API', + 'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com', + 'settings.gitea.page.apiBaseUrl.description': 'URL de base par défaut pour les appels API. Pour les instances auto-hébergées, utilisez l’adresse de votre serveur, ex. https://gitea.example.com.', + 'settings.gitea.page.detectUrls.label': 'URL de détection', + 'settings.gitea.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitea.page.detectUrls.description': 'Les dépôts clonés en SSH ou HTTPS depuis ces hôtes sont reconnus comme Gitea ou Forgejo.', + 'settings.gitProviders.detectUrls.add': 'Ajouter une URL de détection', + 'settings.gitProviders.detectUrls.remove': 'Retirer {host}', + 'settings.gitProviders.detectUrls.invalid': 'Saisissez une URL SSH ou HTTPS ou un nom d’hôte valide.', 'settings.notifications.page.delivery.title': 'Envoi des notifications', 'settings.notifications.page.delivery.enableAria': 'Activer les notifications', 'settings.notifications.page.delivery.enableLabel': 'Activer les notifications', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index ed90da24..39e4feb8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1711,9 +1711,12 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI フォールバックを有効化しました', 'settings.github.page.toast.ghCliDisabled': 'gh CLI フォールバックを無効化しました', 'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 設定の更新に失敗しました', - 'settings.github.page.customDomains.label': 'カスタムドメイン', - 'settings.github.page.customDomains.description': 'これらのドメインでホストされているリポジトリは GitHub として扱われます。', - 'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com', + 'settings.github.page.apiBaseUrl.label': 'API ベース URL', + 'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3', + 'settings.github.page.apiBaseUrl.description': 'API 呼び出しに使用する既定のベース URL です。GitHub Enterprise の場合はサーバーアドレスを指定します。例: https://github.example.com/api/v3。', + 'settings.github.page.detectUrls.label': '検出 URL', + 'settings.github.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.github.page.detectUrls.description': 'これらのホストから SSH または HTTPS でクローンしたリポジトリは GitHub として認識されます。', 'settings.gitlab.page.title': 'GitLab パーソナルアクセストークン', 'settings.gitlab.page.description': 'GitLab パーソナルアクセストークンを貼り付けて接続します。セルフホストの GitLab インスタンスを使用する場合はベース URL を設定してください。', 'settings.gitlab.page.tooltip.connectAccount': 'アプリ内の Issue とマージリクエストのワークフロー用に GitLab アカウントを接続します。', @@ -1721,9 +1724,12 @@ export const settingsDict = { 'settings.gitlab.page.accessToken.placeholder': 'GitLab パーソナルアクセストークンを貼り付け', 'settings.gitlab.page.baseUrl.label': 'ベース URL(任意)', 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', - 'settings.gitlab.page.customDomains.label': 'カスタムドメイン', - 'settings.gitlab.page.customDomains.description': 'これらのドメインでホストされているリポジトリは GitLab として扱われます。', - 'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.com', + 'settings.gitlab.page.apiBaseUrl.label': 'API ベース URL', + 'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com', + 'settings.gitlab.page.apiBaseUrl.description': 'API 呼び出しに使用する既定のベース URL です。セルフホストインスタンスの場合はサーバーアドレスを指定します。例: https://gitlab.example.com。', + 'settings.gitlab.page.detectUrls.label': '検出 URL', + 'settings.gitlab.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitlab.page.detectUrls.description': 'これらのホストから SSH または HTTPS でクローンしたリポジトリは GitLab として認識されます。', 'settings.gitlab.page.actions.connect': 'GitLab に接続', 'settings.gitlab.page.actions.disconnect': '切断', 'settings.gitlab.page.actions.switch': '切り替え', @@ -1763,9 +1769,15 @@ export const settingsDict = { 'settings.gitea.page.toast.disconnectFailed': 'Gitea の切断に失敗しました', 'settings.gitea.page.toast.accountSwitched': 'Gitea アカウントを切り替えました', 'settings.gitea.page.toast.accountSwitchFailed': 'Gitea アカウントの切り替えに失敗しました', - 'settings.gitea.page.customDomains.label': 'カスタムドメイン', - 'settings.gitea.page.customDomains.description': 'これらのドメインでホストされているリポジトリは Gitea または Forgejo として扱われます。', - 'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.com', + 'settings.gitea.page.apiBaseUrl.label': 'API ベース URL', + 'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com', + 'settings.gitea.page.apiBaseUrl.description': 'API 呼び出しに使用する既定のベース URL です。セルフホストインスタンスの場合はサーバーアドレスを指定します。例: https://gitea.example.com。', + 'settings.gitea.page.detectUrls.label': '検出 URL', + 'settings.gitea.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitea.page.detectUrls.description': 'これらのホストから SSH または HTTPS でクローンしたリポジトリは Gitea または Forgejo として認識されます。', + 'settings.gitProviders.detectUrls.add': '検出 URL を追加', + 'settings.gitProviders.detectUrls.remove': '{host} を削除', + 'settings.gitProviders.detectUrls.invalid': '有効な SSH または HTTPS の URL またはホスト名を入力してください。', 'settings.notifications.page.delivery.title': '通知配信', 'settings.notifications.page.delivery.enableAria': '通知を有効化', 'settings.notifications.page.delivery.enableLabel': '通知を有効化', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 11d8b913..f20aa405 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1678,9 +1678,12 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI 대체 활성화됨', 'settings.github.page.toast.ghCliDisabled': 'gh CLI 대체 비활성화됨', 'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 설정을 업데이트하지 못했습니다', - 'settings.github.page.customDomains.label': '사용자 지정 도메인', - 'settings.github.page.customDomains.description': '이 도메인에서 호스팅되는 저장소는 GitHub로 취급됩니다.', - 'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com', + 'settings.github.page.apiBaseUrl.label': 'API 기본 URL', + 'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3', + 'settings.github.page.apiBaseUrl.description': 'API 호출에 사용되는 기본 URL입니다. GitHub Enterprise를 사용하는 경우 서버 주소를 입력하세요(예: https://github.example.com/api/v3).', + 'settings.github.page.detectUrls.label': '감지 URL', + 'settings.github.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.github.page.detectUrls.description': '이 호스트에서 SSH 또는 HTTPS로 복제한 저장소는 GitHub로 인식됩니다.', 'settings.gitlab.page.title': 'GitLab 개인 액세스 토큰', 'settings.gitlab.page.description': '연결하려면 GitLab 개인 액세스 토큰을 붙여넣으세요. 자체 호스팅 GitLab 인스턴스를 사용하는 경우 기본 URL을 설정하세요.', 'settings.gitlab.page.tooltip.connectAccount': '앱 내 이슈 및 병합 요청 워크플로에 GitLab 계정을 연결합니다.', @@ -1688,9 +1691,12 @@ export const settingsDict = { 'settings.gitlab.page.accessToken.placeholder': 'GitLab 개인 액세스 토큰을 붙여넣으세요', 'settings.gitlab.page.baseUrl.label': '기본 URL(선택 사항)', 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', - 'settings.gitlab.page.customDomains.label': '사용자 지정 도메인', - 'settings.gitlab.page.customDomains.description': '이 도메인에서 호스팅되는 저장소는 GitLab로 취급됩니다.', - 'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.com', + 'settings.gitlab.page.apiBaseUrl.label': 'API 기본 URL', + 'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com', + 'settings.gitlab.page.apiBaseUrl.description': 'API 호출에 사용되는 기본 URL입니다. 자체 호스팅 인스턴스의 경우 서버 주소를 입력하세요(예: https://gitlab.example.com).', + 'settings.gitlab.page.detectUrls.label': '감지 URL', + 'settings.gitlab.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitlab.page.detectUrls.description': '이 호스트에서 SSH 또는 HTTPS로 복제한 저장소는 GitLab으로 인식됩니다.', 'settings.gitlab.page.actions.connect': 'GitLab 연결', 'settings.gitlab.page.actions.disconnect': '연결 해제', 'settings.gitlab.page.actions.switch': '전환', @@ -1730,9 +1736,15 @@ export const settingsDict = { 'settings.gitea.page.toast.disconnectFailed': 'Gitea 연결을 해제하지 못했습니다', 'settings.gitea.page.toast.accountSwitched': 'Gitea 계정이 전환되었습니다', 'settings.gitea.page.toast.accountSwitchFailed': 'Gitea 계정을 전환하지 못했습니다', - 'settings.gitea.page.customDomains.label': '사용자 지정 도메인', - 'settings.gitea.page.customDomains.description': '이 도메인에서 호스팅되는 저장소는 Gitea 또는 Forgejo로 취급됩니다.', - 'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.com', + 'settings.gitea.page.apiBaseUrl.label': 'API 기본 URL', + 'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com', + 'settings.gitea.page.apiBaseUrl.description': 'API 호출에 사용되는 기본 URL입니다. 자체 호스팅 인스턴스의 경우 서버 주소를 입력하세요(예: https://gitea.example.com).', + 'settings.gitea.page.detectUrls.label': '감지 URL', + 'settings.gitea.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitea.page.detectUrls.description': '이 호스트에서 SSH 또는 HTTPS로 복제한 저장소는 Gitea 또는 Forgejo로 인식됩니다.', + 'settings.gitProviders.detectUrls.add': '감지 URL 추가', + 'settings.gitProviders.detectUrls.remove': '{host} 제거', + 'settings.gitProviders.detectUrls.invalid': '유효한 SSH 또는 HTTPS URL이나 호스트 이름을 입력하세요.', 'settings.notifications.page.delivery.title': '알림', 'settings.notifications.page.delivery.enableAria': '알림 활성화', 'settings.notifications.page.delivery.enableLabel': '알림 활성화', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 1a6726d7..2922ee2b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -318,9 +318,12 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'Rezerwa gh CLI włączona', 'settings.github.page.toast.ghCliDisabled': 'Rezerwa gh CLI wyłączona', 'settings.github.page.toast.ghCliUpdateFailed': 'Nie udało się zaktualizować ustawienia gh CLI', - 'settings.github.page.customDomains.label': 'Niestandardowe domeny', - 'settings.github.page.customDomains.description': 'Repozytoria hostowane na tych domenach będą traktowane jako GitHub.', - 'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com', + 'settings.github.page.apiBaseUrl.label': 'Adres URL API', + 'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3', + 'settings.github.page.apiBaseUrl.description': 'Domyślny adres URL dla wywołań API. W przypadku GitHub Enterprise podaj adres swojego serwera, np. https://github.example.com/api/v3.', + 'settings.github.page.detectUrls.label': 'Adresy URL wykrywania', + 'settings.github.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.github.page.detectUrls.description': 'Repozytoria klonowane przez SSH lub HTTPS z tych hostów są rozpoznawane jako GitHub.', 'settings.github.page.oauth.title': 'Token OAuth GitHub', 'settings.github.page.ghCli.title': 'Token CLI GitHub', 'settings.github.page.ghCli.activeDescription': 'Uwierzytelniono przez gh CLI', @@ -336,9 +339,12 @@ export const settingsDict = { 'settings.gitlab.page.accessToken.placeholder': 'Wklej swój osobisty token dostępu GitLab', 'settings.gitlab.page.baseUrl.label': 'Podstawowy URL (opcjonalnie)', 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', - 'settings.gitlab.page.customDomains.label': 'Niestandardowe domeny', - 'settings.gitlab.page.customDomains.description': 'Repozytoria hostowane na tych domenach będą traktowane jako GitLab.', - 'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.com', + 'settings.gitlab.page.apiBaseUrl.label': 'Adres URL API', + 'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com', + 'settings.gitlab.page.apiBaseUrl.description': 'Domyślny adres URL dla wywołań API. W przypadku instancji hostowanych samodzielnie podaj adres swojego serwera, np. https://gitlab.example.com.', + 'settings.gitlab.page.detectUrls.label': 'Adresy URL wykrywania', + 'settings.gitlab.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitlab.page.detectUrls.description': 'Repozytoria klonowane przez SSH lub HTTPS z tych hostów są rozpoznawane jako GitLab.', 'settings.gitlab.page.actions.connect': 'Połącz GitLab', 'settings.gitlab.page.actions.disconnect': 'Odłącz', 'settings.gitlab.page.actions.switch': 'Przełącz na', @@ -378,9 +384,15 @@ export const settingsDict = { 'settings.gitea.page.toast.disconnectFailed': 'Nie udało się odłączyć Gitea', 'settings.gitea.page.toast.accountSwitched': 'Konto Gitea zostało przełączone', 'settings.gitea.page.toast.accountSwitchFailed': 'Nie udało się przełączyć konta Gitea', - 'settings.gitea.page.customDomains.label': 'Niestandardowe domeny', - 'settings.gitea.page.customDomains.description': 'Repozytoria hostowane na tych domenach będą traktowane jako Gitea lub Forgejo.', - 'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.com', + 'settings.gitea.page.apiBaseUrl.label': 'Adres URL API', + 'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com', + 'settings.gitea.page.apiBaseUrl.description': 'Domyślny adres URL dla wywołań API. W przypadku instancji hostowanych samodzielnie podaj adres swojego serwera, np. https://gitea.example.com.', + 'settings.gitea.page.detectUrls.label': 'Adresy URL wykrywania', + 'settings.gitea.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitea.page.detectUrls.description': 'Repozytoria klonowane przez SSH lub HTTPS z tych hostów są rozpoznawane jako Gitea lub Forgejo.', + 'settings.gitProviders.detectUrls.add': 'Dodaj adres URL wykrywania', + 'settings.gitProviders.detectUrls.remove': 'Usuń {host}', + 'settings.gitProviders.detectUrls.invalid': 'Podaj prawidłowy adres URL SSH lub HTTPS albo nazwę hosta.', 'settings.magicPrompts.page.actions.resetAllOverrides': 'Zresetuj wszystkie nadpisania', 'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych', 'settings.magicPrompts.page.actions.resetting': 'Resetowanie...', 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 0bb2b76d..97fd9f3c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1678,9 +1678,12 @@ export const settingsDict = { "settings.github.page.toast.ghCliEnabled": "Alternativa gh CLI ativada", "settings.github.page.toast.ghCliDisabled": "Alternativa gh CLI desativada", "settings.github.page.toast.ghCliUpdateFailed": "Falha ao atualizar configuração do gh CLI", - "settings.github.page.customDomains.label": "Domínios personalizados", - "settings.github.page.customDomains.description": "Repositórios hospedados nesses domínios serão tratados como GitHub.", - "settings.github.page.customDomains.placeholder": "github.example.com, git.mycompany.com", + "settings.github.page.apiBaseUrl.label": "URL base da API", + "settings.github.page.apiBaseUrl.placeholder": "https://github.example.com/api/v3", + "settings.github.page.apiBaseUrl.description": "URL base padrão para chamadas de API. Para GitHub Enterprise, use o endereço do seu servidor, ex.: https://github.example.com/api/v3.", + "settings.github.page.detectUrls.label": "URLs de detecção", + "settings.github.page.detectUrls.placeholder": "ssh://git@git.example.com:2222, https://git.example.com", + "settings.github.page.detectUrls.description": "Repositórios clonados via SSH ou HTTPS desses hosts são reconhecidos como GitHub.", "settings.gitlab.page.title": "Token de acesso pessoal do GitLab", "settings.gitlab.page.description": "Cole um token de acesso pessoal do GitLab para conectar. Defina a URL base ao usar uma instância GitLab auto-hospedada.", "settings.gitlab.page.tooltip.connectAccount": "Conecte uma conta do GitLab para fluxos de trabalho de issues e merge requests no aplicativo.", @@ -1688,9 +1691,12 @@ export const settingsDict = { "settings.gitlab.page.accessToken.placeholder": "Cole seu token de acesso pessoal do GitLab", "settings.gitlab.page.baseUrl.label": "URL base (opcional)", "settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com", - "settings.gitlab.page.customDomains.label": "Domínios personalizados", - "settings.gitlab.page.customDomains.description": "Repositórios hospedados nesses domínios serão tratados como GitLab.", - "settings.gitlab.page.customDomains.placeholder": "gitlab.example.com, git.company.com", + "settings.gitlab.page.apiBaseUrl.label": "URL base da API", + "settings.gitlab.page.apiBaseUrl.placeholder": "https://gitlab.example.com", + "settings.gitlab.page.apiBaseUrl.description": "URL base padrão para chamadas de API. Para instâncias auto-hospedadas, use o endereço do seu servidor, ex.: https://gitlab.example.com.", + "settings.gitlab.page.detectUrls.label": "URLs de detecção", + "settings.gitlab.page.detectUrls.placeholder": "ssh://git@git.example.com:2222, https://git.example.com", + "settings.gitlab.page.detectUrls.description": "Repositórios clonados via SSH ou HTTPS desses hosts são reconhecidos como GitLab.", "settings.gitlab.page.actions.connect": "Conectar GitLab", "settings.gitlab.page.actions.disconnect": "Desconectar", "settings.gitlab.page.actions.switch": "Alternar para", @@ -1730,9 +1736,15 @@ export const settingsDict = { "settings.gitea.page.toast.disconnectFailed": "Falha ao desconectar o Gitea", "settings.gitea.page.toast.accountSwitched": "Conta do Gitea alterada", "settings.gitea.page.toast.accountSwitchFailed": "Falha ao alternar a conta do Gitea", - "settings.gitea.page.customDomains.label": "Domínios personalizados", - "settings.gitea.page.customDomains.description": "Repositórios hospedados nesses domínios serão tratados como Gitea ou Forgejo.", - "settings.gitea.page.customDomains.placeholder": "gitea.example.com, git.company.com", + "settings.gitea.page.apiBaseUrl.label": "URL base da API", + "settings.gitea.page.apiBaseUrl.placeholder": "https://gitea.example.com", + "settings.gitea.page.apiBaseUrl.description": "URL base padrão para chamadas de API. Para instâncias auto-hospedadas, use o endereço do seu servidor, ex.: https://gitea.example.com.", + "settings.gitea.page.detectUrls.label": "URLs de detecção", + "settings.gitea.page.detectUrls.placeholder": "ssh://git@git.example.com:2222, https://git.example.com", + "settings.gitea.page.detectUrls.description": "Repositórios clonados via SSH ou HTTPS desses hosts são reconhecidos como Gitea ou Forgejo.", + "settings.gitProviders.detectUrls.add": "Adicionar uma URL de detecção", + "settings.gitProviders.detectUrls.remove": "Remover {host}", + "settings.gitProviders.detectUrls.invalid": "Digite uma URL SSH ou HTTPS ou um nome de host válido.", "settings.notifications.page.delivery.title": "Entrega de notificações", "settings.notifications.page.delivery.enableAria": "Ativar notificações", "settings.notifications.page.delivery.enableLabel": "Ativar notificações", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 0ac266e9..3448af19 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1678,9 +1678,12 @@ export const settingsDict = { "settings.github.page.toast.ghCliEnabled": "Резервний варіант gh CLI увімкнено", "settings.github.page.toast.ghCliDisabled": "Резервний варіант gh CLI вимкнено", "settings.github.page.toast.ghCliUpdateFailed": "Не вдалося оновити налаштування gh CLI", - "settings.github.page.customDomains.label": "Власні домени", - "settings.github.page.customDomains.description": "Репозиторії, розміщені на цих доменах, вважатимуться GitHub.", - "settings.github.page.customDomains.placeholder": "github.example.com, git.mycompany.com", + "settings.github.page.apiBaseUrl.label": "Базова URL-адреса API", + "settings.github.page.apiBaseUrl.placeholder": "https://github.example.com/api/v3", + "settings.github.page.apiBaseUrl.description": "Типова базова URL-адреса для викликів API. Для GitHub Enterprise вкажіть адресу свого сервера, напр. https://github.example.com/api/v3.", + "settings.github.page.detectUrls.label": "URL-адреси виявлення", + "settings.github.page.detectUrls.placeholder": "ssh://git@git.example.com:2222, https://git.example.com", + "settings.github.page.detectUrls.description": "Репозиторії, клоновані через SSH або HTTPS із цих хостів, розпізнаються як GitHub.", "settings.gitlab.page.title": "Персональний токен доступу GitLab", "settings.gitlab.page.description": "Вставте персональний токен доступу GitLab, щоб підключитися. Вкажіть базову URL-адресу, якщо використовуєте самостійно розміщену інстанцію GitLab.", "settings.gitlab.page.tooltip.connectAccount": "Підключіть обліковий запис GitLab для роботи з issues та merge requests в застосунку.", @@ -1688,9 +1691,12 @@ export const settingsDict = { "settings.gitlab.page.accessToken.placeholder": "Вставте ваш персональний токен доступу GitLab", "settings.gitlab.page.baseUrl.label": "Базова URL-адреса (необов'язково)", "settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com", - "settings.gitlab.page.customDomains.label": "Власні домени", - "settings.gitlab.page.customDomains.description": "Репозиторії, розміщені на цих доменах, вважатимуться GitLab.", - "settings.gitlab.page.customDomains.placeholder": "gitlab.example.com, git.company.com", + "settings.gitlab.page.apiBaseUrl.label": "Базова URL-адреса API", + "settings.gitlab.page.apiBaseUrl.placeholder": "https://gitlab.example.com", + "settings.gitlab.page.apiBaseUrl.description": "Типова базова URL-адреса для викликів API. Для саморозміщених екземплярів вкажіть адресу свого сервера, напр. https://gitlab.example.com.", + "settings.gitlab.page.detectUrls.label": "URL-адреси виявлення", + "settings.gitlab.page.detectUrls.placeholder": "ssh://git@git.example.com:2222, https://git.example.com", + "settings.gitlab.page.detectUrls.description": "Репозиторії, клоновані через SSH або HTTPS із цих хостів, розпізнаються як GitLab.", "settings.gitlab.page.actions.connect": "Підключити GitLab", "settings.gitlab.page.actions.disconnect": "Відключити", "settings.gitlab.page.actions.switch": "Перемкнути на", @@ -1730,9 +1736,15 @@ export const settingsDict = { "settings.gitea.page.toast.disconnectFailed": "Не вдалося відключити Gitea", "settings.gitea.page.toast.accountSwitched": "Обліковий запис Gitea перемкнено", "settings.gitea.page.toast.accountSwitchFailed": "Не вдалося перемкнути обліковий запис Gitea", - "settings.gitea.page.customDomains.label": "Власні домени", - "settings.gitea.page.customDomains.description": "Репозиторії, розміщені на цих доменах, вважатимуться Gitea або Forgejo.", - "settings.gitea.page.customDomains.placeholder": "gitea.example.com, git.company.com", + "settings.gitea.page.apiBaseUrl.label": "Базова URL-адреса API", + "settings.gitea.page.apiBaseUrl.placeholder": "https://gitea.example.com", + "settings.gitea.page.apiBaseUrl.description": "Типова базова URL-адреса для викликів API. Для саморозміщених екземплярів вкажіть адресу свого сервера, напр. https://gitea.example.com.", + "settings.gitea.page.detectUrls.label": "URL-адреси виявлення", + "settings.gitea.page.detectUrls.placeholder": "ssh://git@git.example.com:2222, https://git.example.com", + "settings.gitea.page.detectUrls.description": "Репозиторії, клоновані через SSH або HTTPS із цих хостів, розпізнаються як Gitea або Forgejo.", + "settings.gitProviders.detectUrls.add": "Додати URL-адресу виявлення", + "settings.gitProviders.detectUrls.remove": "Видалити {host}", + "settings.gitProviders.detectUrls.invalid": "Введіть коректну URL-адресу SSH або HTTPS чи ім'я хоста.", "settings.notifications.page.delivery.title": "Доставка сповіщень", "settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення", "settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення", 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 f0ca41ad..5dfd3a47 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1678,9 +1678,12 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI 备用已启用', 'settings.github.page.toast.ghCliDisabled': 'gh CLI 备用已禁用', 'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 设置失败', - 'settings.github.page.customDomains.label': '自定义域名', - 'settings.github.page.customDomains.description': '托管在这些域名上的仓库将被视为 GitHub。', - 'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com', + 'settings.github.page.apiBaseUrl.label': 'API 基础 URL', + 'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3', + 'settings.github.page.apiBaseUrl.description': 'API 调用的默认基础 URL。GitHub Enterprise 用户可填写服务器地址,例如 https://github.example.com/api/v3。', + 'settings.github.page.detectUrls.label': '检测 URL', + 'settings.github.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.github.page.detectUrls.description': '通过这些主机以 SSH 或 HTTPS 方式克隆的仓库将被识别为 GitHub。', 'settings.gitlab.page.title': 'GitLab 个人访问令牌', 'settings.gitlab.page.description': '粘贴 GitLab 个人访问令牌以连接。使用自托管 GitLab 实例时,请设置基础 URL。', 'settings.gitlab.page.tooltip.connectAccount': '连接 GitLab 账户,以便在应用内使用 Issue 和合并请求工作流。', @@ -1688,9 +1691,12 @@ export const settingsDict = { 'settings.gitlab.page.accessToken.placeholder': '粘贴你的 GitLab 个人访问令牌', 'settings.gitlab.page.baseUrl.label': '基础 URL(可选)', 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', - 'settings.gitlab.page.customDomains.label': '自定义域名', - 'settings.gitlab.page.customDomains.description': '托管在这些域名上的仓库将被视为 GitLab。', - 'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.com', + 'settings.gitlab.page.apiBaseUrl.label': 'API 基础 URL', + 'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com', + 'settings.gitlab.page.apiBaseUrl.description': 'API 调用的默认基础 URL。自托管实例请填写服务器地址,例如 https://gitlab.example.com。', + 'settings.gitlab.page.detectUrls.label': '检测 URL', + 'settings.gitlab.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitlab.page.detectUrls.description': '通过这些主机以 SSH 或 HTTPS 方式克隆的仓库将被识别为 GitLab。', 'settings.gitlab.page.actions.connect': '连接 GitLab', 'settings.gitlab.page.actions.disconnect': '断开连接', 'settings.gitlab.page.actions.switch': '切换到', @@ -1730,9 +1736,15 @@ export const settingsDict = { 'settings.gitea.page.toast.disconnectFailed': '断开 Gitea 失败', 'settings.gitea.page.toast.accountSwitched': 'Gitea 账户已切换', 'settings.gitea.page.toast.accountSwitchFailed': '切换 Gitea 账户失败', - 'settings.gitea.page.customDomains.label': '自定义域名', - 'settings.gitea.page.customDomains.description': '托管在这些域名上的仓库将被视为 Gitea 或 Forgejo。', - 'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.com', + 'settings.gitea.page.apiBaseUrl.label': 'API 基础 URL', + 'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com', + 'settings.gitea.page.apiBaseUrl.description': 'API 调用的默认基础 URL。自托管实例请填写服务器地址,例如 https://gitea.example.com。', + 'settings.gitea.page.detectUrls.label': '检测 URL', + 'settings.gitea.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitea.page.detectUrls.description': '通过这些主机以 SSH 或 HTTPS 方式克隆的仓库将被识别为 Gitea 或 Forgejo。', + 'settings.gitProviders.detectUrls.add': '添加检测 URL', + 'settings.gitProviders.detectUrls.remove': '移除 {host}', + 'settings.gitProviders.detectUrls.invalid': '请输入有效的 SSH 或 HTTPS URL 或主机名。', 'settings.notifications.page.delivery.title': '通知投递', 'settings.notifications.page.delivery.enableAria': '启用通知', 'settings.notifications.page.delivery.enableLabel': '启用通知', 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 ab8025aa..ca783789 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1585,9 +1585,12 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI 備用已啟用', 'settings.github.page.toast.ghCliDisabled': 'gh CLI 備用已停用', 'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 設定失敗', - 'settings.github.page.customDomains.label': '自訂網域', - 'settings.github.page.customDomains.description': '託管在這些網域上的存放庫將視為 GitHub。', - 'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com', + 'settings.github.page.apiBaseUrl.label': 'API 基礎 URL', + 'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3', + 'settings.github.page.apiBaseUrl.description': 'API 呼叫的預設基礎 URL。GitHub Enterprise 使用者可填寫伺服器位址,例如 https://github.example.com/api/v3。', + 'settings.github.page.detectUrls.label': '偵測 URL', + 'settings.github.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.github.page.detectUrls.description': '透過這些主機以 SSH 或 HTTPS 方式複製的存放庫將識別為 GitHub。', 'settings.gitlab.page.title': 'GitLab 個人存取權杖', 'settings.gitlab.page.description': '貼上 GitLab 個人存取權杖以連線。使用自架 GitLab 執行個體時,請設定基礎 URL。', 'settings.gitlab.page.tooltip.connectAccount': '連線 GitLab 帳號,以在應用程式內使用 Issue 與合併請求工作流程。', @@ -1595,9 +1598,12 @@ export const settingsDict = { 'settings.gitlab.page.accessToken.placeholder': '貼上你的 GitLab 個人存取權杖', 'settings.gitlab.page.baseUrl.label': '基礎 URL(選用)', 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', - 'settings.gitlab.page.customDomains.label': '自訂網域', - 'settings.gitlab.page.customDomains.description': '託管在這些網域上的存放庫將視為 GitLab。', - 'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.com', + 'settings.gitlab.page.apiBaseUrl.label': 'API 基礎 URL', + 'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com', + 'settings.gitlab.page.apiBaseUrl.description': 'API 呼叫的預設基礎 URL。自架執行個體請填寫伺服器位址,例如 https://gitlab.example.com。', + 'settings.gitlab.page.detectUrls.label': '偵測 URL', + 'settings.gitlab.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitlab.page.detectUrls.description': '透過這些主機以 SSH 或 HTTPS 方式複製的存放庫將識別為 GitLab。', 'settings.gitlab.page.actions.connect': '連線 GitLab', 'settings.gitlab.page.actions.disconnect': '中斷連線', 'settings.gitlab.page.actions.switch': '切換到', @@ -1637,9 +1643,15 @@ export const settingsDict = { 'settings.gitea.page.toast.disconnectFailed': '中斷 Gitea 失敗', 'settings.gitea.page.toast.accountSwitched': 'Gitea 帳號已切換', 'settings.gitea.page.toast.accountSwitchFailed': '切換 Gitea 帳號失敗', - 'settings.gitea.page.customDomains.label': '自訂網域', - 'settings.gitea.page.customDomains.description': '託管在這些網域上的存放庫將視為 Gitea 或 Forgejo。', - 'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.com', + 'settings.gitea.page.apiBaseUrl.label': 'API 基礎 URL', + 'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com', + 'settings.gitea.page.apiBaseUrl.description': 'API 呼叫的預設基礎 URL。自架執行個體請填寫伺服器位址,例如 https://gitea.example.com。', + 'settings.gitea.page.detectUrls.label': '偵測 URL', + 'settings.gitea.page.detectUrls.placeholder': 'ssh://git@git.example.com:2222, https://git.example.com', + 'settings.gitea.page.detectUrls.description': '透過這些主機以 SSH 或 HTTPS 方式複製的存放庫將識別為 Gitea 或 Forgejo。', + 'settings.gitProviders.detectUrls.add': '新增偵測 URL', + 'settings.gitProviders.detectUrls.remove': '移除 {host}', + 'settings.gitProviders.detectUrls.invalid': '請輸入有效的 SSH 或 HTTPS URL 或主機名稱。', 'settings.notifications.page.delivery.title': '通知傳遞', 'settings.notifications.page.delivery.enableAria': '啟用通知', 'settings.notifications.page.delivery.enableLabel': '啟用通知', diff --git a/packages/ui/src/lib/linkedIssues.test.ts b/packages/ui/src/lib/linkedIssues.test.ts index d781f7f6..740088c5 100644 --- a/packages/ui/src/lib/linkedIssues.test.ts +++ b/packages/ui/src/lib/linkedIssues.test.ts @@ -30,7 +30,10 @@ const sessionWith = (linked: unknown): Session => ({ metadata: { openchamber: { linked_issues: linked } } } as unknown as Session); const resetStores = () => { - useGitProviderDomainsStore.setState({ domains: { github: [], gitlab: [], gitea: [] } }); + useGitProviderDomainsStore.setState({ + domains: { github: [], gitlab: [], gitea: [] }, + apiBaseUrls: { github: '', gitlab: '', gitea: '' }, + }); useGiteaAuthStore.setState({ status: null }); useGitLabAuthStore.setState({ status: null }); }; @@ -274,6 +277,14 @@ describe('deriveLinkedIssueProvider', () => { expect(deriveLinkedIssueProvider('https://git.example.com/owner/repo/pulls/5')).toBe('gitea'); }); + test('derives a self-hosted github host from the configured api base url', () => { + useGitProviderDomainsStore.setState({ + domains: { github: [], gitlab: [], gitea: [] }, + apiBaseUrls: { github: 'https://github.example.com/api/v3', gitlab: '', gitea: '' }, + }); + expect(deriveLinkedIssueProvider('https://github.example.com/owner/repo/issues/1')).toBe('github'); + }); + test('derives a self-hosted gitlab host from the domains store', () => { useGitProviderDomainsStore.setState({ domains: { github: [], gitlab: ['gitlab.example.com'], gitea: [] }, diff --git a/packages/ui/src/lib/linkedIssues.ts b/packages/ui/src/lib/linkedIssues.ts index 49ad8c32..12c0a7e8 100644 --- a/packages/ui/src/lib/linkedIssues.ts +++ b/packages/ui/src/lib/linkedIssues.ts @@ -1,9 +1,10 @@ import type { Session } from '@opencode-ai/sdk/v2'; import type { ForgeProviderKind } from '@/lib/forge/types'; import { parseGitHost } from '@/lib/gitHost'; +import { buildGitProviderHosts } from '@/lib/gitProvider'; import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; -import { normalizeProviderDomain, useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore'; +import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore'; import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewMetadata'; /** @@ -119,10 +120,11 @@ const getIssueUrlHost = (url: string): string | null => { /** * Which forge a link url belongs to. Well-known hosts resolve without any * state; self-hosted hosts resolve through the connected auth accounts' base - * urls and the user-configured domains, in precedence order github -> gitlab -> - * gitea. Returns null when nothing is known — never a guess, so github-branded - * UI is not offered for an unknown host. github.com is matched first so it can - * never be mistaken for a gitea host. + * urls, the configured api base urls and the user-configured domains, in + * precedence order github -> gitlab -> gitea. Returns null when nothing is + * known — never a guess, so github-branded UI is not offered for an unknown + * host. github.com is matched first so it can never be mistaken for a gitea + * host. */ export const deriveLinkedIssueProvider = (url: string): ForgeProviderKind | null => { const host = getIssueUrlHost(url); @@ -132,23 +134,18 @@ export const deriveLinkedIssueProvider = (url: string): ForgeProviderKind | null if (host === 'gitlab.com') return 'gitlab'; if (host === 'gitea.com') return 'gitea'; - const giteaAccountHosts = (useGiteaAuthStore.getState().status?.accounts ?? []) - .map((account) => normalizeProviderDomain(account.baseUrl)) - .filter((candidate): candidate is string => candidate !== null); - if (giteaAccountHosts.includes(host)) return 'gitea'; + const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState(); + const gitlabAccounts = useGitLabAuthStore.getState().status?.accounts; + const giteaAccounts = useGiteaAuthStore.getState().status?.accounts; + // github.com is covered by the built-in match above; the configured GitHub + // api base host (GitHub Enterprise) is auto-added here via + // `buildGitProviderHosts`, so a GitHub link on a self-hosted instance + // resolves without any separate chip or account entry. + const hosts = buildGitProviderHosts({ domains, apiBaseUrls, gitlabAccounts, giteaAccounts }); - const gitlabAccountHosts = (useGitLabAuthStore.getState().status?.accounts ?? []) - .map((account) => normalizeProviderDomain(account.baseUrl)) - .filter((candidate): candidate is string => candidate !== null); - if (gitlabAccountHosts.includes(host)) return 'gitlab'; - - // GitHub accounts carry no base URL (they are github.com-only, which the - // built-in match above already handles), so there is no host to consult. - - const { domains } = useGitProviderDomainsStore.getState(); - if (domains.github.includes(host)) return 'github'; - if (domains.gitlab.includes(host)) return 'gitlab'; - if (domains.gitea.includes(host)) return 'gitea'; + if (hosts.github.includes(host)) return 'github'; + if (hosts.gitlab.includes(host)) return 'gitlab'; + if (hosts.gitea.includes(host)) return 'gitea'; return null; }; diff --git a/packages/ui/src/lib/linkedSessionMatches.test.ts b/packages/ui/src/lib/linkedSessionMatches.test.ts index 8e87ed94..d13129aa 100644 --- a/packages/ui/src/lib/linkedSessionMatches.test.ts +++ b/packages/ui/src/lib/linkedSessionMatches.test.ts @@ -11,7 +11,10 @@ import { } from './linkedSessionMatches'; const resetStores = () => { - useGitProviderDomainsStore.setState({ domains: { github: [], gitlab: [], gitea: [] } }); + useGitProviderDomainsStore.setState({ + domains: { github: [], gitlab: [], gitea: [] }, + apiBaseUrls: { github: '', gitlab: '', gitea: '' }, + }); useGiteaAuthStore.setState({ status: null }); useGitLabAuthStore.setState({ status: null }); }; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index c02fc88d..10edbdf8 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -3,6 +3,11 @@ import { sanitizeWorkStatusHiddenSections } from '@/components/chat/work-status/ import { createProjectIdFromPath } from '@/lib/projectId'; import { useUIStore } from '@/stores/useUIStore'; import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions'; +import { + useGitProviderDomainsStore, + normalizeApiBaseUrl, + normalizeDomainList, +} from '@/stores/useGitProviderDomainsStore'; import { DEFAULT_FOLLOW_UP_BEHAVIOR, isFollowUpBehavior, @@ -323,6 +328,39 @@ const sanitizeStringArray = (value: unknown): string[] | undefined => { return Array.from(new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))); }; +const GIT_PROVIDER_NAMES = ['github', 'gitlab', 'gitea'] as const; + +const sanitizeGitProviders = (value: unknown): DesktopSettings['gitProviders'] | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const source = value as Record; + const result: NonNullable = {}; + let hasAny = false; + for (const provider of GIT_PROVIDER_NAMES) { + const entry = source[provider]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + const record = entry as Record; + const providerConfig: { apiBaseUrl?: string; detectUrls?: string[] } = {}; + const apiBaseUrl = normalizeApiBaseUrl(record.apiBaseUrl); + if (apiBaseUrl) { + providerConfig.apiBaseUrl = apiBaseUrl; + } + // detectUrls are bare hosts (scheme/port/path stripped) via parseGitHost. + const detectUrls = normalizeDomainList(record.detectUrls); + if (detectUrls.length > 0) { + providerConfig.detectUrls = detectUrls; + } + if (Object.keys(providerConfig).length > 0) { + result[provider] = providerConfig; + hasAny = true; + } + } + return hasAny ? result : undefined; +}; + const sanitizeRecentEfforts = (value: unknown): Record | undefined => { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const result: Record = {}; @@ -1617,6 +1655,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { result.sttLanguage = candidate.sttLanguage.trim(); } + const gitProviders = sanitizeGitProviders(candidate.gitProviders); + if (gitProviders) { + result.gitProviders = gitProviders; + } + return result; }; @@ -1794,6 +1837,16 @@ export const syncDesktopSettings = async (): Promise => { } catch (error) { console.warn('applyDesktopUiPreferences failed:', error); } + try { + // Server `gitProviders` settings are authoritative for provider api base + // urls and detect hosts; hydrate the domains store (localStorage stays a + // cache for the one-time migration of pre-feature custom domains). + if (settings.gitProviders !== undefined) { + useGitProviderDomainsStore.getState().hydrateFromServer(settings.gitProviders); + } + } catch (error) { + console.warn('applyGitProviderSettings failed:', error); + } const migrationPatch: Partial = {}; if (shouldPersistCraftGoalMigration) { if (authoritativeSettings.draftStarters) { diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 3b7a4b2c..7486dbb4 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -501,6 +501,48 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.gitlab.page.actions.connect', keywords: ['gitlab', 'account', 'pat', 'personal access token', 'issues', 'merge requests'], }, + { + id: 'git.github-api-base-url', + page: 'git', + titleKey: 'settings.github.page.apiBaseUrl.label', + descriptionKey: 'settings.github.page.apiBaseUrl.description', + keywords: ['github', 'api', 'base url', 'enterprise', 'self-hosted', 'server'], + }, + { + id: 'git.github-detect-urls', + page: 'git', + titleKey: 'settings.github.page.detectUrls.label', + descriptionKey: 'settings.github.page.detectUrls.description', + keywords: ['github', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'], + }, + { + id: 'git.gitlab-api-base-url', + page: 'git', + titleKey: 'settings.gitlab.page.apiBaseUrl.label', + descriptionKey: 'settings.gitlab.page.apiBaseUrl.description', + keywords: ['gitlab', 'api', 'base url', 'self-hosted', 'server', 'instance'], + }, + { + id: 'git.gitlab-detect-urls', + page: 'git', + titleKey: 'settings.gitlab.page.detectUrls.label', + descriptionKey: 'settings.gitlab.page.detectUrls.description', + keywords: ['gitlab', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'], + }, + { + id: 'git.gitea-api-base-url', + page: 'git', + titleKey: 'settings.gitea.page.apiBaseUrl.label', + descriptionKey: 'settings.gitea.page.apiBaseUrl.description', + keywords: ['gitea', 'forgejo', 'api', 'base url', 'self-hosted', 'server', 'instance'], + }, + { + id: 'git.gitea-detect-urls', + page: 'git', + titleKey: 'settings.gitea.page.detectUrls.label', + descriptionKey: 'settings.gitea.page.detectUrls.description', + keywords: ['gitea', 'forgejo', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'], + }, { id: 'git.identities', page: 'git', diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 4a7dfd2e..256c7e5a 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -12,6 +12,7 @@ import { useSessionUIStore } from "@/sync/session-ui-store"; import { useSelectionStore } from "@/sync/selection-store"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; import { updateDesktopSettings } from "@/lib/persistence"; +import { useGitProviderDomainsStore } from "@/stores/useGitProviderDomainsStore"; import { useDirectoryStore } from "@/stores/useDirectoryStore"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"; @@ -65,6 +66,8 @@ interface OpenChamberDefaults { sttModel?: string; sttLocalModel?: string; sttLanguage?: string; + /** Raw `gitProviders` section of server settings (per-provider apiBaseUrl/detectUrls). */ + gitProviders?: unknown; } const fetchOpenChamberDefaults = async (): Promise => { @@ -103,6 +106,7 @@ const fetchOpenChamberDefaults = async (): Promise => { const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined; const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined; const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined; + const gitProviders = data?.gitProviders; return finish('runtime-settings', { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, @@ -118,6 +122,7 @@ const fetchOpenChamberDefaults = async (): Promise => { sttModel, sttLocalModel, sttLanguage, + gitProviders, }); } } catch { @@ -149,6 +154,7 @@ const fetchOpenChamberDefaults = async (): Promise => { const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined; const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined; const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined; + const gitProviders = data?.gitProviders; return finish('settings-route', { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, @@ -164,6 +170,7 @@ const fetchOpenChamberDefaults = async (): Promise => { sttModel, sttLocalModel, sttLanguage, + gitProviders, }); } catch (error) { markStartupTrace('config.defaults:error', { error: error instanceof Error ? error.message : String(error) }); @@ -2008,6 +2015,18 @@ export const useConfigStore = create()( const safeAgents = Array.isArray(agents) ? agents : []; + // Seed the git provider domains store from the server + // `gitProviders` settings (authoritative); the runtime + // settings path or the fetch path above provided them. + // Failure here must not break the agent load. + if (openChamberDefaults.gitProviders !== undefined) { + try { + useGitProviderDomainsStore.getState().hydrateFromServer(openChamberDefaults.gitProviders); + } catch { + // Ignore — non-authoritative state stays as-is. + } + } + const providerLoad = _inFlightProviders.get(directoryKey); if (providerLoad) { markStartupTrace('loadAgents:awaitProviders', { directoryKey, source }); diff --git a/packages/ui/src/stores/useGitProviderDomainsStore.test.ts b/packages/ui/src/stores/useGitProviderDomainsStore.test.ts index 45a666d3..c5ea58f8 100644 --- a/packages/ui/src/stores/useGitProviderDomainsStore.test.ts +++ b/packages/ui/src/stores/useGitProviderDomainsStore.test.ts @@ -4,6 +4,7 @@ import { useGitProviderDomainsStore } from './useGitProviderDomainsStore'; const resetDomains = () => { useGitProviderDomainsStore.setState({ domains: { github: [], gitlab: [], gitea: [] }, + apiBaseUrls: { github: '', gitlab: '', gitea: '' }, }); }; @@ -36,4 +37,101 @@ describe('useGitProviderDomainsStore', () => { ]); expect(useGitProviderDomainsStore.getState().domains.gitlab).toEqual(['gitlab.example.com']); }); + + test('setApiBaseUrl defaults to an empty string', () => { + resetDomains(); + const store = useGitProviderDomainsStore.getState(); + store.setApiBaseUrl('github', ''); + store.setApiBaseUrl('gitlab', ' '); + expect(useGitProviderDomainsStore.getState().apiBaseUrls).toEqual({ + github: '', + gitlab: '', + gitea: '', + }); + }); + + test('setApiBaseUrl strips trailing slashes but keeps scheme and path', () => { + resetDomains(); + const store = useGitProviderDomainsStore.getState(); + store.setApiBaseUrl('github', ' https://github.example.com/api/v3/ '); + store.setApiBaseUrl('gitlab', 'https://gitlab.example.com'); + store.setApiBaseUrl('gitea', 'gitea.example.com/path//'); + expect(useGitProviderDomainsStore.getState().apiBaseUrls).toEqual({ + github: 'https://github.example.com/api/v3', + gitlab: 'https://gitlab.example.com', + gitea: 'gitea.example.com/path', + }); + }); + + test('hydrateFromServer sets apiBaseUrls and domains from the server config', () => { + resetDomains(); + useGitProviderDomainsStore.getState().hydrateFromServer({ + github: { apiBaseUrl: 'https://github.example.com/api/v3/', detectUrls: ['github.example.com', 'ssh://git@gh.example.com/x'] }, + gitlab: { detectUrls: [] }, + }); + const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState(); + expect(apiBaseUrls).toEqual({ + github: 'https://github.example.com/api/v3', + gitlab: '', + gitea: '', + }); + expect(domains.github).toEqual(['github.example.com', 'gh.example.com']); + expect(domains.gitlab).toEqual([]); + expect(domains.gitea).toEqual([]); + }); + + test('hydrateFromServer ignores malformed config', () => { + resetDomains(); + useGitProviderDomainsStore.getState().hydrateFromServer({ + github: 'not an object', + gitlab: { apiBaseUrl: 42, detectUrls: 'nope' }, + }); + const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState(); + expect(apiBaseUrls).toEqual({ github: '', gitlab: '', gitea: '' }); + expect(domains).toEqual({ github: [], gitlab: [], gitea: [] }); + }); + + test('hydrateFromServer with no config keeps cached domains and clears nothing', () => { + resetDomains(); + useGitProviderDomainsStore.getState().setDomains('github', ['github.example.com']); + useGitProviderDomainsStore.getState().setApiBaseUrl('github', 'https://github.example.com/api/v3'); + useGitProviderDomainsStore.getState().hydrateFromServer(undefined); + const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState(); + expect(domains.github).toEqual(['github.example.com']); + expect(apiBaseUrls.github).toBe(''); + }); + + test('server detect urls win over cached domains', () => { + resetDomains(); + useGitProviderDomainsStore.getState().setDomains('gitea', ['codeberg.org']); + useGitProviderDomainsStore.getState().hydrateFromServer({ + gitea: { detectUrls: ['gitea.example.com'] }, + }); + expect(useGitProviderDomainsStore.getState().domains.gitea).toEqual(['gitea.example.com']); + }); + + test('migration keeps cached domains when the server lacks detect urls', () => { + resetDomains(); + useGitProviderDomainsStore.getState().setDomains('gitea', ['codeberg.org']); + useGitProviderDomainsStore.getState().hydrateFromServer({ + gitea: { apiBaseUrl: 'https://gitea.example.com/api/v1' }, + }); + const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState(); + // Server provided an api base but no detect urls: cached domains survive. + expect(domains.gitea).toEqual(['codeberg.org']); + expect(apiBaseUrls.gitea).toBe('https://gitea.example.com/api/v1'); + }); + + test('migration applies per provider: configured providers win, cached ones survive', () => { + resetDomains(); + useGitProviderDomainsStore.getState().setDomains('github', ['gh.example.com']); + useGitProviderDomainsStore.getState().setDomains('gitlab', ['gitlab.example.com']); + useGitProviderDomainsStore.getState().hydrateFromServer({ + github: { detectUrls: ['github.example.com'] }, + }); + const { domains } = useGitProviderDomainsStore.getState(); + expect(domains.github).toEqual(['github.example.com']); + expect(domains.gitlab).toEqual(['gitlab.example.com']); + expect(domains.gitea).toEqual([]); + }); }); diff --git a/packages/ui/src/stores/useGitProviderDomainsStore.ts b/packages/ui/src/stores/useGitProviderDomainsStore.ts index 325a461f..f177feb6 100644 --- a/packages/ui/src/stores/useGitProviderDomainsStore.ts +++ b/packages/ui/src/stores/useGitProviderDomainsStore.ts @@ -15,9 +15,27 @@ export type GitProviderDomains = { gitea: string[]; }; +/** + * Per-provider configured API base URLs, as entered in server settings + * (`gitProviders..apiBaseUrl`). Normalized: empty string when unset, + * otherwise trimmed with any trailing slashes stripped but scheme and path kept + * (e.g. `https://github.example.com/api/v3`). + */ +export type GitProviderApiBaseUrls = { + github: string; + gitlab: string; + gitea: string; +}; + const DOMAINS_STORAGE_KEY = 'openchamber.git-provider-domains'; const EMPTY_DOMAINS: GitProviderDomains = { github: [], gitlab: [], gitea: [] }; +const EMPTY_API_BASE_URLS: GitProviderApiBaseUrls = { github: '', gitlab: '', gitea: '' }; + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)); + +const GIT_PROVIDERS = ['github', 'gitlab', 'gitea'] as const; /** * Normalize a raw user-supplied domain into a bare hostname. Accepts plain @@ -27,7 +45,11 @@ const EMPTY_DOMAINS: GitProviderDomains = { github: [], gitlab: [], gitea: [] }; */ export const normalizeProviderDomain = (raw: string): string | null => parseGitHost(raw); -const normalizeDomainList = (entries: unknown): string[] => { +/** + * Normalize arbitrary input into a list of bare, deduped hostnames. Non-array + * input yields `[]`; each entry runs through `normalizeProviderDomain`. + */ +export const normalizeDomainList = (entries: unknown): string[] => { if (!Array.isArray(entries)) { return []; } @@ -43,15 +65,55 @@ const normalizeDomainList = (entries: unknown): string[] => { return result; }; +/** + * Normalize a configured API base URL: empty (or non-string) input collapses to + * `''`; otherwise trim and strip trailing slashes while keeping scheme + path. + */ +export const normalizeApiBaseUrl = (raw: unknown): string => { + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!value) { + return ''; + } + return value.replace(/\/+$/, ''); +}; + +/** + * Normalize a server `gitProviders` config into per-provider `{ apiBaseUrl, + * detectUrls }` pairs. Unknown or malformed entries are dropped; provider keys + * outside the known set are ignored. + */ +const normalizeGitProvidersConfig = (config: unknown): { + apiBaseUrls: GitProviderApiBaseUrls; + domains: GitProviderDomains; +} => { + const apiBaseUrls: GitProviderApiBaseUrls = { ...EMPTY_API_BASE_URLS }; + const domains: GitProviderDomains = { github: [], gitlab: [], gitea: [] }; + if (!isRecord(config)) { + return { apiBaseUrls, domains }; + } + for (const provider of GIT_PROVIDERS) { + const entry = config[provider]; + if (!isRecord(entry)) continue; + apiBaseUrls[provider] = normalizeApiBaseUrl(entry.apiBaseUrl); + domains[provider] = normalizeDomainList(entry.detectUrls); + } + return { apiBaseUrls, domains }; +}; + type GitProviderDomainsStore = { domains: GitProviderDomains; + apiBaseUrls: GitProviderApiBaseUrls; setDomains: (provider: GitProviderName, domains: string[]) => void; + setApiBaseUrl: (provider: GitProviderName, url: string) => void; + /** Apply the server's `gitProviders` settings, keeping the server authoritative. */ + hydrateFromServer: (gitProvidersConfig?: unknown) => void; }; export const useGitProviderDomainsStore = create()( persist( (set, get) => ({ domains: EMPTY_DOMAINS, + apiBaseUrls: EMPTY_API_BASE_URLS, setDomains: (provider, domains) => { set({ domains: { @@ -60,21 +122,53 @@ export const useGitProviderDomainsStore = create()( }, } as Partial); }, + setApiBaseUrl: (provider, url) => { + set({ + apiBaseUrls: { + ...get().apiBaseUrls, + [provider]: normalizeApiBaseUrl(url), + }, + } as Partial); + }, + hydrateFromServer: (gitProvidersConfig) => { + const { apiBaseUrls, domains } = normalizeGitProvidersConfig(gitProvidersConfig); + const currentDomains = get().domains; + // One-time migration: when the server has no detect urls for a provider, + // keep whatever was previously persisted locally (the localStorage cache, + // hydrated into `domains`) so existing users' custom domains are not lost + // on upgrade. Whenever the server carries detect urls it wins outright, + // and the persist middleware keeps mirroring state back to localStorage + // as a cache — the server stays authoritative on later hydrates. + for (const provider of GIT_PROVIDERS) { + if (domains[provider].length === 0 && currentDomains[provider].length > 0) { + domains[provider] = currentDomains[provider]; + } + } + set({ domains, apiBaseUrls } as Partial); + }, }), { name: DOMAINS_STORAGE_KEY, storage: createDeferredSafeJSONStorage(), - partialize: (state) => ({ domains: state.domains }), + partialize: (state) => ({ domains: state.domains, apiBaseUrls: state.apiBaseUrls }), merge: (persistedState, currentState) => { - const persisted = (persistedState as { domains?: Partial } | null)?.domains; + const persisted = (persistedState as { + domains?: Partial; + apiBaseUrls?: Partial; + } | null); return { ...currentState, - // Missing or malformed entries collapse to empty arrays so the full - // three-provider shape is always produced after hydration. + // Missing or malformed entries collapse to the canonical shape so the + // full three-provider shape is always produced after hydration. domains: { - github: normalizeDomainList(persisted?.github), - gitlab: normalizeDomainList(persisted?.gitlab), - gitea: normalizeDomainList(persisted?.gitea), + github: normalizeDomainList(persisted?.domains?.github), + gitlab: normalizeDomainList(persisted?.domains?.gitlab), + gitea: normalizeDomainList(persisted?.domains?.gitea), + }, + apiBaseUrls: { + github: normalizeApiBaseUrl(persisted?.apiBaseUrls?.github), + gitlab: normalizeApiBaseUrl(persisted?.apiBaseUrls?.gitlab), + gitea: normalizeApiBaseUrl(persisted?.apiBaseUrls?.gitea), }, }; }, diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts index 78ba0751..9310ad60 100644 --- a/packages/vscode/src/bridge-settings-runtime.ts +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -234,6 +234,42 @@ const stripDerived = (source: Record): Record return next; }; +const GIT_PROVIDER_KEYS = ['github', 'gitlab', 'gitea']; + +// Light shape check for the `gitProviders` settings section coming from the +// webview: reject non-objects outright, coerce detectUrls to a string array and +// apiBaseUrl to a trimmed string, and drop empty provider entries. Keeps a +// malformed payload from landing on disk (the shared settings file). +const sanitizeGitProviders = (input: unknown): Record | undefined => { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return undefined; + } + const source = input as Record; + const result: Record = {}; + for (const key of GIT_PROVIDER_KEYS) { + const entry = source[key]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + const record = entry as Record; + const apiBaseUrl = typeof record.apiBaseUrl === 'string' ? record.apiBaseUrl.trim() : ''; + const detectUrls = Array.isArray(record.detectUrls) + ? record.detectUrls.filter((value): value is string => typeof value === 'string') + : []; + const provider: Record = {}; + if (apiBaseUrl) { + provider.apiBaseUrl = apiBaseUrl; + } + if (detectUrls.length > 0) { + provider.detectUrls = detectUrls; + } + if (Object.keys(provider).length > 0) { + result[key] = provider; + } + } + return Object.keys(result).length > 0 ? result : undefined; +}; + let eagerMigrationAttempted = false; // Read the merged persisted settings: shared file is canonical (synced with @@ -326,6 +362,15 @@ export const persistSettings = async (changes: Record, ctx?: Br } } + if ('gitProviders' in restChanges) { + const gitProviders = sanitizeGitProviders(restChanges.gitProviders); + if (gitProviders) { + restChanges.gitProviders = gitProviders; + } else { + delete restChanges.gitProviders; + } + } + if (typeof restChanges.opencodeBinary === 'string') { restChanges.opencodeBinary = restChanges.opencodeBinary.trim(); } diff --git a/packages/web/server/lib/git-providers/DOCUMENTATION.md b/packages/web/server/lib/git-providers/DOCUMENTATION.md new file mode 100644 index 00000000..78f1ac99 --- /dev/null +++ b/packages/web/server/lib/git-providers/DOCUMENTATION.md @@ -0,0 +1,48 @@ +# Git Providers Configuration Module + +## Purpose + +- This module owns the per-provider git hosting configuration (`gitProviders` in the user settings file): API base URLs and provider-detection hostnames for GitHub, GitLab, and Gitea. +- It is the single source of truth for the effective provider defaults consumed by `packages/web/server/lib/{github,gitlab,gitea}` and is validated end-to-end through the settings GET/PUT routes (the `gitProviders` key round-trips via `sanitizeSettingsUpdate` in `packages/web/server/lib/opencode/settings-helpers.js`). + +## Entrypoints + +- `packages/web/server/lib/git-providers/config.js`: the single module file, exporting the helpers directly. + +## Public exports + +- `GIT_PROVIDER_DEFAULTS`: `{ github: 'https://api.github.com', gitlab: 'https://gitlab.com', gitea: null }`. Built-in defaults are **not persisted**; they are applied at read time by getters. +- `normalizeBaseUrl(raw)`: normalize an API base URL (add `https://` when the scheme is missing, strip trailing slashes, preserve subpaths like `/gitlab`), `null` for empty/unparseable input. +- `normalizeDetectionHost(raw)`: extract the bare lowercase hostname from any git remote/URL form (`https://`, `ssh://`, scp-like `git@host:path`, IPv6); mirrors `packages/ui/src/lib/gitHost.ts` `parseGitHost`. +- `sanitizeGitProviders(payload)`: validate/normalize the `gitProviders` shape — only `github|gitlab|gitea` keys survive; `apiBaseUrl` via `normalizeBaseUrl`, `detectUrls` deduped bare hostnames; empty/absent values dropped; returns `undefined` when nothing valid remains. +- `readGitProvidersConfig()`: read the `gitProviders` section from `settings.json` (`OPENCHAMBER_DATA_DIR` env override, else `~/.config/openchamber`); never throws, returns `{}` on missing/invalid data. +- `getProviderApiBaseUrl(provider)`: configured value -> `GIT_PROVIDER_DEFAULTS[provider]` -> `null` (gitea). +- `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`. + +## Settings shape + +`~/.config/openchamber/settings.json`: + +```json +"gitProviders": { + "github": { "apiBaseUrl": "https://github.example.com/api/v3", "detectUrls": ["github.example.com"] }, + "gitlab": { "apiBaseUrl": "https://gitlab.example.com", "detectUrls": [] }, + "gitea": { "apiBaseUrl": "", "detectUrls": ["gitea.example.com"] } +} +``` + +- `apiBaseUrl`: API base URL; the per-account baseUrl (gitlab/gitea accounts) still wins when set; this is the default/fallback plus connect-form prefill. +- `detectUrls`: SSH/HTTPS URLs normalized to bare hostnames for provider autodetection (client-side; the server only persists/validates them). +- The whole `gitProviders` key is omitted when empty. + +## Consumers + +- `packages/web/server/lib/github/octokit.js`, `device-flow.js`, `routes.js`, `repo/index.js`, `pr-status.js`, `repo/fork-detection.js`: GitHub Enterprise support (Octokit `baseUrl`, device-flow web origin, remote parsing, fallback URLs). +- `packages/web/server/lib/gitlab/auth.js`, `client.js`, `routes.js`: effective default base URL. +- `packages/web/server/lib/gitea/auth.js`, `routes.js`: connect-form default / status `defaultBaseUrl`. +- `packages/web/server/lib/opencode/settings-helpers.js`: `gitProviders` persistence whitelist. + +## Notes for contributors + +- Readers must never throw: `readGitProvidersConfig` and `githubWebOriginFromApiBase` fail closed. +- No new dependencies. diff --git a/packages/web/server/lib/git-providers/config.js b/packages/web/server/lib/git-providers/config.js new file mode 100644 index 00000000..a5fce6f3 --- /dev/null +++ b/packages/web/server/lib/git-providers/config.js @@ -0,0 +1,203 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'); + +const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json'); + +// Built-in defaults are applied at read time by getters; they are never +// persisted (sanitizeGitProviders only stores user-provided overrides). +const GIT_PROVIDER_KEYS = ['github', 'gitlab', 'gitea']; +export const GIT_PROVIDER_DEFAULTS = { + github: 'https://api.github.com', + gitlab: 'https://gitlab.com', + gitea: null, +}; + +/** + * Normalize a user-provided API base URL. Adds `https://` when no scheme is + * present, strips a trailing slash, preserves any subpath (e.g. `/gitlab`), + * and returns null for anything unparseable or empty. + */ +export function normalizeBaseUrl(raw) { + if (typeof raw !== 'string') { + return null; + } + let value = raw.trim(); + if (!value) { + return null; + } + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value)) { + value = `https://${value}`; + } + let parsed; + try { + parsed = new URL(value); + } catch { + return null; + } + if (!parsed.hostname) { + return null; + } + parsed.hash = ''; + parsed.search = ''; + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + return parsed.href.replace(/\/+$/, ''); +} + +const normalizeHost = (host) => + String(host || '').replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, ''); + +/** + * Extract the bare lowercase hostname from any git remote / URL form: + * `https://host/...`, `ssh://git@host/...`, scp-like `git@host:path`, + * `host:path`, and bracketed or unbracketed IPv6. Returns null for empty or + * unparseable input. + */ +export function normalizeDetectionHost(raw) { + if (typeof raw !== 'string') { + return null; + } + const value = raw.trim(); + if (!value) { + return null; + } + + // scp-like form: [user@]host:path — never applies once a scheme is present. + if (!value.includes('://')) { + const authority = value.slice(value.lastIndexOf('@') + 1); + // Bracketed IPv6, e.g. `[2001:db8::1]` or `[2001:db8::1]:owner/repo.git`. + if (authority.startsWith('[')) { + const close = authority.indexOf(']'); + if (close > 0 && authority.slice(1, close).includes(':')) { + return normalizeHost(authority.slice(1, close)); + } + // Malformed brackets fall through to URL parsing, which rejects them. + } else { + const colon = authority.indexOf(':'); + if (colon > 0) { + const candidate = authority.slice(0, colon); + // A single-segment pre-colon value without a dot is not a host — the + // guard rejects Windows paths like `C:\foo`. Hosts with a numeric + // port (`localhost:3000`) still resolve via the URL branch. + if (!candidate.includes('/') && candidate.includes('.')) { + return normalizeHost(candidate); + } + } + // Unbracketed IPv6 (e.g. `2001:db8::1`): parse as a bracketed host. + if (authority.includes(':') && !authority.includes('/') && authority.length > 2) { + try { + return normalizeHost(new URL(`ssh://[${authority}]`).hostname); + } catch { + // Not IPv6; fall through to generic URL parsing. + } + } + } + } + + try { + const parsed = new URL(value.includes('://') ? value : `ssh://${value}`); + return normalizeHost(parsed.hostname); + } catch { + return null; + } +} + +const sanitizeDetectionHosts = (value) => { + if (!Array.isArray(value)) { + return []; + } + const seen = new Set(); + const hosts = []; + for (const raw of value) { + const host = normalizeDetectionHost(raw); + if (!host || seen.has(host)) continue; + seen.add(host); + hosts.push(host); + } + return hosts; +}; + +/** + * Validate and normalize a `gitProviders` settings value. Only the known + * provider keys (github|gitlab|gitea) survive; per provider, `apiBaseUrl` is + * normalized via normalizeBaseUrl and `detectUrls` becomes a deduped array of + * bare hostnames. Empty/absent values are dropped. Returns undefined when + * nothing valid remains, otherwise the normalized partial object. + */ +export function sanitizeGitProviders(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return undefined; + } + const result = {}; + for (const provider of GIT_PROVIDER_KEYS) { + const entry = payload[provider]; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const normalized = {}; + if (entry.apiBaseUrl !== undefined && entry.apiBaseUrl !== null) { + const baseUrl = normalizeBaseUrl(entry.apiBaseUrl); + if (baseUrl) normalized.apiBaseUrl = baseUrl; + } + if (entry.detectUrls !== undefined && entry.detectUrls !== null) { + const hosts = sanitizeDetectionHosts(entry.detectUrls); + if (hosts.length > 0) normalized.detectUrls = hosts; + } + if (Object.keys(normalized).length > 0) { + result[provider] = normalized; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Read the `gitProviders` section of the user settings file + * (`~/.config/openchamber/settings.json`, overridable via + * OPENCHAMBER_DATA_DIR). Never throws; returns {} on missing/invalid data. + */ +export function readGitProvidersConfig() { + try { + if (fs.existsSync(SETTINGS_FILE)) { + const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) || {}; + return sanitizeGitProviders(parsed.gitProviders) ?? {}; + } + } catch { + // ignore + } + return {}; +} + +/** + * Effective API base URL for a provider: the configured settings.json value if + * present, else the built-in default (null for gitea, which has none). + */ +export function getProviderApiBaseUrl(provider) { + return readGitProvidersConfig()[provider]?.apiBaseUrl || GIT_PROVIDER_DEFAULTS[provider] || null; +} + +/** + * Derive the GitHub web origin from an API base URL. The public API host + * (`https://api.github.com`) maps to `https://github.com`; an Enterprise API + * base (`https://host/api/v3` or `https://host/api`) maps to `https://host` + * (trailing `/api[/v3]` path segments are stripped, so subpath installs like + * `https://host/ghe/api/v3` keep their prefix). Anything else yields the + * origin of the URL. Never throws; falls back to `https://github.com`. + */ +export function githubWebOriginFromApiBase(apiBase) { + try { + const url = new URL(apiBase); + if (!url.hostname) { + return 'https://github.com'; + } + if (url.hostname === 'api.github.com') { + return 'https://github.com'; + } + const pathname = url.pathname.replace(/\/+$/, ''); + const stripped = pathname.replace(/\/api\/v3$/, '').replace(/\/api$/, ''); + return `${url.protocol}//${url.host}${stripped}`; + } catch { + return 'https://github.com'; + } +} diff --git a/packages/web/server/lib/git-providers/config.test.js b/packages/web/server/lib/git-providers/config.test.js new file mode 100644 index 00000000..dd5cff9f --- /dev/null +++ b/packages/web/server/lib/git-providers/config.test.js @@ -0,0 +1,176 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, describe, expect, test } from 'vitest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-providers-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +const { + GIT_PROVIDER_DEFAULTS, + normalizeBaseUrl, + normalizeDetectionHost, + sanitizeGitProviders, + readGitProvidersConfig, + getProviderApiBaseUrl, + githubWebOriginFromApiBase, +} = await import('./config.js'); + +const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json'); + +afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); +}); + +afterEach(() => { + if (fs.existsSync(SETTINGS_FILE)) { + fs.unlinkSync(SETTINGS_FILE); + } +}); + +describe('normalizeBaseUrl', () => { + test('adds https scheme when missing', () => { + expect(normalizeBaseUrl('github.example.com')).toBe('https://github.example.com'); + expect(normalizeBaseUrl('gitlab.example.com/gitlab')).toBe('https://gitlab.example.com/gitlab'); + }); + + test('strips trailing slashes but preserves subpaths', () => { + expect(normalizeBaseUrl('https://github.example.com/api/v3/')).toBe('https://github.example.com/api/v3'); + expect(normalizeBaseUrl('https://gitlab.example.com/')).toBe('https://gitlab.example.com'); + expect(normalizeBaseUrl('https://gitlab.example.com/gitlab/')).toBe('https://gitlab.example.com/gitlab'); + }); + + test('keeps an explicit non-https scheme', () => { + expect(normalizeBaseUrl('http://localhost:8080')).toBe('http://localhost:8080'); + }); + + test('returns null for empty or unparseable input', () => { + expect(normalizeBaseUrl('')).toBeNull(); + expect(normalizeBaseUrl(' ')).toBeNull(); + expect(normalizeBaseUrl('not a url')).toBeNull(); + expect(normalizeBaseUrl(null)).toBeNull(); + expect(normalizeBaseUrl(undefined)).toBeNull(); + expect(normalizeBaseUrl(42)).toBeNull(); + }); +}); + +describe('normalizeDetectionHost', () => { + test('extracts the host from https remotes', () => { + expect(normalizeDetectionHost('https://Github.Example.com/owner/repo.git')).toBe('github.example.com'); + expect(normalizeDetectionHost('https://github.com/owner/repo')).toBe('github.com'); + }); + + test('extracts the host from scp-like and ssh remotes', () => { + expect(normalizeDetectionHost('git@github.example.com:owner/repo.git')).toBe('github.example.com'); + expect(normalizeDetectionHost('ssh://git@github.example.com/owner/repo.git')).toBe('github.example.com'); + expect(normalizeDetectionHost('github.example.com:owner/repo.git')).toBe('github.example.com'); + }); + + test('handles ports, user info, and IPv6', () => { + expect(normalizeDetectionHost('https://github.example.com:8443/owner/repo')).toBe('github.example.com'); + expect(normalizeDetectionHost('ssh://user@host.example.com/owner/repo')).toBe('host.example.com'); + expect(normalizeDetectionHost('[2001:db8::1]:owner/repo.git')).toBe('2001:db8::1'); + expect(normalizeDetectionHost('2001:db8::1')).toBe('2001:db8::1'); + }); + + test('returns null for empty or unparseable input', () => { + expect(normalizeDetectionHost('')).toBeNull(); + expect(normalizeDetectionHost(null)).toBeNull(); + expect(normalizeDetectionHost(42)).toBeNull(); + expect(normalizeDetectionHost('C:\\foo')).toBeNull(); + }); +}); + +describe('sanitizeGitProviders', () => { + test('normalizes a valid payload', () => { + expect(sanitizeGitProviders({ + github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['https://github.example.com/owner/repo.git'] }, + gitlab: { apiBaseUrl: 'gitlab.example.com', detectUrls: [] }, + gitea: { apiBaseUrl: '', detectUrls: ['gitea.example.com'] }, + })).toEqual({ + github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] }, + gitlab: { apiBaseUrl: 'https://gitlab.example.com' }, + gitea: { detectUrls: ['gitea.example.com'] }, + }); + }); + + test('dedupes and lowercases detectUrls', () => { + expect(sanitizeGitProviders({ + github: { detectUrls: ['GitHub.Example.com', 'https://github.example.com/x', 'other.example.com', 'other.example.com'] }, + })).toEqual({ + github: { detectUrls: ['github.example.com', 'other.example.com'] }, + }); + }); + + test('drops malformed or empty entries', () => { + expect(sanitizeGitProviders({ github: { apiBaseUrl: ' ' } })).toBeUndefined(); + expect(sanitizeGitProviders({ github: { detectUrls: 'not-an-array' } })).toBeUndefined(); + expect(sanitizeGitProviders({ unknown: { apiBaseUrl: 'https://x.example.com' } })).toBeUndefined(); + expect(sanitizeGitProviders('not-an-object')).toBeUndefined(); + expect(sanitizeGitProviders(null)).toBeUndefined(); + expect(sanitizeGitProviders([])).toBeUndefined(); + }); + + test('ignores unknown provider keys', () => { + expect(sanitizeGitProviders({ + github: { apiBaseUrl: 'https://github.example.com' }, + bitbucket: { apiBaseUrl: 'https://bitbucket.example.com' }, + })).toEqual({ + github: { apiBaseUrl: 'https://github.example.com' }, + }); + }); +}); + +describe('readGitProvidersConfig / getProviderApiBaseUrl', () => { + test('returns {} / defaults when no settings file exists', () => { + expect(readGitProvidersConfig()).toEqual({}); + expect(getProviderApiBaseUrl('github')).toBe('https://api.github.com'); + expect(getProviderApiBaseUrl('gitlab')).toBe('https://gitlab.com'); + expect(getProviderApiBaseUrl('gitea')).toBeNull(); + }); + + test('reads the configured values from settings.json', () => { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ + gitProviders: { + github: { apiBaseUrl: 'https://github.example.com/api/v3' }, + gitlab: { apiBaseUrl: 'https://gitlab.example.com' }, + }, + })); + expect(readGitProvidersConfig()).toEqual({ + github: { apiBaseUrl: 'https://github.example.com/api/v3' }, + gitlab: { apiBaseUrl: 'https://gitlab.example.com' }, + }); + expect(getProviderApiBaseUrl('github')).toBe('https://github.example.com/api/v3'); + expect(getProviderApiBaseUrl('gitlab')).toBe('https://gitlab.example.com'); + expect(getProviderApiBaseUrl('gitea')).toBeNull(); + }); + + test('never throws on a malformed settings file', () => { + fs.writeFileSync(SETTINGS_FILE, '{not-json'); + expect(readGitProvidersConfig()).toEqual({}); + expect(getProviderApiBaseUrl('github')).toBe(GIT_PROVIDER_DEFAULTS.github); + }); +}); + +describe('githubWebOriginFromApiBase', () => { + test('maps the public api host to github.com', () => { + expect(githubWebOriginFromApiBase('https://api.github.com')).toBe('https://github.com'); + }); + + test('maps enterprise api bases to the host', () => { + expect(githubWebOriginFromApiBase('https://github.example.com/api/v3')).toBe('https://github.example.com'); + expect(githubWebOriginFromApiBase('https://github.example.com/api')).toBe('https://github.example.com'); + }); + + test('keeps subpath prefixes and plain origins', () => { + expect(githubWebOriginFromApiBase('https://github.example.com/ghe/api/v3')).toBe('https://github.example.com/ghe'); + expect(githubWebOriginFromApiBase('https://github.example.com')).toBe('https://github.example.com'); + expect(githubWebOriginFromApiBase('https://github.example.com:8443/api/v3')).toBe('https://github.example.com:8443'); + }); + + test('falls back for invalid input and never throws', () => { + expect(githubWebOriginFromApiBase('')).toBe('https://github.com'); + expect(githubWebOriginFromApiBase(null)).toBe('https://github.com'); + expect(githubWebOriginFromApiBase('not a url')).toBe('https://github.com'); + }); +}); diff --git a/packages/web/server/lib/gitea/DOCUMENTATION.md b/packages/web/server/lib/gitea/DOCUMENTATION.md index dbf04495..9dd083c8 100644 --- a/packages/web/server/lib/gitea/DOCUMENTATION.md +++ b/packages/web/server/lib/gitea/DOCUMENTATION.md @@ -29,7 +29,8 @@ - `clearGiteaAuth()`: remove the current account. - `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input. - `GITEA_AUTH_FILE`: auth file path. -- There is **no default base URL**: Gitea/Forgejo is self-hosted, so the instance URL is always user-provided. +- `getGiteaDefaultBaseUrl()`: effective default base URL — configured `gitProviders.gitea.apiBaseUrl` from `settings.json`, else `null`. Used to prefill the connect form and as the connect/status default; stored accounts still require an explicit base URL (there is no invented host). +- There is **no built-in default base URL**: Gitea/Forgejo is self-hosted, so the instance URL is always user-provided. ### Client (`client.js`) @@ -46,7 +47,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 only source — there is no default instance. Stored entries without a usable base URL are dropped. +- Base URL resolution: the caller-supplied `baseUrl` (normalized) is the primary source — there is no built-in default instance. A configured `settings.json` `gitProviders.gitea.apiBaseUrl` acts as the connect-form default/fallback. Stored entries without a usable base URL are dropped. - 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. @@ -91,8 +92,8 @@ | Method | Path | Shape | |---|---|---| -| GET | `/api/gitea/auth/status` | `{ connected, user?, accounts[] }` | -| POST | `/api/gitea/auth/connect` | body `{ accessToken, baseUrl }` -> `{ connected, user, accounts }`; `400` for missing/invalid token or base URL | +| GET | `/api/gitea/auth/status` | `{ connected, user?, accounts[], defaultBaseUrl? }` (`defaultBaseUrl` present when connected; the configured `gitProviders.gitea.apiBaseUrl`, else `null`) | +| POST | `/api/gitea/auth/connect` | body `{ accessToken, baseUrl? }` -> `{ connected, user, accounts }`; `400` for missing/invalid token; `400` when neither a valid `baseUrl` nor a configured default exists | | POST | `/api/gitea/auth/activate` | body `{ accountId }` -> `{ connected, user, accounts }`; `404` unknown account | | DELETE | `/api/gitea/auth` | `{ removed }` | | GET | `/api/gitea/me` | `{ username, id, name, avatarUrl, webUrl, email? }`; `401` when not connected | diff --git a/packages/web/server/lib/gitea/auth.js b/packages/web/server/lib/gitea/auth.js index 1e31272f..95ce190c 100644 --- a/packages/web/server/lib/gitea/auth.js +++ b/packages/web/server/lib/gitea/auth.js @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; +import { getProviderApiBaseUrl } from '../git-providers/config.js'; const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) @@ -9,9 +10,16 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR const STORAGE_DIR = OPENCHAMBER_DATA_DIR; const STORAGE_FILE = path.join(STORAGE_DIR, 'gitea-auth.json'); -// Gitea/Forgejo are self-hosted — there is deliberately NO default base URL. -// The instance URL is always user-provided (see `normalizeBaseUrl`); auth.js -// never invents a host for a stored account. +// Gitea/Forgejo are self-hosted — there is deliberately NO built-in default +// base URL. The instance URL is always user-provided (see `normalizeBaseUrl`); +// auth.js never invents a host for a stored account. A configured +// settings.json gitProviders.gitea.apiBaseUrl can act as the default for the +// connect form, but stored accounts still require an explicit baseUrl. + +/** Effective default Gitea base URL: configured settings.json value, else null (no built-in default). */ +export function getGiteaDefaultBaseUrl() { + return getProviderApiBaseUrl('gitea'); +} function ensureStorageDir() { if (!fs.existsSync(STORAGE_DIR)) { diff --git a/packages/web/server/lib/gitea/index.js b/packages/web/server/lib/gitea/index.js index d60071e3..ab5f82f4 100644 --- a/packages/web/server/lib/gitea/index.js +++ b/packages/web/server/lib/gitea/index.js @@ -6,6 +6,7 @@ export { clearGiteaAuth, normalizeBaseUrl, GITEA_AUTH_FILE, + getGiteaDefaultBaseUrl, } from './auth.js'; export { diff --git a/packages/web/server/lib/gitea/routes.js b/packages/web/server/lib/gitea/routes.js index af5220e8..58af7032 100644 --- a/packages/web/server/lib/gitea/routes.js +++ b/packages/web/server/lib/gitea/routes.js @@ -237,7 +237,7 @@ export function registerGiteaRoutes(app, options = {}) { app.get('/api/gitea/auth/status', async (_req, res) => { try { - const { getGiteaAuth, getGiteaAuthAccounts, clearGiteaAuth } = await getGiteaLibraries(); + const { getGiteaAuth, getGiteaAuthAccounts, clearGiteaAuth, getGiteaDefaultBaseUrl } = await getGiteaLibraries(); const auth = getGiteaAuth(); const accounts = getGiteaAuthAccounts(); if (!auth?.accessToken) { @@ -261,6 +261,7 @@ export function registerGiteaRoutes(app, options = {}) { connected: true, ...(user ? { user } : {}), accounts, + defaultBaseUrl: getGiteaDefaultBaseUrl(), }); } catch (error) { console.error('Failed to get Gitea auth status:', error); @@ -275,8 +276,8 @@ export function registerGiteaRoutes(app, options = {}) { return res.status(400).json({ error: 'accessToken is required' }); } - const { normalizeBaseUrl, setGiteaAuth, getGiteaAuthAccounts } = await getGiteaLibraries(); - const baseUrl = normalizeBaseUrl(req.body?.baseUrl); + const { normalizeBaseUrl, setGiteaAuth, getGiteaAuthAccounts, getGiteaDefaultBaseUrl } = await getGiteaLibraries(); + const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || getGiteaDefaultBaseUrl(); if (!baseUrl) { return res.status(400).json({ error: 'baseUrl is required and must be a valid URL' }); } diff --git a/packages/web/server/lib/gitea/routes.test.js b/packages/web/server/lib/gitea/routes.test.js index 43af9d26..150344c0 100644 --- a/packages/web/server/lib/gitea/routes.test.js +++ b/packages/web/server/lib/gitea/routes.test.js @@ -1,7 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import express from 'express'; import request from 'supertest'; @@ -201,7 +201,6 @@ describe('Gitea auth routes', () => { test('me returns the connected user', async () => { setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser }); scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); - const app = createApp(); const response = await request(app).get('/api/gitea/me'); expect(response.status).toBe(200); @@ -216,6 +215,47 @@ describe('Gitea auth routes', () => { }); }); +describe('Gitea configured default base URL', () => { + const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json'); + + afterEach(() => { + if (fs.existsSync(SETTINGS_FILE)) { + fs.unlinkSync(SETTINGS_FILE); + } + }); + + test('auth/status reports the configured defaultBaseUrl', async () => { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ + gitProviders: { gitea: { apiBaseUrl: 'https://gitea.example.com' } }, + })); + setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'https://gitea.example.com', user: aliceUser }); + scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); + + const app = createApp(); + const response = await request(app).get('/api/gitea/auth/status'); + expect(response.status).toBe(200); + expect(response.body.connected).toBe(true); + expect(response.body.defaultBaseUrl).toBe('https://gitea.example.com'); + }); + + test('auth/connect falls back to the configured default base URL when baseUrl is blank', async () => { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ + gitProviders: { gitea: { apiBaseUrl: 'https://gitea.example.com' } }, + })); + 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(200); + expect(response.body).toMatchObject({ connected: true }); + expect(fetchMock.mock.calls[0][0]).toBe('https://gitea.example.com/api/v1/user'); + expect(getGiteaAuth()?.baseUrl).toBe('https://gitea.example.com'); + }); +}); + describe('Gitea data routes', () => { beforeEach(() => { resetAuthFile(); diff --git a/packages/web/server/lib/github/DOCUMENTATION.md b/packages/web/server/lib/github/DOCUMENTATION.md index ae5a0e51..135d4860 100644 --- a/packages/web/server/lib/github/DOCUMENTATION.md +++ b/packages/web/server/lib/github/DOCUMENTATION.md @@ -32,18 +32,26 @@ ### Device flow -- `startDeviceFlow({ clientId, scope })`: request device code. -- `exchangeDeviceCode({ clientId, deviceCode })`: poll for access token. +- `startDeviceFlow({ clientId, scope, webOrigin? })`: request device code. +- `exchangeDeviceCode({ clientId, deviceCode, webOrigin? })`: poll for access token. ### Octokit - `getOctokitOrNull()`: current Octokit or `null`. +- `createOctokit(token, baseUrl?)`: Octokit factory; the optional `baseUrl` (GitHub Enterprise API base) is passed to the Octokit constructor. ### Repo -- `parseGitHubRemoteUrl(raw)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`. +- `parseGitHubRemoteUrl(raw, options?)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`; `options.host` / `options.webOrigin` default to `github.com` / `https://github.com` and are used for self-hosted (Enterprise) remotes. - `resolveGitHubRepoFromDirectory(directory, remoteName)`: resolve GitHub repo from a local git remote. +## Git provider configuration + +Per-provider settings come from `~/.config/openchamber/settings.json` under `gitProviders` (validated in `packages/web/server/lib/git-providers/config.js`, persisted via the settings GET/PUT routes). GitHub resolution: + +- API base URL: configured `gitProviders.github.apiBaseUrl` -> default `https://api.github.com`. The configured value drives the Octokit `baseUrl` (`getOctokitOrNull`, device-flow account activation). +- Device flow web origin: derived from the API base via `githubWebOriginFromApiBase` — the public host maps to `https://github.com`; an Enterprise base (`https://host/api/v3` or `https://host/api`) maps to `https://host`. + ## Auth storage and config - Auth storage: `~/.config/openchamber/github-auth.json` diff --git a/packages/web/server/lib/github/device-flow.js b/packages/web/server/lib/github/device-flow.js index 335d5af1..b8372dda 100644 --- a/packages/web/server/lib/github/device-flow.js +++ b/packages/web/server/lib/github/device-flow.js @@ -1,6 +1,5 @@ -const DEVICE_CODE_URL = 'https://github.com/login/device/code'; -const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'; const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; +const DEFAULT_WEB_ORIGIN = 'https://github.com'; const encodeForm = (params) => { const body = new URLSearchParams(); @@ -32,16 +31,19 @@ async function postForm(url, params) { return payload; } -export async function startDeviceFlow({ clientId, scope }) { - return postForm(DEVICE_CODE_URL, { +const deviceCodeUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/device/code`; +const accessTokenUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/oauth/access_token`; + +export async function startDeviceFlow({ clientId, scope, webOrigin }) { + return postForm(deviceCodeUrl(webOrigin), { client_id: clientId, scope, }); } -export async function exchangeDeviceCode({ clientId, deviceCode }) { +export async function exchangeDeviceCode({ clientId, deviceCode, webOrigin }) { // GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states. - const payload = await postForm(ACCESS_TOKEN_URL, { + const payload = await postForm(accessTokenUrl(webOrigin), { client_id: clientId, device_code: deviceCode, grant_type: DEVICE_GRANT_TYPE, diff --git a/packages/web/server/lib/github/index.js b/packages/web/server/lib/github/index.js index 893a26e5..69ead33a 100644 --- a/packages/web/server/lib/github/index.js +++ b/packages/web/server/lib/github/index.js @@ -28,3 +28,8 @@ export { parseGitHubRemoteUrl, resolveGitHubRepoFromDirectory, } from './repo/index.js'; + +export { + getProviderApiBaseUrl, + githubWebOriginFromApiBase, +} from '../git-providers/config.js'; diff --git a/packages/web/server/lib/github/octokit.js b/packages/web/server/lib/github/octokit.js index 0eb36164..2c4d686e 100644 --- a/packages/web/server/lib/github/octokit.js +++ b/packages/web/server/lib/github/octokit.js @@ -1,6 +1,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'; // Per-request timeout for every GitHub call. Octokit v22 uses native fetch, // which has no built-in timeout — without this, a stuck connection hangs until @@ -69,8 +70,12 @@ const createConditionalFetch = (token) => async (url, options = {}) => { }; /** Create an Octokit instance with per-request timeout + ETag revalidation. */ -export function createOctokit(token) { - return new Octokit({ auth: token, request: { fetch: createConditionalFetch(token) } }); +export function createOctokit(token, baseUrl) { + return new Octokit({ + auth: token, + ...(baseUrl ? { baseUrl } : {}), + request: { fetch: createConditionalFetch(token) }, + }); } export function getOctokitOrNull() { @@ -80,5 +85,5 @@ export function getOctokitOrNull() { if (!token) { return null; } - return createOctokit(token); + return createOctokit(token, getProviderApiBaseUrl('github')); } diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index 8e88122c..e0989a15 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -2,6 +2,9 @@ import { stat } from 'node:fs/promises'; import { getRemotes, getStatus } from '../git/index.js'; import { resolveGitHubRepoFromDirectory } from './repo/index.js'; import { noteIfGitHubRateLimit } from './rate-limit.js'; +import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../git-providers/config.js'; + +const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github')); const directoryExists = async (dir) => { if (!dir) return false; @@ -295,7 +298,7 @@ const expandRepoNetwork = async (octokit, candidates) => { pushCandidate({ owner: parent.owner.login, repo: parent.name, - url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`, + url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`, }, candidate.remoteName, candidate.priority + 0.1); } @@ -304,7 +307,7 @@ const expandRepoNetwork = async (octokit, candidates) => { pushCandidate({ owner: source.owner.login, repo: source.name, - url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`, + url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`, }, candidate.remoteName, candidate.priority + 0.2); } } diff --git a/packages/web/server/lib/github/repo/fork-detection.js b/packages/web/server/lib/github/repo/fork-detection.js index cd24d547..d3ec963b 100644 --- a/packages/web/server/lib/github/repo/fork-detection.js +++ b/packages/web/server/lib/github/repo/fork-detection.js @@ -1,4 +1,7 @@ import { resolveGitHubRepoFromDirectory } from './index.js'; +import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js'; + +const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github')); const REPO_METADATA_TTL_MS = 5 * 60_000; const REPO_METADATA_CACHE_MAX_ENTRIES = 200; @@ -75,7 +78,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi result.push({ owner: parent.owner.login, repo: parent.name, - url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`, + url: parent.html_url || `${getGitHubWebOrigin()}/${parent.owner.login}/${parent.name}`, source: 'upstream', }); } @@ -89,7 +92,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi result.push({ owner: source.owner.login, repo: source.name, - url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`, + url: source.html_url || `${getGitHubWebOrigin()}/${source.owner.login}/${source.name}`, source: 'upstream', }); } diff --git a/packages/web/server/lib/github/repo/index.js b/packages/web/server/lib/github/repo/index.js index 6d0babd8..bc6fd68a 100644 --- a/packages/web/server/lib/github/repo/index.js +++ b/packages/web/server/lib/github/repo/index.js @@ -1,6 +1,17 @@ import { getRemoteUrl } from '../../git/index.js'; +import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../../git-providers/config.js'; -export const parseGitHubRemoteUrl = (raw) => { +const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github')); + +const webHostFromOrigin = (webOrigin) => { + try { + return new URL(webOrigin).hostname || 'github.com'; + } catch { + return 'github.com'; + } +}; + +export const parseGitHubRemoteUrl = (raw, { host = 'github.com', webOrigin = 'https://github.com' } = {}) => { if (typeof raw !== 'string') { return null; } @@ -10,34 +21,36 @@ export const parseGitHubRemoteUrl = (raw) => { } // git@github.com:OWNER/REPO.git - if (value.startsWith('git@github.com:')) { - const rest = value.slice('git@github.com:'.length); + const scpPrefix = `git@${host}:`; + if (value.startsWith(scpPrefix)) { + const rest = value.slice(scpPrefix.length); const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest; const [owner, repo] = cleaned.split('/'); if (!owner || !repo) return null; - return { owner, repo, url: `https://github.com/${owner}/${repo}` }; + return { owner, repo, url: `${webOrigin}/${owner}/${repo}` }; } // ssh://git@github.com/OWNER/REPO.git - if (value.startsWith('ssh://git@github.com/')) { - const rest = value.slice('ssh://git@github.com/'.length); + const sshPrefix = `ssh://git@${host}/`; + if (value.startsWith(sshPrefix)) { + const rest = value.slice(sshPrefix.length); const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest; const [owner, repo] = cleaned.split('/'); if (!owner || !repo) return null; - return { owner, repo, url: `https://github.com/${owner}/${repo}` }; + return { owner, repo, url: `${webOrigin}/${owner}/${repo}` }; } // https://github.com/OWNER/REPO(.git) try { const url = new URL(value); - if (url.hostname !== 'github.com') { + if (url.hostname !== host) { return null; } const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path; const [owner, repo] = cleaned.split('/'); if (!owner || !repo) return null; - return { owner, repo, url: `https://github.com/${owner}/${repo}` }; + return { owner, repo, url: `${webOrigin}/${owner}/${repo}` }; } catch { return null; } @@ -48,8 +61,10 @@ export async function resolveGitHubRepoFromDirectory(directory, remoteName = 'or if (!remoteUrl) { return { repo: null, remoteUrl: null }; } + const webOrigin = getGitHubWebOrigin(); + const host = webHostFromOrigin(webOrigin); return { - repo: parseGitHubRemoteUrl(remoteUrl), + repo: parseGitHubRemoteUrl(remoteUrl, { host, webOrigin }), remoteUrl, }; } diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index e1111216..62ea10a8 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -321,7 +321,7 @@ export function registerGitHubRoutes(app) { app.post('/api/github/auth/start', async (_req, res) => { try { - const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries(); + const { getGitHubClientId, getGitHubScopes, startDeviceFlow, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries(); const clientId = getGitHubClientId(); if (!clientId) { return res.status(400).json({ @@ -330,10 +330,12 @@ export function registerGitHubRoutes(app) { } const scope = getGitHubScopes(); + const webOrigin = githubWebOriginFromApiBase(getProviderApiBaseUrl('github')); const payload = await startDeviceFlow({ clientId, scope, + webOrigin, }); return res.json({ @@ -353,7 +355,7 @@ export function registerGitHubRoutes(app) { app.post('/api/github/auth/complete', async (req, res) => { try { - const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); + const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries(); const clientId = getGitHubClientId(); if (!clientId) { return res.status(400).json({ @@ -369,7 +371,10 @@ export function registerGitHubRoutes(app) { return res.status(400).json({ error: 'deviceCode is required' }); } - const payload = await exchangeDeviceCode({ clientId, deviceCode }); + const apiBase = getProviderApiBaseUrl('github'); + const webOrigin = githubWebOriginFromApiBase(apiBase); + + const payload = await exchangeDeviceCode({ clientId, deviceCode, webOrigin }); if (payload?.error) { return res.json({ @@ -385,7 +390,7 @@ export function registerGitHubRoutes(app) { } const { createOctokit } = await import('./octokit.js'); - const octokit = createOctokit(accessToken); + const octokit = createOctokit(accessToken, apiBase); const user = await getGitHubUserSummary(octokit); setGitHubAuth({ @@ -1773,6 +1778,9 @@ export function registerGitHubRoutes(app) { const { resolveGitHubRepoFromDirectory } = await import('./index.js'); const { resolveRepoNetwork } = await import('./repo/fork-detection.js'); + const { getProviderApiBaseUrl } = await getGitHubLibraries(); + const apiBase = getProviderApiBaseUrl('github'); + const repoNetwork = await resolveRepoNetwork(octokit, directory); const { repo } = await resolveGitHubRepoFromDirectory(directory); if (!repo) { @@ -1817,7 +1825,7 @@ export function registerGitHubRoutes(app) { const issues = items .filter((item) => !item?.pull_request) .map((item) => { - const repoFullName = (item.repository_url || '').replace('https://api.github.com/repos/', ''); + const repoFullName = (item.repository_url || '').replace(`${apiBase}/repos/`, ''); const matched = reposToQuery.find((r) => `${r.owner}/${r.repo}` === repoFullName); return mapIssueSummary(item, matched || reposToQuery[0]); }); diff --git a/packages/web/server/lib/gitlab/DOCUMENTATION.md b/packages/web/server/lib/gitlab/DOCUMENTATION.md index 035a593f..9016946d 100644 --- a/packages/web/server/lib/gitlab/DOCUMENTATION.md +++ b/packages/web/server/lib/gitlab/DOCUMENTATION.md @@ -28,7 +28,8 @@ - `clearGitLabAuth()`: remove the current account. - `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input. - `GITLAB_AUTH_FILE`: auth file path. -- `DEFAULT_GITLAB_BASE_URL`: `https://gitlab.com`. +- `DEFAULT_GITLAB_BASE_URL`: `https://gitlab.com` (compatibility constant). +- `getGitLabDefaultBaseUrl()`: effective default base URL — configured `gitProviders.gitlab.apiBaseUrl` from `settings.json` if present, else `https://gitlab.com`. Used for stored-account fallback and the auth status/connect `defaultBaseUrl` fields. ### Client (`client.js`) @@ -45,7 +46,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) -> `DEFAULT_GITLAB_BASE_URL`. +- Base URL resolution: caller-supplied `baseUrl` (normalized) -> effective default via `getGitLabDefaultBaseUrl()` (configured `settings.json` `gitProviders.gitlab.apiBaseUrl`, else `https://gitlab.com`). - 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/auth.js b/packages/web/server/lib/gitlab/auth.js index e9a84408..ab15c68a 100644 --- a/packages/web/server/lib/gitlab/auth.js +++ b/packages/web/server/lib/gitlab/auth.js @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; +import { getProviderApiBaseUrl } from '../git-providers/config.js'; const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) @@ -9,8 +10,15 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR const STORAGE_DIR = OPENCHAMBER_DATA_DIR; const STORAGE_FILE = path.join(STORAGE_DIR, 'gitlab-auth.json'); +// Kept for compatibility with existing consumers/tests; the effective fallback +// lives in the git-providers defaults (GIT_PROVIDER_DEFAULTS.gitlab). export const DEFAULT_GITLAB_BASE_URL = 'https://gitlab.com'; +/** Effective default GitLab base URL: configured settings.json value, else the built-in default. */ +export function getGitLabDefaultBaseUrl() { + return getProviderApiBaseUrl('gitlab'); +} + function ensureStorageDir() { if (!fs.existsSync(STORAGE_DIR)) { fs.mkdirSync(STORAGE_DIR, { recursive: true }); @@ -119,7 +127,7 @@ function normalizeAuthEntry(entry) { if (!entry || typeof entry !== 'object') return null; const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : ''; if (!accessToken) return null; - const baseUrl = normalizeBaseUrl(entry.baseUrl) || DEFAULT_GITLAB_BASE_URL; + const baseUrl = normalizeBaseUrl(entry.baseUrl) || getGitLabDefaultBaseUrl(); const username = typeof entry.username === 'string' ? entry.username : ''; const accountId = resolveAccountId({ @@ -218,7 +226,7 @@ export function getGitLabAuthAccounts() { avatarUrl: entry.avatarUrl || null, webUrl: entry.webUrl || null, }, - baseUrl: entry.baseUrl || DEFAULT_GITLAB_BASE_URL, + baseUrl: entry.baseUrl || getGitLabDefaultBaseUrl(), current: Boolean(entry.current), })); } @@ -227,7 +235,7 @@ export function setGitLabAuth({ accessToken, baseUrl, user }) { if (!accessToken || typeof accessToken !== 'string') { throw new Error('accessToken is required'); } - const normalizedBaseUrl = normalizeBaseUrl(baseUrl) || DEFAULT_GITLAB_BASE_URL; + const normalizedBaseUrl = normalizeBaseUrl(baseUrl) || getGitLabDefaultBaseUrl(); const normalizedUser = user && typeof user === 'object' ? { username: typeof user.username === 'string' ? user.username : undefined, diff --git a/packages/web/server/lib/gitlab/auth.test.js b/packages/web/server/lib/gitlab/auth.test.js index 3f6af618..5d86b171 100644 --- a/packages/web/server/lib/gitlab/auth.test.js +++ b/packages/web/server/lib/gitlab/auth.test.js @@ -15,8 +15,11 @@ const { normalizeBaseUrl, GITLAB_AUTH_FILE, DEFAULT_GITLAB_BASE_URL, + getGitLabDefaultBaseUrl, } = await import('./auth.js'); +const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json'); + afterAll(() => { fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); }); @@ -25,6 +28,9 @@ afterEach(() => { if (fs.existsSync(GITLAB_AUTH_FILE)) { fs.unlinkSync(GITLAB_AUTH_FILE); } + if (fs.existsSync(SETTINGS_FILE)) { + fs.unlinkSync(SETTINGS_FILE); + } }); const aliceUser = { @@ -135,6 +141,19 @@ describe('multi-account switching', () => { }); }); +describe('getGitLabDefaultBaseUrl', () => { + test('falls back to the built-in default when nothing is configured', () => { + expect(getGitLabDefaultBaseUrl()).toBe(DEFAULT_GITLAB_BASE_URL); + }); + + test('returns the configured settings.json default when present', () => { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ + gitProviders: { gitlab: { apiBaseUrl: 'https://gitlab.example.com' } }, + })); + expect(getGitLabDefaultBaseUrl()).toBe('https://gitlab.example.com'); + }); +}); + describe('clearGitLabAuth', () => { test('removes the current account and deletes the file when empty', () => { setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); diff --git a/packages/web/server/lib/gitlab/client.js b/packages/web/server/lib/gitlab/client.js index eb419970..42c487f0 100644 --- a/packages/web/server/lib/gitlab/client.js +++ b/packages/web/server/lib/gitlab/client.js @@ -1,4 +1,4 @@ -import { getGitLabAuth, DEFAULT_GITLAB_BASE_URL } from './auth.js'; +import { getGitLabAuth, getGitLabDefaultBaseUrl } from './auth.js'; // Per-request timeout for every GitLab call. GitLab REST can hang under load // (especially self-hosted instances); bounding each request lets the caller @@ -113,7 +113,7 @@ export function isGitLabRateLimited() { // ---- Response helpers ---- const joinApiUrl = (baseUrl, path) => { - const base = String(baseUrl || DEFAULT_GITLAB_BASE_URL).replace(/\/+$/, ''); + const base = String(baseUrl || getGitLabDefaultBaseUrl()).replace(/\/+$/, ''); const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : ''; return `${base}/api/v4${p}`; }; @@ -305,7 +305,7 @@ export function createGitLabClient({ token, baseUrl }) { function normalizeBaseForClient(baseUrl) { if (typeof baseUrl !== 'string' || !baseUrl.trim()) { - return DEFAULT_GITLAB_BASE_URL; + return getGitLabDefaultBaseUrl(); } return baseUrl.trim().replace(/\/+$/, ''); } diff --git a/packages/web/server/lib/gitlab/index.js b/packages/web/server/lib/gitlab/index.js index 95b2deb2..ba8a92b5 100644 --- a/packages/web/server/lib/gitlab/index.js +++ b/packages/web/server/lib/gitlab/index.js @@ -7,6 +7,7 @@ export { normalizeBaseUrl, GITLAB_AUTH_FILE, DEFAULT_GITLAB_BASE_URL, + getGitLabDefaultBaseUrl, } from './auth.js'; export { diff --git a/packages/web/server/lib/gitlab/routes.js b/packages/web/server/lib/gitlab/routes.js index 858d6a4c..18d061c2 100644 --- a/packages/web/server/lib/gitlab/routes.js +++ b/packages/web/server/lib/gitlab/routes.js @@ -314,11 +314,12 @@ export function registerGitLabRoutes(app, options = {}) { app.get('/api/gitlab/auth/status', async (_req, res) => { try { - const { getGitLabAuth, getGitLabAuthAccounts, clearGitLabAuth, DEFAULT_GITLAB_BASE_URL } = await getGitLabLibraries(); + const { getGitLabAuth, getGitLabAuthAccounts, clearGitLabAuth, getGitLabDefaultBaseUrl } = await getGitLabLibraries(); const auth = getGitLabAuth(); const accounts = getGitLabAuthAccounts(); + const defaultBaseUrl = getGitLabDefaultBaseUrl(); if (!auth?.accessToken) { - return res.json({ connected: false, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL }); + return res.json({ connected: false, accounts, defaultBaseUrl }); } const client = await getClient(); @@ -327,7 +328,7 @@ export function registerGitLabRoutes(app, options = {}) { const resp = await client.user(); if (resp.status === 401 || resp.status === 403) { clearGitLabAuth(); - return res.json({ connected: false, accounts: getGitLabAuthAccounts(), defaultBaseUrl: DEFAULT_GITLAB_BASE_URL }); + return res.json({ connected: false, accounts: getGitLabAuthAccounts(), defaultBaseUrl }); } if (resp.status === 200 && resp.data) { user = mapGitLabUser(resp.data); @@ -338,7 +339,7 @@ export function registerGitLabRoutes(app, options = {}) { connected: true, ...(user ? { user } : {}), accounts, - defaultBaseUrl: DEFAULT_GITLAB_BASE_URL, + defaultBaseUrl, }); } catch (error) { console.error('Failed to get GitLab auth status:', error); @@ -353,8 +354,8 @@ export function registerGitLabRoutes(app, options = {}) { return res.status(400).json({ error: 'accessToken is required' }); } - const { normalizeBaseUrl, DEFAULT_GITLAB_BASE_URL, setGitLabAuth, getGitLabAuthAccounts } = await getGitLabLibraries(); - const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || DEFAULT_GITLAB_BASE_URL; + const { normalizeBaseUrl, getGitLabDefaultBaseUrl, setGitLabAuth, getGitLabAuthAccounts } = await getGitLabLibraries(); + const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || getGitLabDefaultBaseUrl(); const { createGitLabClient } = await getGitLabLibraries(); const client = createGitLabClient({ token: accessToken, baseUrl }); @@ -371,7 +372,7 @@ export function registerGitLabRoutes(app, options = {}) { connected: true, user: mapGitLabUser(resp.data), accounts: getGitLabAuthAccounts(), - defaultBaseUrl: DEFAULT_GITLAB_BASE_URL, + defaultBaseUrl: getGitLabDefaultBaseUrl(), }); } catch (error) { console.error('Failed to connect GitLab:', error); @@ -386,7 +387,7 @@ export function registerGitLabRoutes(app, options = {}) { return res.status(400).json({ error: 'accountId is required' }); } - const { activateGitLabAuth, getGitLabAuth, getGitLabAuthAccounts, DEFAULT_GITLAB_BASE_URL } = await getGitLabLibraries(); + const { activateGitLabAuth, getGitLabAuth, getGitLabAuthAccounts, getGitLabDefaultBaseUrl } = await getGitLabLibraries(); const activated = activateGitLabAuth(accountId); if (!activated) { return res.status(404).json({ error: 'GitLab account not found' }); @@ -394,8 +395,9 @@ export function registerGitLabRoutes(app, options = {}) { const auth = getGitLabAuth(); const accounts = getGitLabAuthAccounts(); + const defaultBaseUrl = getGitLabDefaultBaseUrl(); if (!auth?.accessToken) { - return res.json({ connected: false, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL }); + return res.json({ connected: false, accounts, defaultBaseUrl }); } let user = auth.username @@ -416,7 +418,7 @@ export function registerGitLabRoutes(app, options = {}) { } } - return res.json({ connected: true, user, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL }); + return res.json({ connected: true, user, accounts, defaultBaseUrl }); } catch (error) { console.error('Failed to activate GitLab account:', error); return res.status(500).json({ error: error.message || 'Failed to activate GitLab account' }); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 3b6c453c..eaba673c 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -208,7 +208,7 @@ Transport-triggered health checks share the periodic monitor's failure accountin - `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping. - Returned API: - `normalizePwaAppName(value, fallback?)` - - `sanitizeSettingsUpdate(payload)` + - `sanitizeSettingsUpdate(payload)` — whitelist of persisted keys; includes `gitProviders` (validated via `packages/web/server/lib/git-providers/config.js` `sanitizeGitProviders`), which therefore round-trips through GET/PUT `/api/config/settings`. - `mergePersistedSettings(current, changes)` - `formatSettingsResponse(settings)` diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 7bbfb163..b58f7408 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -1,3 +1,5 @@ +import { sanitizeGitProviders } from '../git-providers/config.js'; + export const createSettingsHelpers = (dependencies) => { const { normalizePathForPersistence, @@ -481,6 +483,10 @@ export const createSettingsHelpers = (dependencies) => { const trimmed = candidate.gitModelId.trim(); result.gitModelId = trimmed.length > 0 ? trimmed : undefined; } + const gitProviders = sanitizeGitProviders(candidate.gitProviders); + if (gitProviders) { + result.gitProviders = gitProviders; + } if (typeof candidate.pwaAppName === 'string') { result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined); } diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 543c5531..b2da029e 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -465,6 +465,46 @@ describe('settings helpers', () => { expect(sanitized.favoriteModels).toEqual(payload.favoriteModels); expect(sanitized.recentModels).toEqual(payload.recentModels); }); + + it('round-trips a valid gitProviders payload through sanitizeSettingsUpdate', () => { + const helpers = createTestHelpersWithRealSanitizers(); + const payload = { + gitProviders: { + github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] }, + gitlab: { apiBaseUrl: 'gitlab.example.com', detectUrls: [] }, + gitea: { apiBaseUrl: '', detectUrls: ['gitea.example.com'] }, + }, + }; + + expect(helpers.sanitizeSettingsUpdate(payload)).toEqual({ + gitProviders: { + github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] }, + gitlab: { apiBaseUrl: 'https://gitlab.example.com' }, + gitea: { detectUrls: ['gitea.example.com'] }, + }, + }); + }); + + it('drops malformed gitProviders payloads entirely', () => { + const helpers = createTestHelpersWithRealSanitizers(); + + expect(helpers.sanitizeSettingsUpdate({ gitProviders: 'not-an-object' })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ gitProviders: [] })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ gitProviders: null })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ gitProviders: { unknown: { apiBaseUrl: 'https://x.example.com' } } })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { apiBaseUrl: ' ' } } })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { detectUrls: 'github.example.com' } } })).toEqual({}); + }); + + it('normalizes gitProviders apiBaseUrl scheme and strips trailing slashes', () => { + const helpers = createTestHelpersWithRealSanitizers(); + + expect(helpers.sanitizeSettingsUpdate({ + gitProviders: { github: { apiBaseUrl: 'github.example.com/api/v3/' } }, + })).toEqual({ + gitProviders: { github: { apiBaseUrl: 'https://github.example.com/api/v3' } }, + }); + }); }); describe('session retention settings persistence', () => {