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.
This commit is contained in:
@@ -11,7 +11,8 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { SettingsSection, SettingsGroupTitle } from '@/components/sections/shared/SettingsSection';
|
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 = {
|
type GitHubUser = {
|
||||||
login: string;
|
login: string;
|
||||||
@@ -446,7 +447,10 @@ export const GitHubSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CustomDomainsInput provider="github" />
|
<div className="flex flex-col gap-4 pt-4">
|
||||||
|
<ProviderApiBaseUrlInput provider="github" />
|
||||||
|
<ProviderDetectUrlsInput provider="github" />
|
||||||
|
</div>
|
||||||
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
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 => {
|
const getBaseUrlHost = (baseUrl?: string | null): string => {
|
||||||
if (!baseUrl) return '';
|
if (!baseUrl) return '';
|
||||||
@@ -34,7 +36,11 @@ export const GitLabSettings: React.FC = () => {
|
|||||||
|
|
||||||
const [isBusy, setIsBusy] = React.useState(false);
|
const [isBusy, setIsBusy] = React.useState(false);
|
||||||
const [accessToken, setAccessToken] = React.useState('');
|
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(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -290,7 +296,10 @@ export const GitLabSettings: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CustomDomainsInput provider="gitlab" />
|
<div className="flex flex-col gap-4 pt-4">
|
||||||
|
<ProviderApiBaseUrlInput provider="gitlab" />
|
||||||
|
<ProviderDetectUrlsInput provider="gitlab" />
|
||||||
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
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 => {
|
const getBaseUrlHost = (baseUrl?: string | null): string => {
|
||||||
if (!baseUrl) return '';
|
if (!baseUrl) return '';
|
||||||
@@ -34,7 +36,11 @@ export const GiteaSettings: React.FC = () => {
|
|||||||
|
|
||||||
const [isBusy, setIsBusy] = React.useState(false);
|
const [isBusy, setIsBusy] = React.useState(false);
|
||||||
const [accessToken, setAccessToken] = React.useState('');
|
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(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -300,7 +306,10 @@ export const GiteaSettings: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CustomDomainsInput provider="gitea" />
|
<div className="flex flex-col gap-4 pt-4">
|
||||||
|
<ProviderApiBaseUrlInput provider="gitea" />
|
||||||
|
<ProviderDetectUrlsInput provider="gitea" />
|
||||||
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
|
||||||
<label htmlFor={`${provider}-custom-domains`} className="typography-settings-field-label text-foreground">
|
|
||||||
{t(`settings.${provider}.page.customDomains.label`)}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id={`${provider}-custom-domains`}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => 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]"
|
|
||||||
/>
|
|
||||||
<span className="typography-micro text-muted-foreground">
|
|
||||||
{t(`settings.${provider}.page.customDomains.description`)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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<void> => {
|
||||||
|
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 (
|
||||||
|
<SettingsStackedField
|
||||||
|
label={t(`settings.${provider}.page.apiBaseUrl.label`)}
|
||||||
|
description={t(`settings.${provider}.page.apiBaseUrl.description`)}
|
||||||
|
descriptionPlacement="after"
|
||||||
|
settingsItem={`git.${provider}-api-base-url`}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</SettingsStackedField>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<SettingsStackedField
|
||||||
|
label={t(`settings.${provider}.page.detectUrls.label`)}
|
||||||
|
description={t(`settings.${provider}.page.detectUrls.description`)}
|
||||||
|
descriptionPlacement="after"
|
||||||
|
settingsItem={`git.${provider}-detect-urls`}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
|
{domains.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{domains.map((host) => (
|
||||||
|
<span
|
||||||
|
key={host}
|
||||||
|
className="inline-flex h-6 items-center gap-0.5 rounded-md border border-border/60 bg-[var(--surface-elevated)] pl-2 pr-1"
|
||||||
|
>
|
||||||
|
<span className="typography-micro font-mono text-foreground">{host}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
aria-label={t('settings.gitProviders.detectUrls.remove', { host })}
|
||||||
|
title={t('settings.gitProviders.detectUrls.remove', { host })}
|
||||||
|
onClick={() => removeChip(host)}
|
||||||
|
className="h-4 w-4 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||||
|
>
|
||||||
|
<Icon name="close" className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => {
|
||||||
|
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 ? (
|
||||||
|
<p className="typography-micro text-[var(--status-error)]">
|
||||||
|
{t('settings.gitProviders.detectUrls.invalid')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SettingsStackedField>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { resolveGitProvider, useGitProvider } from '@/lib/gitProvider';
|
import { resolveGitProvider, useGitProvider, buildGitProviderHosts } from '@/lib/gitProvider';
|
||||||
import type { GitProviderHosts } from '@/lib/gitProvider';
|
import type { GitProviderHosts } from '@/lib/gitProvider';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
@@ -10,20 +10,16 @@ import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
|||||||
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
|
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider-host sets derived from the connected accounts and the
|
* Provider-host sets derived from the connected accounts, the configured api
|
||||||
* user-configured custom domains, mirroring the `hosts` memo inside
|
* base urls and the user-configured custom domains, mirroring the `hosts` memo
|
||||||
* `useGitProvider` so the imperative resolver classifies directories the same
|
* inside `useGitProvider` so the imperative resolver classifies directories the
|
||||||
* way the hook does.
|
* same way the hook does.
|
||||||
*/
|
*/
|
||||||
const buildProviderHosts = (): GitProviderHosts => {
|
const buildProviderHosts = (): GitProviderHosts => {
|
||||||
const gitlabAccounts = useGitLabAuthStore.getState().status?.accounts;
|
const gitlabAccounts = useGitLabAuthStore.getState().status?.accounts;
|
||||||
const giteaAccounts = useGiteaAuthStore.getState().status?.accounts;
|
const giteaAccounts = useGiteaAuthStore.getState().status?.accounts;
|
||||||
const domains = useGitProviderDomainsStore.getState().domains;
|
const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState();
|
||||||
return {
|
return buildGitProviderHosts({ domains, apiBaseUrls, gitlabAccounts, giteaAccounts });
|
||||||
github: domains.github,
|
|
||||||
gitlab: [...(gitlabAccounts ?? []).map((account) => account.baseUrl), ...domains.gitlab],
|
|
||||||
gitea: [...(giteaAccounts ?? []).map((account) => account.baseUrl), ...domains.gitea],
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -217,6 +217,14 @@ export type DesktopSettings = {
|
|||||||
sttModel?: string;
|
sttModel?: string;
|
||||||
sttLocalModel?: string;
|
sttLocalModel?: string;
|
||||||
sttLanguage?: 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
|
// Global draft welcome starters (pinned commands/skills), persisted to settings.json
|
||||||
draftStarters?: DraftStarterRef[];
|
draftStarters?: DraftStarterRef[];
|
||||||
draftStartersVisible?: boolean;
|
draftStartersVisible?: boolean;
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 EMPTY_HOSTS: GitProviderHosts = { github: [], gitlab: [], gitea: [] };
|
||||||
|
|
||||||
|
const emptyInput = {
|
||||||
|
domains: { github: [], gitlab: [], gitea: [] },
|
||||||
|
apiBaseUrls: { github: '', gitlab: '', gitea: '' },
|
||||||
|
};
|
||||||
|
|
||||||
describe('detectGitProvider', () => {
|
describe('detectGitProvider', () => {
|
||||||
test('returns null with no remotes', () => {
|
test('returns null with no remotes', () => {
|
||||||
expect(detectGitProvider([], EMPTY_HOSTS)).toBeNull();
|
expect(detectGitProvider([], EMPTY_HOSTS)).toBeNull();
|
||||||
@@ -135,3 +140,68 @@ describe('detectGitProvider', () => {
|
|||||||
expect(detectGitProvider(['https://GITHUB.com/owner/repo.git'], EMPTY_HOSTS)).toBe('github');
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import { parseGitHost } from '@/lib/gitHost';
|
|||||||
import { getRemotes } from '@/lib/gitApi';
|
import { getRemotes } from '@/lib/gitApi';
|
||||||
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
||||||
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
|
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';
|
export type GitProvider = 'github' | 'gitlab' | 'gitea' | 'other';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-provider hostname sets used for detection: custom user-configured
|
* Per-provider hostname sets used for detection: custom user-configured
|
||||||
* domains (from the domains store) plus account-derived base-URL hostnames.
|
* domains (from the domains store), account-derived base-URL hostnames, and the
|
||||||
* Built-in defaults (github.com, gitlab.com) are applied inside the detection
|
* configured api base host. Built-in defaults (github.com, gitlab.com) are
|
||||||
* logic and never need to be present here.
|
* applied inside the detection logic and never need to be present here.
|
||||||
*/
|
*/
|
||||||
export type GitProviderHosts = {
|
export type GitProviderHosts = {
|
||||||
github: string[];
|
github: string[];
|
||||||
@@ -32,6 +37,57 @@ const normalizeHostList = (hosts: string[] | undefined): string[] => {
|
|||||||
return result;
|
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
|
* 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
|
* 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 gitlabAccounts = useGitLabAuthStore((state) => state.status?.accounts);
|
||||||
const giteaAccounts = useGiteaAuthStore((state) => state.status?.accounts);
|
const giteaAccounts = useGiteaAuthStore((state) => state.status?.accounts);
|
||||||
const domains = useGitProviderDomainsStore((state) => state.domains);
|
const domains = useGitProviderDomainsStore((state) => state.domains);
|
||||||
|
const apiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls);
|
||||||
const hosts = useMemo<GitProviderHosts>(
|
const hosts = useMemo<GitProviderHosts>(
|
||||||
() => ({
|
() => buildGitProviderHosts({ domains, apiBaseUrls, gitlabAccounts, giteaAccounts }),
|
||||||
github: domains.github,
|
[domains, apiBaseUrls, gitlabAccounts, giteaAccounts],
|
||||||
gitlab: [...(gitlabAccounts ?? []).map((account) => account.baseUrl), ...domains.gitlab],
|
|
||||||
gitea: [...(giteaAccounts ?? []).map((account) => account.baseUrl), ...domains.gitea],
|
|
||||||
}),
|
|
||||||
[domains, gitlabAccounts, giteaAccounts],
|
|
||||||
);
|
);
|
||||||
const [provider, setProvider] = useState<GitProvider | null>(null);
|
const [provider, setProvider] = useState<GitProvider | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1635,9 +1635,12 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.ghCliEnabled': 'gh CLI Fallback aktiviert',
|
'settings.github.page.toast.ghCliEnabled': 'gh CLI Fallback aktiviert',
|
||||||
'settings.github.page.toast.ghCliDisabled': 'gh CLI Fallback deaktiviert',
|
'settings.github.page.toast.ghCliDisabled': 'gh CLI Fallback deaktiviert',
|
||||||
'settings.github.page.toast.ghCliUpdateFailed': 'Fehler beim Aktualisieren der gh CLI Einstellung',
|
'settings.github.page.toast.ghCliUpdateFailed': 'Fehler beim Aktualisieren der gh CLI Einstellung',
|
||||||
'settings.github.page.customDomains.label': 'Benutzerdefinierte Domains',
|
'settings.github.page.apiBaseUrl.label': 'API-Basis-URL',
|
||||||
'settings.github.page.customDomains.description': 'Repositories auf diesen Domains werden als GitHub behandelt.',
|
'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3',
|
||||||
'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com',
|
'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.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.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.',
|
'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.accessToken.placeholder': 'Fügen Sie Ihr GitLab Personal Access Token ein',
|
||||||
'settings.gitlab.page.baseUrl.label': 'Basis-URL (optional)',
|
'settings.gitlab.page.baseUrl.label': 'Basis-URL (optional)',
|
||||||
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
||||||
'settings.gitlab.page.customDomains.label': 'Benutzerdefinierte Domains',
|
'settings.gitlab.page.apiBaseUrl.label': 'API-Basis-URL',
|
||||||
'settings.gitlab.page.customDomains.description': 'Repositories auf diesen Domains werden als GitLab behandelt.',
|
'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com',
|
||||||
'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.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.connect': 'GitLab verbinden',
|
||||||
'settings.gitlab.page.actions.disconnect': 'Trennen',
|
'settings.gitlab.page.actions.disconnect': 'Trennen',
|
||||||
'settings.gitlab.page.actions.switch': 'Wechseln zu',
|
'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.disconnectFailed': 'Trennen von Gitea fehlgeschlagen',
|
||||||
'settings.gitea.page.toast.accountSwitched': 'Gitea-Konto gewechselt',
|
'settings.gitea.page.toast.accountSwitched': 'Gitea-Konto gewechselt',
|
||||||
'settings.gitea.page.toast.accountSwitchFailed': 'Wechsel des Gitea-Kontos fehlgeschlagen',
|
'settings.gitea.page.toast.accountSwitchFailed': 'Wechsel des Gitea-Kontos fehlgeschlagen',
|
||||||
'settings.gitea.page.customDomains.label': 'Benutzerdefinierte Domains',
|
'settings.gitea.page.apiBaseUrl.label': 'API-Basis-URL',
|
||||||
'settings.gitea.page.customDomains.description': 'Repositories auf diesen Domains werden als Gitea oder Forgejo behandelt.',
|
'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com',
|
||||||
'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.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.title': 'Benachrichtigungsübermittlung',
|
||||||
'settings.notifications.page.delivery.enableAria': 'Benachrichtigungen aktivieren',
|
'settings.notifications.page.delivery.enableAria': 'Benachrichtigungen aktivieren',
|
||||||
'settings.notifications.page.delivery.enableLabel': 'Benachrichtigungen aktivieren',
|
'settings.notifications.page.delivery.enableLabel': 'Benachrichtigungen aktivieren',
|
||||||
|
|||||||
@@ -1701,9 +1701,12 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.ghCliEnabled': 'gh CLI fallback enabled',
|
'settings.github.page.toast.ghCliEnabled': 'gh CLI fallback enabled',
|
||||||
'settings.github.page.toast.ghCliDisabled': 'gh CLI fallback disabled',
|
'settings.github.page.toast.ghCliDisabled': 'gh CLI fallback disabled',
|
||||||
'settings.github.page.toast.ghCliUpdateFailed': 'Failed to update gh CLI setting',
|
'settings.github.page.toast.ghCliUpdateFailed': 'Failed to update gh CLI setting',
|
||||||
'settings.github.page.customDomains.label': 'Custom domains',
|
'settings.github.page.apiBaseUrl.label': 'API base URL',
|
||||||
'settings.github.page.customDomains.description': 'Repos hosted on these domains are treated as GitHub.',
|
'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3',
|
||||||
'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com',
|
'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.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.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.',
|
'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.accessToken.placeholder': 'Paste your GitLab personal access token',
|
||||||
'settings.gitlab.page.baseUrl.label': 'Base URL (optional)',
|
'settings.gitlab.page.baseUrl.label': 'Base URL (optional)',
|
||||||
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
||||||
'settings.gitlab.page.customDomains.label': 'Custom domains',
|
'settings.gitlab.page.apiBaseUrl.label': 'API base URL',
|
||||||
'settings.gitlab.page.customDomains.description': 'Repos hosted on these domains are treated as GitLab.',
|
'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com',
|
||||||
'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.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.connect': 'Connect GitLab',
|
||||||
'settings.gitlab.page.actions.disconnect': 'Disconnect',
|
'settings.gitlab.page.actions.disconnect': 'Disconnect',
|
||||||
'settings.gitlab.page.actions.switch': 'Switch to',
|
'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.disconnectFailed': 'Failed to disconnect Gitea',
|
||||||
'settings.gitea.page.toast.accountSwitched': 'Gitea account switched',
|
'settings.gitea.page.toast.accountSwitched': 'Gitea account switched',
|
||||||
'settings.gitea.page.toast.accountSwitchFailed': 'Failed to switch Gitea account',
|
'settings.gitea.page.toast.accountSwitchFailed': 'Failed to switch Gitea account',
|
||||||
'settings.gitea.page.customDomains.label': 'Custom domains',
|
'settings.gitea.page.apiBaseUrl.label': 'API base URL',
|
||||||
'settings.gitea.page.customDomains.description': 'Repos hosted on these domains are treated as Gitea or Forgejo.',
|
'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com',
|
||||||
'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.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.title': 'Notification Delivery',
|
||||||
'settings.notifications.page.delivery.enableAria': 'Enable notifications',
|
'settings.notifications.page.delivery.enableAria': 'Enable notifications',
|
||||||
'settings.notifications.page.delivery.enableLabel': 'Enable Notifications',
|
'settings.notifications.page.delivery.enableLabel': 'Enable Notifications',
|
||||||
|
|||||||
@@ -1678,9 +1678,12 @@ export const settingsDict = {
|
|||||||
"settings.github.page.toast.ghCliEnabled": "Respaldo de gh CLI activado",
|
"settings.github.page.toast.ghCliEnabled": "Respaldo de gh CLI activado",
|
||||||
"settings.github.page.toast.ghCliDisabled": "Respaldo de gh CLI desactivado",
|
"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.toast.ghCliUpdateFailed": "No se pudo actualizar la configuración de gh CLI",
|
||||||
"settings.github.page.customDomains.label": "Dominios personalizados",
|
"settings.github.page.apiBaseUrl.label": "URL base de la API",
|
||||||
"settings.github.page.customDomains.description": "Los repositorios alojados en estos dominios se tratarán como GitHub.",
|
"settings.github.page.apiBaseUrl.placeholder": "https://github.example.com/api/v3",
|
||||||
"settings.github.page.customDomains.placeholder": "github.example.com, git.mycompany.com",
|
"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.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.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.",
|
"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.accessToken.placeholder": "Pega tu token de acceso personal de GitLab",
|
||||||
"settings.gitlab.page.baseUrl.label": "URL base (opcional)",
|
"settings.gitlab.page.baseUrl.label": "URL base (opcional)",
|
||||||
"settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com",
|
"settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com",
|
||||||
"settings.gitlab.page.customDomains.label": "Dominios personalizados",
|
"settings.gitlab.page.apiBaseUrl.label": "URL base de la API",
|
||||||
"settings.gitlab.page.customDomains.description": "Los repositorios alojados en estos dominios se tratarán como GitLab.",
|
"settings.gitlab.page.apiBaseUrl.placeholder": "https://gitlab.example.com",
|
||||||
"settings.gitlab.page.customDomains.placeholder": "gitlab.example.com, git.company.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.connect": "Conectar GitLab",
|
||||||
"settings.gitlab.page.actions.disconnect": "Desconectar",
|
"settings.gitlab.page.actions.disconnect": "Desconectar",
|
||||||
"settings.gitlab.page.actions.switch": "Cambiar a",
|
"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.disconnectFailed": "No se pudo desconectar Gitea",
|
||||||
"settings.gitea.page.toast.accountSwitched": "Cuenta de Gitea cambiada",
|
"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.toast.accountSwitchFailed": "No se pudo cambiar la cuenta de Gitea",
|
||||||
"settings.gitea.page.customDomains.label": "Dominios personalizados",
|
"settings.gitea.page.apiBaseUrl.label": "URL base de la API",
|
||||||
"settings.gitea.page.customDomains.description": "Los repositorios alojados en estos dominios se tratarán como Gitea o Forgejo.",
|
"settings.gitea.page.apiBaseUrl.placeholder": "https://gitea.example.com",
|
||||||
"settings.gitea.page.customDomains.placeholder": "gitea.example.com, git.company.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.title": "Entrega de notificaciones",
|
||||||
"settings.notifications.page.delivery.enableAria": "Habilitar notificaciones",
|
"settings.notifications.page.delivery.enableAria": "Habilitar notificaciones",
|
||||||
"settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones",
|
"settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones",
|
||||||
|
|||||||
@@ -1596,9 +1596,12 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.ghCliEnabled': 'Solution de secours gh CLI activée',
|
'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.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.toast.ghCliUpdateFailed': 'Échec de la mise à jour du paramètre gh CLI',
|
||||||
'settings.github.page.customDomains.label': 'Domaines personnalisés',
|
'settings.github.page.apiBaseUrl.label': 'URL de base de l’API',
|
||||||
'settings.github.page.customDomains.description': 'Les dépôts hébergés sur ces domaines sont traités comme GitHub.',
|
'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3',
|
||||||
'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com',
|
'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.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.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.',
|
'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.accessToken.placeholder': 'Collez votre jeton d\'accès personnel GitLab',
|
||||||
'settings.gitlab.page.baseUrl.label': 'URL de base (facultatif)',
|
'settings.gitlab.page.baseUrl.label': 'URL de base (facultatif)',
|
||||||
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
||||||
'settings.gitlab.page.customDomains.label': 'Domaines personnalisés',
|
'settings.gitlab.page.apiBaseUrl.label': 'URL de base de l’API',
|
||||||
'settings.gitlab.page.customDomains.description': 'Les dépôts hébergés sur ces domaines sont traités comme GitLab.',
|
'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com',
|
||||||
'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.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.connect': 'Connecter GitLab',
|
||||||
'settings.gitlab.page.actions.disconnect': 'Déconnecter',
|
'settings.gitlab.page.actions.disconnect': 'Déconnecter',
|
||||||
'settings.gitlab.page.actions.switch': 'Passer à',
|
'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.disconnectFailed': 'Échec de la déconnexion du Gitea',
|
||||||
'settings.gitea.page.toast.accountSwitched': 'Le compte Gitea a changé',
|
'settings.gitea.page.toast.accountSwitched': 'Le compte Gitea a changé',
|
||||||
'settings.gitea.page.toast.accountSwitchFailed': 'Échec du changement de compte Gitea',
|
'settings.gitea.page.toast.accountSwitchFailed': 'Échec du changement de compte Gitea',
|
||||||
'settings.gitea.page.customDomains.label': 'Domaines personnalisés',
|
'settings.gitea.page.apiBaseUrl.label': 'URL de base de l’API',
|
||||||
'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.apiBaseUrl.placeholder': 'https://gitea.example.com',
|
||||||
'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.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.title': 'Envoi des notifications',
|
||||||
'settings.notifications.page.delivery.enableAria': 'Activer les notifications',
|
'settings.notifications.page.delivery.enableAria': 'Activer les notifications',
|
||||||
'settings.notifications.page.delivery.enableLabel': 'Activer les notifications',
|
'settings.notifications.page.delivery.enableLabel': 'Activer les notifications',
|
||||||
|
|||||||
@@ -1711,9 +1711,12 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.ghCliEnabled': 'gh CLI フォールバックを有効化しました',
|
'settings.github.page.toast.ghCliEnabled': 'gh CLI フォールバックを有効化しました',
|
||||||
'settings.github.page.toast.ghCliDisabled': 'gh CLI フォールバックを無効化しました',
|
'settings.github.page.toast.ghCliDisabled': 'gh CLI フォールバックを無効化しました',
|
||||||
'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 設定の更新に失敗しました',
|
'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 設定の更新に失敗しました',
|
||||||
'settings.github.page.customDomains.label': 'カスタムドメイン',
|
'settings.github.page.apiBaseUrl.label': 'API ベース URL',
|
||||||
'settings.github.page.customDomains.description': 'これらのドメインでホストされているリポジトリは GitHub として扱われます。',
|
'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3',
|
||||||
'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com',
|
'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.title': 'GitLab パーソナルアクセストークン',
|
||||||
'settings.gitlab.page.description': 'GitLab パーソナルアクセストークンを貼り付けて接続します。セルフホストの GitLab インスタンスを使用する場合はベース URL を設定してください。',
|
'settings.gitlab.page.description': 'GitLab パーソナルアクセストークンを貼り付けて接続します。セルフホストの GitLab インスタンスを使用する場合はベース URL を設定してください。',
|
||||||
'settings.gitlab.page.tooltip.connectAccount': 'アプリ内の Issue とマージリクエストのワークフロー用に GitLab アカウントを接続します。',
|
'settings.gitlab.page.tooltip.connectAccount': 'アプリ内の Issue とマージリクエストのワークフロー用に GitLab アカウントを接続します。',
|
||||||
@@ -1721,9 +1724,12 @@ export const settingsDict = {
|
|||||||
'settings.gitlab.page.accessToken.placeholder': 'GitLab パーソナルアクセストークンを貼り付け',
|
'settings.gitlab.page.accessToken.placeholder': 'GitLab パーソナルアクセストークンを貼り付け',
|
||||||
'settings.gitlab.page.baseUrl.label': 'ベース URL(任意)',
|
'settings.gitlab.page.baseUrl.label': 'ベース URL(任意)',
|
||||||
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
||||||
'settings.gitlab.page.customDomains.label': 'カスタムドメイン',
|
'settings.gitlab.page.apiBaseUrl.label': 'API ベース URL',
|
||||||
'settings.gitlab.page.customDomains.description': 'これらのドメインでホストされているリポジトリは GitLab として扱われます。',
|
'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com',
|
||||||
'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.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.connect': 'GitLab に接続',
|
||||||
'settings.gitlab.page.actions.disconnect': '切断',
|
'settings.gitlab.page.actions.disconnect': '切断',
|
||||||
'settings.gitlab.page.actions.switch': '切り替え',
|
'settings.gitlab.page.actions.switch': '切り替え',
|
||||||
@@ -1763,9 +1769,15 @@ export const settingsDict = {
|
|||||||
'settings.gitea.page.toast.disconnectFailed': 'Gitea の切断に失敗しました',
|
'settings.gitea.page.toast.disconnectFailed': 'Gitea の切断に失敗しました',
|
||||||
'settings.gitea.page.toast.accountSwitched': 'Gitea アカウントを切り替えました',
|
'settings.gitea.page.toast.accountSwitched': 'Gitea アカウントを切り替えました',
|
||||||
'settings.gitea.page.toast.accountSwitchFailed': 'Gitea アカウントの切り替えに失敗しました',
|
'settings.gitea.page.toast.accountSwitchFailed': 'Gitea アカウントの切り替えに失敗しました',
|
||||||
'settings.gitea.page.customDomains.label': 'カスタムドメイン',
|
'settings.gitea.page.apiBaseUrl.label': 'API ベース URL',
|
||||||
'settings.gitea.page.customDomains.description': 'これらのドメインでホストされているリポジトリは Gitea または Forgejo として扱われます。',
|
'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com',
|
||||||
'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.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.title': '通知配信',
|
||||||
'settings.notifications.page.delivery.enableAria': '通知を有効化',
|
'settings.notifications.page.delivery.enableAria': '通知を有効化',
|
||||||
'settings.notifications.page.delivery.enableLabel': '通知を有効化',
|
'settings.notifications.page.delivery.enableLabel': '通知を有効化',
|
||||||
|
|||||||
@@ -1678,9 +1678,12 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.ghCliEnabled': 'gh CLI 대체 활성화됨',
|
'settings.github.page.toast.ghCliEnabled': 'gh CLI 대체 활성화됨',
|
||||||
'settings.github.page.toast.ghCliDisabled': 'gh CLI 대체 비활성화됨',
|
'settings.github.page.toast.ghCliDisabled': 'gh CLI 대체 비활성화됨',
|
||||||
'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 설정을 업데이트하지 못했습니다',
|
'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 설정을 업데이트하지 못했습니다',
|
||||||
'settings.github.page.customDomains.label': '사용자 지정 도메인',
|
'settings.github.page.apiBaseUrl.label': 'API 기본 URL',
|
||||||
'settings.github.page.customDomains.description': '이 도메인에서 호스팅되는 저장소는 GitHub로 취급됩니다.',
|
'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3',
|
||||||
'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com',
|
'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.title': 'GitLab 개인 액세스 토큰',
|
||||||
'settings.gitlab.page.description': '연결하려면 GitLab 개인 액세스 토큰을 붙여넣으세요. 자체 호스팅 GitLab 인스턴스를 사용하는 경우 기본 URL을 설정하세요.',
|
'settings.gitlab.page.description': '연결하려면 GitLab 개인 액세스 토큰을 붙여넣으세요. 자체 호스팅 GitLab 인스턴스를 사용하는 경우 기본 URL을 설정하세요.',
|
||||||
'settings.gitlab.page.tooltip.connectAccount': '앱 내 이슈 및 병합 요청 워크플로에 GitLab 계정을 연결합니다.',
|
'settings.gitlab.page.tooltip.connectAccount': '앱 내 이슈 및 병합 요청 워크플로에 GitLab 계정을 연결합니다.',
|
||||||
@@ -1688,9 +1691,12 @@ export const settingsDict = {
|
|||||||
'settings.gitlab.page.accessToken.placeholder': 'GitLab 개인 액세스 토큰을 붙여넣으세요',
|
'settings.gitlab.page.accessToken.placeholder': 'GitLab 개인 액세스 토큰을 붙여넣으세요',
|
||||||
'settings.gitlab.page.baseUrl.label': '기본 URL(선택 사항)',
|
'settings.gitlab.page.baseUrl.label': '기본 URL(선택 사항)',
|
||||||
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
||||||
'settings.gitlab.page.customDomains.label': '사용자 지정 도메인',
|
'settings.gitlab.page.apiBaseUrl.label': 'API 기본 URL',
|
||||||
'settings.gitlab.page.customDomains.description': '이 도메인에서 호스팅되는 저장소는 GitLab로 취급됩니다.',
|
'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com',
|
||||||
'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.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.connect': 'GitLab 연결',
|
||||||
'settings.gitlab.page.actions.disconnect': '연결 해제',
|
'settings.gitlab.page.actions.disconnect': '연결 해제',
|
||||||
'settings.gitlab.page.actions.switch': '전환',
|
'settings.gitlab.page.actions.switch': '전환',
|
||||||
@@ -1730,9 +1736,15 @@ export const settingsDict = {
|
|||||||
'settings.gitea.page.toast.disconnectFailed': 'Gitea 연결을 해제하지 못했습니다',
|
'settings.gitea.page.toast.disconnectFailed': 'Gitea 연결을 해제하지 못했습니다',
|
||||||
'settings.gitea.page.toast.accountSwitched': 'Gitea 계정이 전환되었습니다',
|
'settings.gitea.page.toast.accountSwitched': 'Gitea 계정이 전환되었습니다',
|
||||||
'settings.gitea.page.toast.accountSwitchFailed': 'Gitea 계정을 전환하지 못했습니다',
|
'settings.gitea.page.toast.accountSwitchFailed': 'Gitea 계정을 전환하지 못했습니다',
|
||||||
'settings.gitea.page.customDomains.label': '사용자 지정 도메인',
|
'settings.gitea.page.apiBaseUrl.label': 'API 기본 URL',
|
||||||
'settings.gitea.page.customDomains.description': '이 도메인에서 호스팅되는 저장소는 Gitea 또는 Forgejo로 취급됩니다.',
|
'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com',
|
||||||
'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.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.title': '알림',
|
||||||
'settings.notifications.page.delivery.enableAria': '알림 활성화',
|
'settings.notifications.page.delivery.enableAria': '알림 활성화',
|
||||||
'settings.notifications.page.delivery.enableLabel': '알림 활성화',
|
'settings.notifications.page.delivery.enableLabel': '알림 활성화',
|
||||||
|
|||||||
@@ -318,9 +318,12 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.ghCliEnabled': 'Rezerwa gh CLI włączona',
|
'settings.github.page.toast.ghCliEnabled': 'Rezerwa gh CLI włączona',
|
||||||
'settings.github.page.toast.ghCliDisabled': 'Rezerwa gh CLI wyłą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.toast.ghCliUpdateFailed': 'Nie udało się zaktualizować ustawienia gh CLI',
|
||||||
'settings.github.page.customDomains.label': 'Niestandardowe domeny',
|
'settings.github.page.apiBaseUrl.label': 'Adres URL API',
|
||||||
'settings.github.page.customDomains.description': 'Repozytoria hostowane na tych domenach będą traktowane jako GitHub.',
|
'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3',
|
||||||
'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com',
|
'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.oauth.title': 'Token OAuth GitHub',
|
||||||
'settings.github.page.ghCli.title': 'Token CLI GitHub',
|
'settings.github.page.ghCli.title': 'Token CLI GitHub',
|
||||||
'settings.github.page.ghCli.activeDescription': 'Uwierzytelniono przez gh CLI',
|
'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.accessToken.placeholder': 'Wklej swój osobisty token dostępu GitLab',
|
||||||
'settings.gitlab.page.baseUrl.label': 'Podstawowy URL (opcjonalnie)',
|
'settings.gitlab.page.baseUrl.label': 'Podstawowy URL (opcjonalnie)',
|
||||||
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
||||||
'settings.gitlab.page.customDomains.label': 'Niestandardowe domeny',
|
'settings.gitlab.page.apiBaseUrl.label': 'Adres URL API',
|
||||||
'settings.gitlab.page.customDomains.description': 'Repozytoria hostowane na tych domenach będą traktowane jako GitLab.',
|
'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com',
|
||||||
'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.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.connect': 'Połącz GitLab',
|
||||||
'settings.gitlab.page.actions.disconnect': 'Odłącz',
|
'settings.gitlab.page.actions.disconnect': 'Odłącz',
|
||||||
'settings.gitlab.page.actions.switch': 'Przełącz na',
|
'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.disconnectFailed': 'Nie udało się odłączyć Gitea',
|
||||||
'settings.gitea.page.toast.accountSwitched': 'Konto Gitea zostało przełączone',
|
'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.toast.accountSwitchFailed': 'Nie udało się przełączyć konta Gitea',
|
||||||
'settings.gitea.page.customDomains.label': 'Niestandardowe domeny',
|
'settings.gitea.page.apiBaseUrl.label': 'Adres URL API',
|
||||||
'settings.gitea.page.customDomains.description': 'Repozytoria hostowane na tych domenach będą traktowane jako Gitea lub Forgejo.',
|
'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com',
|
||||||
'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.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.resetAllOverrides': 'Zresetuj wszystkie nadpisania',
|
||||||
'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych',
|
'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych',
|
||||||
'settings.magicPrompts.page.actions.resetting': 'Resetowanie...',
|
'settings.magicPrompts.page.actions.resetting': 'Resetowanie...',
|
||||||
|
|||||||
@@ -1678,9 +1678,12 @@ export const settingsDict = {
|
|||||||
"settings.github.page.toast.ghCliEnabled": "Alternativa gh CLI ativada",
|
"settings.github.page.toast.ghCliEnabled": "Alternativa gh CLI ativada",
|
||||||
"settings.github.page.toast.ghCliDisabled": "Alternativa gh CLI desativada",
|
"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.toast.ghCliUpdateFailed": "Falha ao atualizar configuração do gh CLI",
|
||||||
"settings.github.page.customDomains.label": "Domínios personalizados",
|
"settings.github.page.apiBaseUrl.label": "URL base da API",
|
||||||
"settings.github.page.customDomains.description": "Repositórios hospedados nesses domínios serão tratados como GitHub.",
|
"settings.github.page.apiBaseUrl.placeholder": "https://github.example.com/api/v3",
|
||||||
"settings.github.page.customDomains.placeholder": "github.example.com, git.mycompany.com",
|
"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.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.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.",
|
"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.accessToken.placeholder": "Cole seu token de acesso pessoal do GitLab",
|
||||||
"settings.gitlab.page.baseUrl.label": "URL base (opcional)",
|
"settings.gitlab.page.baseUrl.label": "URL base (opcional)",
|
||||||
"settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com",
|
"settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com",
|
||||||
"settings.gitlab.page.customDomains.label": "Domínios personalizados",
|
"settings.gitlab.page.apiBaseUrl.label": "URL base da API",
|
||||||
"settings.gitlab.page.customDomains.description": "Repositórios hospedados nesses domínios serão tratados como GitLab.",
|
"settings.gitlab.page.apiBaseUrl.placeholder": "https://gitlab.example.com",
|
||||||
"settings.gitlab.page.customDomains.placeholder": "gitlab.example.com, git.company.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.connect": "Conectar GitLab",
|
||||||
"settings.gitlab.page.actions.disconnect": "Desconectar",
|
"settings.gitlab.page.actions.disconnect": "Desconectar",
|
||||||
"settings.gitlab.page.actions.switch": "Alternar para",
|
"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.disconnectFailed": "Falha ao desconectar o Gitea",
|
||||||
"settings.gitea.page.toast.accountSwitched": "Conta do Gitea alterada",
|
"settings.gitea.page.toast.accountSwitched": "Conta do Gitea alterada",
|
||||||
"settings.gitea.page.toast.accountSwitchFailed": "Falha ao alternar a conta do Gitea",
|
"settings.gitea.page.toast.accountSwitchFailed": "Falha ao alternar a conta do Gitea",
|
||||||
"settings.gitea.page.customDomains.label": "Domínios personalizados",
|
"settings.gitea.page.apiBaseUrl.label": "URL base da API",
|
||||||
"settings.gitea.page.customDomains.description": "Repositórios hospedados nesses domínios serão tratados como Gitea ou Forgejo.",
|
"settings.gitea.page.apiBaseUrl.placeholder": "https://gitea.example.com",
|
||||||
"settings.gitea.page.customDomains.placeholder": "gitea.example.com, git.company.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.title": "Entrega de notificações",
|
||||||
"settings.notifications.page.delivery.enableAria": "Ativar notificações",
|
"settings.notifications.page.delivery.enableAria": "Ativar notificações",
|
||||||
"settings.notifications.page.delivery.enableLabel": "Ativar notificações",
|
"settings.notifications.page.delivery.enableLabel": "Ativar notificações",
|
||||||
|
|||||||
@@ -1678,9 +1678,12 @@ export const settingsDict = {
|
|||||||
"settings.github.page.toast.ghCliEnabled": "Резервний варіант gh CLI увімкнено",
|
"settings.github.page.toast.ghCliEnabled": "Резервний варіант gh CLI увімкнено",
|
||||||
"settings.github.page.toast.ghCliDisabled": "Резервний варіант gh CLI вимкнено",
|
"settings.github.page.toast.ghCliDisabled": "Резервний варіант gh CLI вимкнено",
|
||||||
"settings.github.page.toast.ghCliUpdateFailed": "Не вдалося оновити налаштування gh CLI",
|
"settings.github.page.toast.ghCliUpdateFailed": "Не вдалося оновити налаштування gh CLI",
|
||||||
"settings.github.page.customDomains.label": "Власні домени",
|
"settings.github.page.apiBaseUrl.label": "Базова URL-адреса API",
|
||||||
"settings.github.page.customDomains.description": "Репозиторії, розміщені на цих доменах, вважатимуться GitHub.",
|
"settings.github.page.apiBaseUrl.placeholder": "https://github.example.com/api/v3",
|
||||||
"settings.github.page.customDomains.placeholder": "github.example.com, git.mycompany.com",
|
"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.title": "Персональний токен доступу GitLab",
|
||||||
"settings.gitlab.page.description": "Вставте персональний токен доступу GitLab, щоб підключитися. Вкажіть базову URL-адресу, якщо використовуєте самостійно розміщену інстанцію GitLab.",
|
"settings.gitlab.page.description": "Вставте персональний токен доступу GitLab, щоб підключитися. Вкажіть базову URL-адресу, якщо використовуєте самостійно розміщену інстанцію GitLab.",
|
||||||
"settings.gitlab.page.tooltip.connectAccount": "Підключіть обліковий запис GitLab для роботи з issues та merge requests в застосунку.",
|
"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.accessToken.placeholder": "Вставте ваш персональний токен доступу GitLab",
|
||||||
"settings.gitlab.page.baseUrl.label": "Базова URL-адреса (необов'язково)",
|
"settings.gitlab.page.baseUrl.label": "Базова URL-адреса (необов'язково)",
|
||||||
"settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com",
|
"settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com",
|
||||||
"settings.gitlab.page.customDomains.label": "Власні домени",
|
"settings.gitlab.page.apiBaseUrl.label": "Базова URL-адреса API",
|
||||||
"settings.gitlab.page.customDomains.description": "Репозиторії, розміщені на цих доменах, вважатимуться GitLab.",
|
"settings.gitlab.page.apiBaseUrl.placeholder": "https://gitlab.example.com",
|
||||||
"settings.gitlab.page.customDomains.placeholder": "gitlab.example.com, git.company.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.connect": "Підключити GitLab",
|
||||||
"settings.gitlab.page.actions.disconnect": "Відключити",
|
"settings.gitlab.page.actions.disconnect": "Відключити",
|
||||||
"settings.gitlab.page.actions.switch": "Перемкнути на",
|
"settings.gitlab.page.actions.switch": "Перемкнути на",
|
||||||
@@ -1730,9 +1736,15 @@ export const settingsDict = {
|
|||||||
"settings.gitea.page.toast.disconnectFailed": "Не вдалося відключити Gitea",
|
"settings.gitea.page.toast.disconnectFailed": "Не вдалося відключити Gitea",
|
||||||
"settings.gitea.page.toast.accountSwitched": "Обліковий запис Gitea перемкнено",
|
"settings.gitea.page.toast.accountSwitched": "Обліковий запис Gitea перемкнено",
|
||||||
"settings.gitea.page.toast.accountSwitchFailed": "Не вдалося перемкнути обліковий запис Gitea",
|
"settings.gitea.page.toast.accountSwitchFailed": "Не вдалося перемкнути обліковий запис Gitea",
|
||||||
"settings.gitea.page.customDomains.label": "Власні домени",
|
"settings.gitea.page.apiBaseUrl.label": "Базова URL-адреса API",
|
||||||
"settings.gitea.page.customDomains.description": "Репозиторії, розміщені на цих доменах, вважатимуться Gitea або Forgejo.",
|
"settings.gitea.page.apiBaseUrl.placeholder": "https://gitea.example.com",
|
||||||
"settings.gitea.page.customDomains.placeholder": "gitea.example.com, git.company.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.title": "Доставка сповіщень",
|
||||||
"settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення",
|
"settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення",
|
||||||
"settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення",
|
"settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення",
|
||||||
|
|||||||
@@ -1678,9 +1678,12 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.ghCliEnabled': 'gh CLI 备用已启用',
|
'settings.github.page.toast.ghCliEnabled': 'gh CLI 备用已启用',
|
||||||
'settings.github.page.toast.ghCliDisabled': 'gh CLI 备用已禁用',
|
'settings.github.page.toast.ghCliDisabled': 'gh CLI 备用已禁用',
|
||||||
'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 设置失败',
|
'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 设置失败',
|
||||||
'settings.github.page.customDomains.label': '自定义域名',
|
'settings.github.page.apiBaseUrl.label': 'API 基础 URL',
|
||||||
'settings.github.page.customDomains.description': '托管在这些域名上的仓库将被视为 GitHub。',
|
'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3',
|
||||||
'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com',
|
'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.title': 'GitLab 个人访问令牌',
|
||||||
'settings.gitlab.page.description': '粘贴 GitLab 个人访问令牌以连接。使用自托管 GitLab 实例时,请设置基础 URL。',
|
'settings.gitlab.page.description': '粘贴 GitLab 个人访问令牌以连接。使用自托管 GitLab 实例时,请设置基础 URL。',
|
||||||
'settings.gitlab.page.tooltip.connectAccount': '连接 GitLab 账户,以便在应用内使用 Issue 和合并请求工作流。',
|
'settings.gitlab.page.tooltip.connectAccount': '连接 GitLab 账户,以便在应用内使用 Issue 和合并请求工作流。',
|
||||||
@@ -1688,9 +1691,12 @@ export const settingsDict = {
|
|||||||
'settings.gitlab.page.accessToken.placeholder': '粘贴你的 GitLab 个人访问令牌',
|
'settings.gitlab.page.accessToken.placeholder': '粘贴你的 GitLab 个人访问令牌',
|
||||||
'settings.gitlab.page.baseUrl.label': '基础 URL(可选)',
|
'settings.gitlab.page.baseUrl.label': '基础 URL(可选)',
|
||||||
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
||||||
'settings.gitlab.page.customDomains.label': '自定义域名',
|
'settings.gitlab.page.apiBaseUrl.label': 'API 基础 URL',
|
||||||
'settings.gitlab.page.customDomains.description': '托管在这些域名上的仓库将被视为 GitLab。',
|
'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com',
|
||||||
'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.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.connect': '连接 GitLab',
|
||||||
'settings.gitlab.page.actions.disconnect': '断开连接',
|
'settings.gitlab.page.actions.disconnect': '断开连接',
|
||||||
'settings.gitlab.page.actions.switch': '切换到',
|
'settings.gitlab.page.actions.switch': '切换到',
|
||||||
@@ -1730,9 +1736,15 @@ export const settingsDict = {
|
|||||||
'settings.gitea.page.toast.disconnectFailed': '断开 Gitea 失败',
|
'settings.gitea.page.toast.disconnectFailed': '断开 Gitea 失败',
|
||||||
'settings.gitea.page.toast.accountSwitched': 'Gitea 账户已切换',
|
'settings.gitea.page.toast.accountSwitched': 'Gitea 账户已切换',
|
||||||
'settings.gitea.page.toast.accountSwitchFailed': '切换 Gitea 账户失败',
|
'settings.gitea.page.toast.accountSwitchFailed': '切换 Gitea 账户失败',
|
||||||
'settings.gitea.page.customDomains.label': '自定义域名',
|
'settings.gitea.page.apiBaseUrl.label': 'API 基础 URL',
|
||||||
'settings.gitea.page.customDomains.description': '托管在这些域名上的仓库将被视为 Gitea 或 Forgejo。',
|
'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com',
|
||||||
'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.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.title': '通知投递',
|
||||||
'settings.notifications.page.delivery.enableAria': '启用通知',
|
'settings.notifications.page.delivery.enableAria': '启用通知',
|
||||||
'settings.notifications.page.delivery.enableLabel': '启用通知',
|
'settings.notifications.page.delivery.enableLabel': '启用通知',
|
||||||
|
|||||||
@@ -1585,9 +1585,12 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.ghCliEnabled': 'gh CLI 備用已啟用',
|
'settings.github.page.toast.ghCliEnabled': 'gh CLI 備用已啟用',
|
||||||
'settings.github.page.toast.ghCliDisabled': 'gh CLI 備用已停用',
|
'settings.github.page.toast.ghCliDisabled': 'gh CLI 備用已停用',
|
||||||
'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 設定失敗',
|
'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 設定失敗',
|
||||||
'settings.github.page.customDomains.label': '自訂網域',
|
'settings.github.page.apiBaseUrl.label': 'API 基礎 URL',
|
||||||
'settings.github.page.customDomains.description': '託管在這些網域上的存放庫將視為 GitHub。',
|
'settings.github.page.apiBaseUrl.placeholder': 'https://github.example.com/api/v3',
|
||||||
'settings.github.page.customDomains.placeholder': 'github.example.com, git.mycompany.com',
|
'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.title': 'GitLab 個人存取權杖',
|
||||||
'settings.gitlab.page.description': '貼上 GitLab 個人存取權杖以連線。使用自架 GitLab 執行個體時,請設定基礎 URL。',
|
'settings.gitlab.page.description': '貼上 GitLab 個人存取權杖以連線。使用自架 GitLab 執行個體時,請設定基礎 URL。',
|
||||||
'settings.gitlab.page.tooltip.connectAccount': '連線 GitLab 帳號,以在應用程式內使用 Issue 與合併請求工作流程。',
|
'settings.gitlab.page.tooltip.connectAccount': '連線 GitLab 帳號,以在應用程式內使用 Issue 與合併請求工作流程。',
|
||||||
@@ -1595,9 +1598,12 @@ export const settingsDict = {
|
|||||||
'settings.gitlab.page.accessToken.placeholder': '貼上你的 GitLab 個人存取權杖',
|
'settings.gitlab.page.accessToken.placeholder': '貼上你的 GitLab 個人存取權杖',
|
||||||
'settings.gitlab.page.baseUrl.label': '基礎 URL(選用)',
|
'settings.gitlab.page.baseUrl.label': '基礎 URL(選用)',
|
||||||
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com',
|
||||||
'settings.gitlab.page.customDomains.label': '自訂網域',
|
'settings.gitlab.page.apiBaseUrl.label': 'API 基礎 URL',
|
||||||
'settings.gitlab.page.customDomains.description': '託管在這些網域上的存放庫將視為 GitLab。',
|
'settings.gitlab.page.apiBaseUrl.placeholder': 'https://gitlab.example.com',
|
||||||
'settings.gitlab.page.customDomains.placeholder': 'gitlab.example.com, git.company.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.connect': '連線 GitLab',
|
||||||
'settings.gitlab.page.actions.disconnect': '中斷連線',
|
'settings.gitlab.page.actions.disconnect': '中斷連線',
|
||||||
'settings.gitlab.page.actions.switch': '切換到',
|
'settings.gitlab.page.actions.switch': '切換到',
|
||||||
@@ -1637,9 +1643,15 @@ export const settingsDict = {
|
|||||||
'settings.gitea.page.toast.disconnectFailed': '中斷 Gitea 失敗',
|
'settings.gitea.page.toast.disconnectFailed': '中斷 Gitea 失敗',
|
||||||
'settings.gitea.page.toast.accountSwitched': 'Gitea 帳號已切換',
|
'settings.gitea.page.toast.accountSwitched': 'Gitea 帳號已切換',
|
||||||
'settings.gitea.page.toast.accountSwitchFailed': '切換 Gitea 帳號失敗',
|
'settings.gitea.page.toast.accountSwitchFailed': '切換 Gitea 帳號失敗',
|
||||||
'settings.gitea.page.customDomains.label': '自訂網域',
|
'settings.gitea.page.apiBaseUrl.label': 'API 基礎 URL',
|
||||||
'settings.gitea.page.customDomains.description': '託管在這些網域上的存放庫將視為 Gitea 或 Forgejo。',
|
'settings.gitea.page.apiBaseUrl.placeholder': 'https://gitea.example.com',
|
||||||
'settings.gitea.page.customDomains.placeholder': 'gitea.example.com, git.company.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.title': '通知傳遞',
|
||||||
'settings.notifications.page.delivery.enableAria': '啟用通知',
|
'settings.notifications.page.delivery.enableAria': '啟用通知',
|
||||||
'settings.notifications.page.delivery.enableLabel': '啟用通知',
|
'settings.notifications.page.delivery.enableLabel': '啟用通知',
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ const sessionWith = (linked: unknown): Session =>
|
|||||||
({ metadata: { openchamber: { linked_issues: linked } } } as unknown as Session);
|
({ metadata: { openchamber: { linked_issues: linked } } } as unknown as Session);
|
||||||
|
|
||||||
const resetStores = () => {
|
const resetStores = () => {
|
||||||
useGitProviderDomainsStore.setState({ domains: { github: [], gitlab: [], gitea: [] } });
|
useGitProviderDomainsStore.setState({
|
||||||
|
domains: { github: [], gitlab: [], gitea: [] },
|
||||||
|
apiBaseUrls: { github: '', gitlab: '', gitea: '' },
|
||||||
|
});
|
||||||
useGiteaAuthStore.setState({ status: null });
|
useGiteaAuthStore.setState({ status: null });
|
||||||
useGitLabAuthStore.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');
|
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', () => {
|
test('derives a self-hosted gitlab host from the domains store', () => {
|
||||||
useGitProviderDomainsStore.setState({
|
useGitProviderDomainsStore.setState({
|
||||||
domains: { github: [], gitlab: ['gitlab.example.com'], gitea: [] },
|
domains: { github: [], gitlab: ['gitlab.example.com'], gitea: [] },
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import type { ForgeProviderKind } from '@/lib/forge/types';
|
import type { ForgeProviderKind } from '@/lib/forge/types';
|
||||||
import { parseGitHost } from '@/lib/gitHost';
|
import { parseGitHost } from '@/lib/gitHost';
|
||||||
|
import { buildGitProviderHosts } from '@/lib/gitProvider';
|
||||||
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
|
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
|
||||||
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
||||||
import { normalizeProviderDomain, useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
|
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
|
||||||
import { getSessionMetadata, type SessionMetadataRecord } from './sessionReviewMetadata';
|
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
|
* Which forge a link url belongs to. Well-known hosts resolve without any
|
||||||
* state; self-hosted hosts resolve through the connected auth accounts' base
|
* state; self-hosted hosts resolve through the connected auth accounts' base
|
||||||
* urls and the user-configured domains, in precedence order github -> gitlab ->
|
* urls, the configured api base urls and the user-configured domains, in
|
||||||
* gitea. Returns null when nothing is known — never a guess, so github-branded
|
* precedence order github -> gitlab -> gitea. Returns null when nothing is
|
||||||
* UI is not offered for an unknown host. github.com is matched first so it can
|
* known — never a guess, so github-branded UI is not offered for an unknown
|
||||||
* never be mistaken for a gitea host.
|
* host. github.com is matched first so it can never be mistaken for a gitea
|
||||||
|
* host.
|
||||||
*/
|
*/
|
||||||
export const deriveLinkedIssueProvider = (url: string): ForgeProviderKind | null => {
|
export const deriveLinkedIssueProvider = (url: string): ForgeProviderKind | null => {
|
||||||
const host = getIssueUrlHost(url);
|
const host = getIssueUrlHost(url);
|
||||||
@@ -132,23 +134,18 @@ export const deriveLinkedIssueProvider = (url: string): ForgeProviderKind | null
|
|||||||
if (host === 'gitlab.com') return 'gitlab';
|
if (host === 'gitlab.com') return 'gitlab';
|
||||||
if (host === 'gitea.com') return 'gitea';
|
if (host === 'gitea.com') return 'gitea';
|
||||||
|
|
||||||
const giteaAccountHosts = (useGiteaAuthStore.getState().status?.accounts ?? [])
|
const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState();
|
||||||
.map((account) => normalizeProviderDomain(account.baseUrl))
|
const gitlabAccounts = useGitLabAuthStore.getState().status?.accounts;
|
||||||
.filter((candidate): candidate is string => candidate !== null);
|
const giteaAccounts = useGiteaAuthStore.getState().status?.accounts;
|
||||||
if (giteaAccountHosts.includes(host)) return 'gitea';
|
// 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 ?? [])
|
if (hosts.github.includes(host)) return 'github';
|
||||||
.map((account) => normalizeProviderDomain(account.baseUrl))
|
if (hosts.gitlab.includes(host)) return 'gitlab';
|
||||||
.filter((candidate): candidate is string => candidate !== null);
|
if (hosts.gitea.includes(host)) return 'gitea';
|
||||||
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';
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
} from './linkedSessionMatches';
|
} from './linkedSessionMatches';
|
||||||
|
|
||||||
const resetStores = () => {
|
const resetStores = () => {
|
||||||
useGitProviderDomainsStore.setState({ domains: { github: [], gitlab: [], gitea: [] } });
|
useGitProviderDomainsStore.setState({
|
||||||
|
domains: { github: [], gitlab: [], gitea: [] },
|
||||||
|
apiBaseUrls: { github: '', gitlab: '', gitea: '' },
|
||||||
|
});
|
||||||
useGiteaAuthStore.setState({ status: null });
|
useGiteaAuthStore.setState({ status: null });
|
||||||
useGitLabAuthStore.setState({ status: null });
|
useGitLabAuthStore.setState({ status: null });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { sanitizeWorkStatusHiddenSections } from '@/components/chat/work-status/
|
|||||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
|
import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
|
||||||
|
import {
|
||||||
|
useGitProviderDomainsStore,
|
||||||
|
normalizeApiBaseUrl,
|
||||||
|
normalizeDomainList,
|
||||||
|
} from '@/stores/useGitProviderDomainsStore';
|
||||||
import {
|
import {
|
||||||
DEFAULT_FOLLOW_UP_BEHAVIOR,
|
DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||||
isFollowUpBehavior,
|
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)));
|
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<string, unknown>;
|
||||||
|
const result: NonNullable<DesktopSettings['gitProviders']> = {};
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, string[]> | undefined => {
|
const sanitizeRecentEfforts = (value: unknown): Record<string, string[]> | undefined => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||||
const result: Record<string, string[]> = {};
|
const result: Record<string, string[]> = {};
|
||||||
@@ -1617,6 +1655,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
result.sttLanguage = candidate.sttLanguage.trim();
|
result.sttLanguage = candidate.sttLanguage.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gitProviders = sanitizeGitProviders(candidate.gitProviders);
|
||||||
|
if (gitProviders) {
|
||||||
|
result.gitProviders = gitProviders;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1794,6 +1837,16 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('applyDesktopUiPreferences failed:', 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<DesktopSettings> = {};
|
const migrationPatch: Partial<DesktopSettings> = {};
|
||||||
if (shouldPersistCraftGoalMigration) {
|
if (shouldPersistCraftGoalMigration) {
|
||||||
if (authoritativeSettings.draftStarters) {
|
if (authoritativeSettings.draftStarters) {
|
||||||
|
|||||||
@@ -501,6 +501,48 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
|||||||
titleKey: 'settings.gitlab.page.actions.connect',
|
titleKey: 'settings.gitlab.page.actions.connect',
|
||||||
keywords: ['gitlab', 'account', 'pat', 'personal access token', 'issues', 'merge requests'],
|
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',
|
id: 'git.identities',
|
||||||
page: 'git',
|
page: 'git',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useSessionUIStore } from "@/sync/session-ui-store";
|
|||||||
import { useSelectionStore } from "@/sync/selection-store";
|
import { useSelectionStore } from "@/sync/selection-store";
|
||||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
|
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
|
||||||
import { updateDesktopSettings } from "@/lib/persistence";
|
import { updateDesktopSettings } from "@/lib/persistence";
|
||||||
|
import { useGitProviderDomainsStore } from "@/stores/useGitProviderDomainsStore";
|
||||||
import { useDirectoryStore } from "@/stores/useDirectoryStore";
|
import { useDirectoryStore } from "@/stores/useDirectoryStore";
|
||||||
import { useProjectsStore } from "@/stores/useProjectsStore";
|
import { useProjectsStore } from "@/stores/useProjectsStore";
|
||||||
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution";
|
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution";
|
||||||
@@ -65,6 +66,8 @@ interface OpenChamberDefaults {
|
|||||||
sttModel?: string;
|
sttModel?: string;
|
||||||
sttLocalModel?: string;
|
sttLocalModel?: string;
|
||||||
sttLanguage?: string;
|
sttLanguage?: string;
|
||||||
|
/** Raw `gitProviders` section of server settings (per-provider apiBaseUrl/detectUrls). */
|
||||||
|
gitProviders?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||||
@@ -103,6 +106,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
|||||||
const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined;
|
const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined;
|
||||||
const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined;
|
const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined;
|
||||||
const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined;
|
const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined;
|
||||||
|
const gitProviders = data?.gitProviders;
|
||||||
|
|
||||||
return finish('runtime-settings', {
|
return finish('runtime-settings', {
|
||||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||||
@@ -118,6 +122,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
|||||||
sttModel,
|
sttModel,
|
||||||
sttLocalModel,
|
sttLocalModel,
|
||||||
sttLanguage,
|
sttLanguage,
|
||||||
|
gitProviders,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -149,6 +154,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
|||||||
const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined;
|
const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined;
|
||||||
const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined;
|
const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined;
|
||||||
const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined;
|
const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined;
|
||||||
|
const gitProviders = data?.gitProviders;
|
||||||
|
|
||||||
return finish('settings-route', {
|
return finish('settings-route', {
|
||||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||||
@@ -164,6 +170,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
|||||||
sttModel,
|
sttModel,
|
||||||
sttLocalModel,
|
sttLocalModel,
|
||||||
sttLanguage,
|
sttLanguage,
|
||||||
|
gitProviders,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
markStartupTrace('config.defaults:error', { error: error instanceof Error ? error.message : String(error) });
|
markStartupTrace('config.defaults:error', { error: error instanceof Error ? error.message : String(error) });
|
||||||
@@ -2008,6 +2015,18 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
|
|
||||||
const safeAgents = Array.isArray(agents) ? agents : [];
|
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);
|
const providerLoad = _inFlightProviders.get(directoryKey);
|
||||||
if (providerLoad) {
|
if (providerLoad) {
|
||||||
markStartupTrace('loadAgents:awaitProviders', { directoryKey, source });
|
markStartupTrace('loadAgents:awaitProviders', { directoryKey, source });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useGitProviderDomainsStore } from './useGitProviderDomainsStore';
|
|||||||
const resetDomains = () => {
|
const resetDomains = () => {
|
||||||
useGitProviderDomainsStore.setState({
|
useGitProviderDomainsStore.setState({
|
||||||
domains: { github: [], gitlab: [], gitea: [] },
|
domains: { github: [], gitlab: [], gitea: [] },
|
||||||
|
apiBaseUrls: { github: '', gitlab: '', gitea: '' },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,4 +37,101 @@ describe('useGitProviderDomainsStore', () => {
|
|||||||
]);
|
]);
|
||||||
expect(useGitProviderDomainsStore.getState().domains.gitlab).toEqual(['gitlab.example.com']);
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,9 +15,27 @@ export type GitProviderDomains = {
|
|||||||
gitea: string[];
|
gitea: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-provider configured API base URLs, as entered in server settings
|
||||||
|
* (`gitProviders.<provider>.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 DOMAINS_STORAGE_KEY = 'openchamber.git-provider-domains';
|
||||||
|
|
||||||
const EMPTY_DOMAINS: GitProviderDomains = { github: [], gitlab: [], gitea: [] };
|
const EMPTY_DOMAINS: GitProviderDomains = { github: [], gitlab: [], gitea: [] };
|
||||||
|
const EMPTY_API_BASE_URLS: GitProviderApiBaseUrls = { github: '', gitlab: '', gitea: '' };
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
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
|
* 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);
|
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)) {
|
if (!Array.isArray(entries)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -43,15 +65,55 @@ const normalizeDomainList = (entries: unknown): string[] => {
|
|||||||
return result;
|
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 = {
|
type GitProviderDomainsStore = {
|
||||||
domains: GitProviderDomains;
|
domains: GitProviderDomains;
|
||||||
|
apiBaseUrls: GitProviderApiBaseUrls;
|
||||||
setDomains: (provider: GitProviderName, domains: string[]) => void;
|
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<GitProviderDomainsStore>()(
|
export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
domains: EMPTY_DOMAINS,
|
domains: EMPTY_DOMAINS,
|
||||||
|
apiBaseUrls: EMPTY_API_BASE_URLS,
|
||||||
setDomains: (provider, domains) => {
|
setDomains: (provider, domains) => {
|
||||||
set({
|
set({
|
||||||
domains: {
|
domains: {
|
||||||
@@ -60,21 +122,53 @@ export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
|
|||||||
},
|
},
|
||||||
} as Partial<GitProviderDomainsStore>);
|
} as Partial<GitProviderDomainsStore>);
|
||||||
},
|
},
|
||||||
|
setApiBaseUrl: (provider, url) => {
|
||||||
|
set({
|
||||||
|
apiBaseUrls: {
|
||||||
|
...get().apiBaseUrls,
|
||||||
|
[provider]: normalizeApiBaseUrl(url),
|
||||||
|
},
|
||||||
|
} as Partial<GitProviderDomainsStore>);
|
||||||
|
},
|
||||||
|
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<GitProviderDomainsStore>);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: DOMAINS_STORAGE_KEY,
|
name: DOMAINS_STORAGE_KEY,
|
||||||
storage: createDeferredSafeJSONStorage(),
|
storage: createDeferredSafeJSONStorage(),
|
||||||
partialize: (state) => ({ domains: state.domains }),
|
partialize: (state) => ({ domains: state.domains, apiBaseUrls: state.apiBaseUrls }),
|
||||||
merge: (persistedState, currentState) => {
|
merge: (persistedState, currentState) => {
|
||||||
const persisted = (persistedState as { domains?: Partial<GitProviderDomains> } | null)?.domains;
|
const persisted = (persistedState as {
|
||||||
|
domains?: Partial<GitProviderDomains>;
|
||||||
|
apiBaseUrls?: Partial<GitProviderApiBaseUrls>;
|
||||||
|
} | null);
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
// Missing or malformed entries collapse to empty arrays so the full
|
// Missing or malformed entries collapse to the canonical shape so the
|
||||||
// three-provider shape is always produced after hydration.
|
// full three-provider shape is always produced after hydration.
|
||||||
domains: {
|
domains: {
|
||||||
github: normalizeDomainList(persisted?.github),
|
github: normalizeDomainList(persisted?.domains?.github),
|
||||||
gitlab: normalizeDomainList(persisted?.gitlab),
|
gitlab: normalizeDomainList(persisted?.domains?.gitlab),
|
||||||
gitea: normalizeDomainList(persisted?.gitea),
|
gitea: normalizeDomainList(persisted?.domains?.gitea),
|
||||||
|
},
|
||||||
|
apiBaseUrls: {
|
||||||
|
github: normalizeApiBaseUrl(persisted?.apiBaseUrls?.github),
|
||||||
|
gitlab: normalizeApiBaseUrl(persisted?.apiBaseUrls?.gitlab),
|
||||||
|
gitea: normalizeApiBaseUrl(persisted?.apiBaseUrls?.gitea),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -234,6 +234,42 @@ const stripDerived = (source: Record<string, unknown>): Record<string, unknown>
|
|||||||
return next;
|
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<string, unknown> | undefined => {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const source = input as Record<string, unknown>;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown> = {};
|
||||||
|
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;
|
let eagerMigrationAttempted = false;
|
||||||
|
|
||||||
// Read the merged persisted settings: shared file is canonical (synced with
|
// Read the merged persisted settings: shared file is canonical (synced with
|
||||||
@@ -326,6 +362,15 @@ export const persistSettings = async (changes: Record<string, unknown>, 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') {
|
if (typeof restChanges.opencodeBinary === 'string') {
|
||||||
restChanges.opencodeBinary = restChanges.opencodeBinary.trim();
|
restChanges.opencodeBinary = restChanges.opencodeBinary.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
- `clearGiteaAuth()`: remove the current account.
|
- `clearGiteaAuth()`: remove the current account.
|
||||||
- `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input.
|
- `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input.
|
||||||
- `GITEA_AUTH_FILE`: auth file path.
|
- `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`)
|
### Client (`client.js`)
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
|
|
||||||
- Auth storage: `~/.config/openchamber/gitea-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
|
- Auth storage: `~/.config/openchamber/gitea-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
|
||||||
- Writes are atomic (tmp file + rename) and file mode is `0o600`.
|
- 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:<first8>` when the username is missing.
|
- Account id: `` `${host}:${username}` `` (e.g. `gitea.example.com:alice`), falling back to `token:<first8>` when the username is missing.
|
||||||
- Auth header on every request: `Authorization: token <pat>`.
|
- Auth header on every request: `Authorization: token <pat>`.
|
||||||
- Gitea's `GET /user` uses `login`/`full_name`/`html_url`; `setGiteaAuth` accepts both that and the `username`/`web_url` variants.
|
- 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 |
|
| Method | Path | Shape |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/gitea/auth/status` | `{ connected, user?, accounts[] }` |
|
| 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 or base URL |
|
| 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 |
|
| POST | `/api/gitea/auth/activate` | body `{ accountId }` -> `{ connected, user, accounts }`; `404` unknown account |
|
||||||
| DELETE | `/api/gitea/auth` | `{ removed }` |
|
| DELETE | `/api/gitea/auth` | `{ removed }` |
|
||||||
| GET | `/api/gitea/me` | `{ username, id, name, avatarUrl, webUrl, email? }`; `401` when not connected |
|
| GET | `/api/gitea/me` | `{ username, id, name, avatarUrl, webUrl, email? }`; `401` when not connected |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
import { getProviderApiBaseUrl } from '../git-providers/config.js';
|
||||||
|
|
||||||
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||||
? path.resolve(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_DIR = OPENCHAMBER_DATA_DIR;
|
||||||
const STORAGE_FILE = path.join(STORAGE_DIR, 'gitea-auth.json');
|
const STORAGE_FILE = path.join(STORAGE_DIR, 'gitea-auth.json');
|
||||||
|
|
||||||
// Gitea/Forgejo are self-hosted — there is deliberately NO default base URL.
|
// Gitea/Forgejo are self-hosted — there is deliberately NO built-in default
|
||||||
// The instance URL is always user-provided (see `normalizeBaseUrl`); auth.js
|
// base URL. The instance URL is always user-provided (see `normalizeBaseUrl`);
|
||||||
// never invents a host for a stored account.
|
// 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() {
|
function ensureStorageDir() {
|
||||||
if (!fs.existsSync(STORAGE_DIR)) {
|
if (!fs.existsSync(STORAGE_DIR)) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export {
|
|||||||
clearGiteaAuth,
|
clearGiteaAuth,
|
||||||
normalizeBaseUrl,
|
normalizeBaseUrl,
|
||||||
GITEA_AUTH_FILE,
|
GITEA_AUTH_FILE,
|
||||||
|
getGiteaDefaultBaseUrl,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export function registerGiteaRoutes(app, options = {}) {
|
|||||||
|
|
||||||
app.get('/api/gitea/auth/status', async (_req, res) => {
|
app.get('/api/gitea/auth/status', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { getGiteaAuth, getGiteaAuthAccounts, clearGiteaAuth } = await getGiteaLibraries();
|
const { getGiteaAuth, getGiteaAuthAccounts, clearGiteaAuth, getGiteaDefaultBaseUrl } = await getGiteaLibraries();
|
||||||
const auth = getGiteaAuth();
|
const auth = getGiteaAuth();
|
||||||
const accounts = getGiteaAuthAccounts();
|
const accounts = getGiteaAuthAccounts();
|
||||||
if (!auth?.accessToken) {
|
if (!auth?.accessToken) {
|
||||||
@@ -261,6 +261,7 @@ export function registerGiteaRoutes(app, options = {}) {
|
|||||||
connected: true,
|
connected: true,
|
||||||
...(user ? { user } : {}),
|
...(user ? { user } : {}),
|
||||||
accounts,
|
accounts,
|
||||||
|
defaultBaseUrl: getGiteaDefaultBaseUrl(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get Gitea auth status:', 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' });
|
return res.status(400).json({ error: 'accessToken is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { normalizeBaseUrl, setGiteaAuth, getGiteaAuthAccounts } = await getGiteaLibraries();
|
const { normalizeBaseUrl, setGiteaAuth, getGiteaAuthAccounts, getGiteaDefaultBaseUrl } = await getGiteaLibraries();
|
||||||
const baseUrl = normalizeBaseUrl(req.body?.baseUrl);
|
const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || getGiteaDefaultBaseUrl();
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
return res.status(400).json({ error: 'baseUrl is required and must be a valid URL' });
|
return res.status(400).json({ error: 'baseUrl is required and must be a valid URL' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
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 express from 'express';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
|
||||||
@@ -201,7 +201,6 @@ describe('Gitea auth routes', () => {
|
|||||||
test('me returns the connected user', async () => {
|
test('me returns the connected user', async () => {
|
||||||
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
|
setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser });
|
||||||
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
|
scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]);
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const response = await request(app).get('/api/gitea/me');
|
const response = await request(app).get('/api/gitea/me');
|
||||||
expect(response.status).toBe(200);
|
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', () => {
|
describe('Gitea data routes', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAuthFile();
|
resetAuthFile();
|
||||||
|
|||||||
@@ -32,18 +32,26 @@
|
|||||||
|
|
||||||
### Device flow
|
### Device flow
|
||||||
|
|
||||||
- `startDeviceFlow({ clientId, scope })`: request device code.
|
- `startDeviceFlow({ clientId, scope, webOrigin? })`: request device code.
|
||||||
- `exchangeDeviceCode({ clientId, deviceCode })`: poll for access token.
|
- `exchangeDeviceCode({ clientId, deviceCode, webOrigin? })`: poll for access token.
|
||||||
|
|
||||||
### Octokit
|
### Octokit
|
||||||
|
|
||||||
- `getOctokitOrNull()`: current Octokit or `null`.
|
- `getOctokitOrNull()`: current Octokit or `null`.
|
||||||
|
- `createOctokit(token, baseUrl?)`: Octokit factory; the optional `baseUrl` (GitHub Enterprise API base) is passed to the Octokit constructor.
|
||||||
|
|
||||||
### Repo
|
### 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.
|
- `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 and config
|
||||||
|
|
||||||
- Auth storage: `~/.config/openchamber/github-auth.json`
|
- Auth storage: `~/.config/openchamber/github-auth.json`
|
||||||
|
|||||||
@@ -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 DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
const DEFAULT_WEB_ORIGIN = 'https://github.com';
|
||||||
|
|
||||||
const encodeForm = (params) => {
|
const encodeForm = (params) => {
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
@@ -32,16 +31,19 @@ async function postForm(url, params) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startDeviceFlow({ clientId, scope }) {
|
const deviceCodeUrl = (webOrigin) => `${(webOrigin || DEFAULT_WEB_ORIGIN).replace(/\/+$/, '')}/login/device/code`;
|
||||||
return postForm(DEVICE_CODE_URL, {
|
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,
|
client_id: clientId,
|
||||||
scope,
|
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.
|
// 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,
|
client_id: clientId,
|
||||||
device_code: deviceCode,
|
device_code: deviceCode,
|
||||||
grant_type: DEVICE_GRANT_TYPE,
|
grant_type: DEVICE_GRANT_TYPE,
|
||||||
|
|||||||
@@ -28,3 +28,8 @@ export {
|
|||||||
parseGitHubRemoteUrl,
|
parseGitHubRemoteUrl,
|
||||||
resolveGitHubRepoFromDirectory,
|
resolveGitHubRepoFromDirectory,
|
||||||
} from './repo/index.js';
|
} from './repo/index.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getProviderApiBaseUrl,
|
||||||
|
githubWebOriginFromApiBase,
|
||||||
|
} from '../git-providers/config.js';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Octokit } from '@octokit/rest';
|
import { Octokit } from '@octokit/rest';
|
||||||
import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js';
|
import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js';
|
||||||
import { getGhCliToken } from './gh-cli-credential.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,
|
// 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
|
// 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. */
|
/** Create an Octokit instance with per-request timeout + ETag revalidation. */
|
||||||
export function createOctokit(token) {
|
export function createOctokit(token, baseUrl) {
|
||||||
return new Octokit({ auth: token, request: { fetch: createConditionalFetch(token) } });
|
return new Octokit({
|
||||||
|
auth: token,
|
||||||
|
...(baseUrl ? { baseUrl } : {}),
|
||||||
|
request: { fetch: createConditionalFetch(token) },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOctokitOrNull() {
|
export function getOctokitOrNull() {
|
||||||
@@ -80,5 +85,5 @@ export function getOctokitOrNull() {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return createOctokit(token);
|
return createOctokit(token, getProviderApiBaseUrl('github'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { stat } from 'node:fs/promises';
|
|||||||
import { getRemotes, getStatus } from '../git/index.js';
|
import { getRemotes, getStatus } from '../git/index.js';
|
||||||
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
|
import { resolveGitHubRepoFromDirectory } from './repo/index.js';
|
||||||
import { noteIfGitHubRateLimit } from './rate-limit.js';
|
import { noteIfGitHubRateLimit } from './rate-limit.js';
|
||||||
|
import { getProviderApiBaseUrl, githubWebOriginFromApiBase } from '../git-providers/config.js';
|
||||||
|
|
||||||
|
const getGitHubWebOrigin = () => githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
|
||||||
|
|
||||||
const directoryExists = async (dir) => {
|
const directoryExists = async (dir) => {
|
||||||
if (!dir) return false;
|
if (!dir) return false;
|
||||||
@@ -295,7 +298,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
|
|||||||
pushCandidate({
|
pushCandidate({
|
||||||
owner: parent.owner.login,
|
owner: parent.owner.login,
|
||||||
repo: parent.name,
|
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);
|
}, candidate.remoteName, candidate.priority + 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +307,7 @@ const expandRepoNetwork = async (octokit, candidates) => {
|
|||||||
pushCandidate({
|
pushCandidate({
|
||||||
owner: source.owner.login,
|
owner: source.owner.login,
|
||||||
repo: source.name,
|
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);
|
}, candidate.remoteName, candidate.priority + 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { resolveGitHubRepoFromDirectory } from './index.js';
|
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_TTL_MS = 5 * 60_000;
|
||||||
const REPO_METADATA_CACHE_MAX_ENTRIES = 200;
|
const REPO_METADATA_CACHE_MAX_ENTRIES = 200;
|
||||||
@@ -75,7 +78,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
|
|||||||
result.push({
|
result.push({
|
||||||
owner: parent.owner.login,
|
owner: parent.owner.login,
|
||||||
repo: parent.name,
|
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',
|
source: 'upstream',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -89,7 +92,7 @@ export async function resolveRepoNetwork(octokit, directory, remoteName = 'origi
|
|||||||
result.push({
|
result.push({
|
||||||
owner: source.owner.login,
|
owner: source.owner.login,
|
||||||
repo: source.name,
|
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',
|
source: 'upstream',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { getRemoteUrl } from '../../git/index.js';
|
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') {
|
if (typeof raw !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -10,34 +21,36 @@ export const parseGitHubRemoteUrl = (raw) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// git@github.com:OWNER/REPO.git
|
// git@github.com:OWNER/REPO.git
|
||||||
if (value.startsWith('git@github.com:')) {
|
const scpPrefix = `git@${host}:`;
|
||||||
const rest = value.slice('git@github.com:'.length);
|
if (value.startsWith(scpPrefix)) {
|
||||||
|
const rest = value.slice(scpPrefix.length);
|
||||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||||
const [owner, repo] = cleaned.split('/');
|
const [owner, repo] = cleaned.split('/');
|
||||||
if (!owner || !repo) return null;
|
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
|
// ssh://git@github.com/OWNER/REPO.git
|
||||||
if (value.startsWith('ssh://git@github.com/')) {
|
const sshPrefix = `ssh://git@${host}/`;
|
||||||
const rest = value.slice('ssh://git@github.com/'.length);
|
if (value.startsWith(sshPrefix)) {
|
||||||
|
const rest = value.slice(sshPrefix.length);
|
||||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||||
const [owner, repo] = cleaned.split('/');
|
const [owner, repo] = cleaned.split('/');
|
||||||
if (!owner || !repo) return null;
|
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)
|
// https://github.com/OWNER/REPO(.git)
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
if (url.hostname !== 'github.com') {
|
if (url.hostname !== host) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
|
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
|
||||||
const [owner, repo] = cleaned.split('/');
|
const [owner, repo] = cleaned.split('/');
|
||||||
if (!owner || !repo) return null;
|
if (!owner || !repo) return null;
|
||||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
return { owner, repo, url: `${webOrigin}/${owner}/${repo}` };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -48,8 +61,10 @@ export async function resolveGitHubRepoFromDirectory(directory, remoteName = 'or
|
|||||||
if (!remoteUrl) {
|
if (!remoteUrl) {
|
||||||
return { repo: null, remoteUrl: null };
|
return { repo: null, remoteUrl: null };
|
||||||
}
|
}
|
||||||
|
const webOrigin = getGitHubWebOrigin();
|
||||||
|
const host = webHostFromOrigin(webOrigin);
|
||||||
return {
|
return {
|
||||||
repo: parseGitHubRemoteUrl(remoteUrl),
|
repo: parseGitHubRemoteUrl(remoteUrl, { host, webOrigin }),
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export function registerGitHubRoutes(app) {
|
|||||||
|
|
||||||
app.post('/api/github/auth/start', async (_req, res) => {
|
app.post('/api/github/auth/start', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
|
const { getGitHubClientId, getGitHubScopes, startDeviceFlow, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries();
|
||||||
const clientId = getGitHubClientId();
|
const clientId = getGitHubClientId();
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -330,10 +330,12 @@ export function registerGitHubRoutes(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scope = getGitHubScopes();
|
const scope = getGitHubScopes();
|
||||||
|
const webOrigin = githubWebOriginFromApiBase(getProviderApiBaseUrl('github'));
|
||||||
|
|
||||||
const payload = await startDeviceFlow({
|
const payload = await startDeviceFlow({
|
||||||
clientId,
|
clientId,
|
||||||
scope,
|
scope,
|
||||||
|
webOrigin,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -353,7 +355,7 @@ export function registerGitHubRoutes(app) {
|
|||||||
|
|
||||||
app.post('/api/github/auth/complete', async (req, res) => {
|
app.post('/api/github/auth/complete', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts, githubWebOriginFromApiBase, getProviderApiBaseUrl } = await getGitHubLibraries();
|
||||||
const clientId = getGitHubClientId();
|
const clientId = getGitHubClientId();
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -369,7 +371,10 @@ export function registerGitHubRoutes(app) {
|
|||||||
return res.status(400).json({ error: 'deviceCode is required' });
|
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) {
|
if (payload?.error) {
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -385,7 +390,7 @@ export function registerGitHubRoutes(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { createOctokit } = await import('./octokit.js');
|
const { createOctokit } = await import('./octokit.js');
|
||||||
const octokit = createOctokit(accessToken);
|
const octokit = createOctokit(accessToken, apiBase);
|
||||||
const user = await getGitHubUserSummary(octokit);
|
const user = await getGitHubUserSummary(octokit);
|
||||||
|
|
||||||
setGitHubAuth({
|
setGitHubAuth({
|
||||||
@@ -1773,6 +1778,9 @@ export function registerGitHubRoutes(app) {
|
|||||||
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
const { resolveGitHubRepoFromDirectory } = await import('./index.js');
|
||||||
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
|
const { resolveRepoNetwork } = await import('./repo/fork-detection.js');
|
||||||
|
|
||||||
|
const { getProviderApiBaseUrl } = await getGitHubLibraries();
|
||||||
|
const apiBase = getProviderApiBaseUrl('github');
|
||||||
|
|
||||||
const repoNetwork = await resolveRepoNetwork(octokit, directory);
|
const repoNetwork = await resolveRepoNetwork(octokit, directory);
|
||||||
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
@@ -1817,7 +1825,7 @@ export function registerGitHubRoutes(app) {
|
|||||||
const issues = items
|
const issues = items
|
||||||
.filter((item) => !item?.pull_request)
|
.filter((item) => !item?.pull_request)
|
||||||
.map((item) => {
|
.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);
|
const matched = reposToQuery.find((r) => `${r.owner}/${r.repo}` === repoFullName);
|
||||||
return mapIssueSummary(item, matched || reposToQuery[0]);
|
return mapIssueSummary(item, matched || reposToQuery[0]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
- `clearGitLabAuth()`: remove the current account.
|
- `clearGitLabAuth()`: remove the current account.
|
||||||
- `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input.
|
- `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input.
|
||||||
- `GITLAB_AUTH_FILE`: auth file path.
|
- `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`)
|
### Client (`client.js`)
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
|
|
||||||
- Auth storage: `~/.config/openchamber/gitlab-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
|
- Auth storage: `~/.config/openchamber/gitlab-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
|
||||||
- Writes are atomic (tmp file + rename) and file mode is `0o600`.
|
- 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:<first8>` when the username is missing.
|
- Account id: `` `${host}:${username}` `` (e.g. `gitlab.com:alice`), falling back to `token:<first8>` when the username is missing.
|
||||||
- Auth header on every request: `PRIVATE-TOKEN: <pat>`.
|
- Auth header on every request: `PRIVATE-TOKEN: <pat>`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
import { getProviderApiBaseUrl } from '../git-providers/config.js';
|
||||||
|
|
||||||
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||||
? path.resolve(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_DIR = OPENCHAMBER_DATA_DIR;
|
||||||
const STORAGE_FILE = path.join(STORAGE_DIR, 'gitlab-auth.json');
|
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';
|
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() {
|
function ensureStorageDir() {
|
||||||
if (!fs.existsSync(STORAGE_DIR)) {
|
if (!fs.existsSync(STORAGE_DIR)) {
|
||||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||||
@@ -119,7 +127,7 @@ function normalizeAuthEntry(entry) {
|
|||||||
if (!entry || typeof entry !== 'object') return null;
|
if (!entry || typeof entry !== 'object') return null;
|
||||||
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
|
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
|
||||||
if (!accessToken) return null;
|
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 username = typeof entry.username === 'string' ? entry.username : '';
|
||||||
|
|
||||||
const accountId = resolveAccountId({
|
const accountId = resolveAccountId({
|
||||||
@@ -218,7 +226,7 @@ export function getGitLabAuthAccounts() {
|
|||||||
avatarUrl: entry.avatarUrl || null,
|
avatarUrl: entry.avatarUrl || null,
|
||||||
webUrl: entry.webUrl || null,
|
webUrl: entry.webUrl || null,
|
||||||
},
|
},
|
||||||
baseUrl: entry.baseUrl || DEFAULT_GITLAB_BASE_URL,
|
baseUrl: entry.baseUrl || getGitLabDefaultBaseUrl(),
|
||||||
current: Boolean(entry.current),
|
current: Boolean(entry.current),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -227,7 +235,7 @@ export function setGitLabAuth({ accessToken, baseUrl, user }) {
|
|||||||
if (!accessToken || typeof accessToken !== 'string') {
|
if (!accessToken || typeof accessToken !== 'string') {
|
||||||
throw new Error('accessToken is required');
|
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'
|
const normalizedUser = user && typeof user === 'object'
|
||||||
? {
|
? {
|
||||||
username: typeof user.username === 'string' ? user.username : undefined,
|
username: typeof user.username === 'string' ? user.username : undefined,
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ const {
|
|||||||
normalizeBaseUrl,
|
normalizeBaseUrl,
|
||||||
GITLAB_AUTH_FILE,
|
GITLAB_AUTH_FILE,
|
||||||
DEFAULT_GITLAB_BASE_URL,
|
DEFAULT_GITLAB_BASE_URL,
|
||||||
|
getGitLabDefaultBaseUrl,
|
||||||
} = await import('./auth.js');
|
} = await import('./auth.js');
|
||||||
|
|
||||||
|
const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json');
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
|
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@@ -25,6 +28,9 @@ afterEach(() => {
|
|||||||
if (fs.existsSync(GITLAB_AUTH_FILE)) {
|
if (fs.existsSync(GITLAB_AUTH_FILE)) {
|
||||||
fs.unlinkSync(GITLAB_AUTH_FILE);
|
fs.unlinkSync(GITLAB_AUTH_FILE);
|
||||||
}
|
}
|
||||||
|
if (fs.existsSync(SETTINGS_FILE)) {
|
||||||
|
fs.unlinkSync(SETTINGS_FILE);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const aliceUser = {
|
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', () => {
|
describe('clearGitLabAuth', () => {
|
||||||
test('removes the current account and deletes the file when empty', () => {
|
test('removes the current account and deletes the file when empty', () => {
|
||||||
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
|
setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser });
|
||||||
|
|||||||
@@ -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
|
// Per-request timeout for every GitLab call. GitLab REST can hang under load
|
||||||
// (especially self-hosted instances); bounding each request lets the caller
|
// (especially self-hosted instances); bounding each request lets the caller
|
||||||
@@ -113,7 +113,7 @@ export function isGitLabRateLimited() {
|
|||||||
// ---- Response helpers ----
|
// ---- Response helpers ----
|
||||||
|
|
||||||
const joinApiUrl = (baseUrl, path) => {
|
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}`) : '';
|
const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : '';
|
||||||
return `${base}/api/v4${p}`;
|
return `${base}/api/v4${p}`;
|
||||||
};
|
};
|
||||||
@@ -305,7 +305,7 @@ export function createGitLabClient({ token, baseUrl }) {
|
|||||||
|
|
||||||
function normalizeBaseForClient(baseUrl) {
|
function normalizeBaseForClient(baseUrl) {
|
||||||
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
|
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
|
||||||
return DEFAULT_GITLAB_BASE_URL;
|
return getGitLabDefaultBaseUrl();
|
||||||
}
|
}
|
||||||
return baseUrl.trim().replace(/\/+$/, '');
|
return baseUrl.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export {
|
|||||||
normalizeBaseUrl,
|
normalizeBaseUrl,
|
||||||
GITLAB_AUTH_FILE,
|
GITLAB_AUTH_FILE,
|
||||||
DEFAULT_GITLAB_BASE_URL,
|
DEFAULT_GITLAB_BASE_URL,
|
||||||
|
getGitLabDefaultBaseUrl,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -314,11 +314,12 @@ export function registerGitLabRoutes(app, options = {}) {
|
|||||||
|
|
||||||
app.get('/api/gitlab/auth/status', async (_req, res) => {
|
app.get('/api/gitlab/auth/status', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { getGitLabAuth, getGitLabAuthAccounts, clearGitLabAuth, DEFAULT_GITLAB_BASE_URL } = await getGitLabLibraries();
|
const { getGitLabAuth, getGitLabAuthAccounts, clearGitLabAuth, getGitLabDefaultBaseUrl } = await getGitLabLibraries();
|
||||||
const auth = getGitLabAuth();
|
const auth = getGitLabAuth();
|
||||||
const accounts = getGitLabAuthAccounts();
|
const accounts = getGitLabAuthAccounts();
|
||||||
|
const defaultBaseUrl = getGitLabDefaultBaseUrl();
|
||||||
if (!auth?.accessToken) {
|
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();
|
const client = await getClient();
|
||||||
@@ -327,7 +328,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
|||||||
const resp = await client.user();
|
const resp = await client.user();
|
||||||
if (resp.status === 401 || resp.status === 403) {
|
if (resp.status === 401 || resp.status === 403) {
|
||||||
clearGitLabAuth();
|
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) {
|
if (resp.status === 200 && resp.data) {
|
||||||
user = mapGitLabUser(resp.data);
|
user = mapGitLabUser(resp.data);
|
||||||
@@ -338,7 +339,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
|||||||
connected: true,
|
connected: true,
|
||||||
...(user ? { user } : {}),
|
...(user ? { user } : {}),
|
||||||
accounts,
|
accounts,
|
||||||
defaultBaseUrl: DEFAULT_GITLAB_BASE_URL,
|
defaultBaseUrl,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get GitLab auth status:', 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' });
|
return res.status(400).json({ error: 'accessToken is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { normalizeBaseUrl, DEFAULT_GITLAB_BASE_URL, setGitLabAuth, getGitLabAuthAccounts } = await getGitLabLibraries();
|
const { normalizeBaseUrl, getGitLabDefaultBaseUrl, setGitLabAuth, getGitLabAuthAccounts } = await getGitLabLibraries();
|
||||||
const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || DEFAULT_GITLAB_BASE_URL;
|
const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || getGitLabDefaultBaseUrl();
|
||||||
|
|
||||||
const { createGitLabClient } = await getGitLabLibraries();
|
const { createGitLabClient } = await getGitLabLibraries();
|
||||||
const client = createGitLabClient({ token: accessToken, baseUrl });
|
const client = createGitLabClient({ token: accessToken, baseUrl });
|
||||||
@@ -371,7 +372,7 @@ export function registerGitLabRoutes(app, options = {}) {
|
|||||||
connected: true,
|
connected: true,
|
||||||
user: mapGitLabUser(resp.data),
|
user: mapGitLabUser(resp.data),
|
||||||
accounts: getGitLabAuthAccounts(),
|
accounts: getGitLabAuthAccounts(),
|
||||||
defaultBaseUrl: DEFAULT_GITLAB_BASE_URL,
|
defaultBaseUrl: getGitLabDefaultBaseUrl(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to connect GitLab:', 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' });
|
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);
|
const activated = activateGitLabAuth(accountId);
|
||||||
if (!activated) {
|
if (!activated) {
|
||||||
return res.status(404).json({ error: 'GitLab account not found' });
|
return res.status(404).json({ error: 'GitLab account not found' });
|
||||||
@@ -394,8 +395,9 @@ export function registerGitLabRoutes(app, options = {}) {
|
|||||||
|
|
||||||
const auth = getGitLabAuth();
|
const auth = getGitLabAuth();
|
||||||
const accounts = getGitLabAuthAccounts();
|
const accounts = getGitLabAuthAccounts();
|
||||||
|
const defaultBaseUrl = getGitLabDefaultBaseUrl();
|
||||||
if (!auth?.accessToken) {
|
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
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to activate GitLab account:', error);
|
console.error('Failed to activate GitLab account:', error);
|
||||||
return res.status(500).json({ error: error.message || 'Failed to activate GitLab account' });
|
return res.status(500).json({ error: error.message || 'Failed to activate GitLab account' });
|
||||||
|
|||||||
@@ -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.
|
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
|
||||||
- Returned API:
|
- Returned API:
|
||||||
- `normalizePwaAppName(value, fallback?)`
|
- `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)`
|
- `mergePersistedSettings(current, changes)`
|
||||||
- `formatSettingsResponse(settings)`
|
- `formatSettingsResponse(settings)`
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { sanitizeGitProviders } from '../git-providers/config.js';
|
||||||
|
|
||||||
export const createSettingsHelpers = (dependencies) => {
|
export const createSettingsHelpers = (dependencies) => {
|
||||||
const {
|
const {
|
||||||
normalizePathForPersistence,
|
normalizePathForPersistence,
|
||||||
@@ -481,6 +483,10 @@ export const createSettingsHelpers = (dependencies) => {
|
|||||||
const trimmed = candidate.gitModelId.trim();
|
const trimmed = candidate.gitModelId.trim();
|
||||||
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
|
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
const gitProviders = sanitizeGitProviders(candidate.gitProviders);
|
||||||
|
if (gitProviders) {
|
||||||
|
result.gitProviders = gitProviders;
|
||||||
|
}
|
||||||
if (typeof candidate.pwaAppName === 'string') {
|
if (typeof candidate.pwaAppName === 'string') {
|
||||||
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
|
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -465,6 +465,46 @@ describe('settings helpers', () => {
|
|||||||
expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
|
expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
|
||||||
expect(sanitized.recentModels).toEqual(payload.recentModels);
|
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', () => {
|
describe('session retention settings persistence', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user