diff --git a/package.json b/package.json
index f9c145f5..ca220980 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"icons:sprite": "node scripts/generate-file-type-sprite.mjs",
"icons:generate": "bun run scripts/generate-icon-sprite.mjs",
"themes:port:opencode": "tsx scripts/port-opencode-theme.ts",
+ "gitea:live-test": "bun packages/web/scripts/gitea-live-test.ts",
"version:bump": "node scripts/bump-version.mjs",
"release:prepare": "bun run build && bun run type-check && bun run lint",
"release:test": "./scripts/test-release-build.sh",
diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
index f7d537cb..87a541b0 100644
--- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
@@ -442,8 +442,16 @@ export const GitHubSettings: React.FC<{ embedded?: boolean }> = ({ embedded = fa
)}
-
-
+ {connected ? (
+ <>
+
+
+ >
+ ) : (
+
+ {t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.github') })}
+
+ )}
>
);
diff --git a/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx b/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx
index 16dddb5f..b45adfb1 100644
--- a/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/GitLabSettings.tsx
@@ -292,8 +292,16 @@ export const GitLabSettings: React.FC<{ embedded?: boolean }> = ({ embedded = fa
-
-
+ {connected ? (
+ <>
+
+
+ >
+ ) : (
+
+ {t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.gitlab') })}
+
+ )}
>
);
diff --git a/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx b/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx
index c56562bd..8f43f302 100644
--- a/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx
+++ b/packages/ui/src/components/sections/openchamber/GiteaSettings.tsx
@@ -302,8 +302,16 @@ export const GiteaSettings: React.FC<{ embedded?: boolean }> = ({ embedded = fal
-
-
+ {connected ? (
+ <>
+
+
+ >
+ ) : (
+
+ {t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.gitea') })}
+
+ )}
>
);
diff --git a/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx b/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx
index 9115cefa..a2c5b783 100644
--- a/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx
+++ b/packages/ui/src/components/sections/projects/ProjectGitProvidersSection.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { Input } from '@/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import { reportSettingsSaveState } from '@/lib/persistence';
import { ProjectSettingsSubsection } from '@/components/sections/projects/ProjectSettingsSubsection';
@@ -10,12 +11,21 @@ import {
type GitProviderApiBaseUrls,
type GitProviderName,
} from '@/stores/useGitProviderDomainsStore';
+import { Icon } from "@/components/icon/Icon";
+import type { IconName } from "@/components/icon/icons";
+import { useGitProvider } from '@/lib/gitProvider';
import type { ProjectRef } from '@/lib/openchamberConfig';
const GIT_PROVIDERS: GitProviderName[] = ['github', 'gitlab', 'gitea'];
const EMPTY_API_BASE_URLS: GitProviderApiBaseUrls = { github: '', gitlab: '', gitea: '' };
+const PROVIDER_ICONS: Record = {
+ github: 'github-fill',
+ gitlab: 'gitlab',
+ gitea: 'gitea',
+};
+
/**
* Read the per-provider `apiBaseUrl` overrides out of an untyped server
* `gitProviders` payload. Unknown or malformed entries collapse to ''.
@@ -37,21 +47,40 @@ const readProjectApiBaseUrls = (gitProviders: unknown): GitProviderApiBaseUrls =
return result;
};
+/**
+ * Read the forced `provider` out of an untyped server `gitProviders` payload.
+ * Anything outside github|gitlab|gitea collapses to null (auto-detect).
+ */
+const readProjectProvider = (gitProviders: unknown): GitProviderName | null => {
+ if (!gitProviders || typeof gitProviders !== 'object' || Array.isArray(gitProviders)) {
+ return null;
+ }
+ const provider = (gitProviders as Record).provider;
+ return typeof provider === 'string' && GIT_PROVIDERS.includes(provider as GitProviderName)
+ ? (provider as GitProviderName)
+ : null;
+};
+
/**
* Build the full per-project `gitProviders` object for the server. The server
* replaces the whole `gitProviders` key on PUT, so every provider is sent
- * together; providers with an empty override are omitted.
+ * together; providers with an empty override are omitted, and the forced
+ * `provider` is included only when one is selected.
*/
const buildGitProvidersPayload = (
drafts: GitProviderApiBaseUrls,
-): Partial> => {
- const payload: Partial> = {};
- for (const provider of GIT_PROVIDERS) {
- const url = drafts[provider].trim();
+ provider: GitProviderName | null,
+): { provider?: GitProviderName } & Partial> => {
+ const payload: Partial> & { provider?: GitProviderName } = {};
+ for (const entryProvider of GIT_PROVIDERS) {
+ const url = drafts[entryProvider].trim();
if (url) {
- payload[provider] = { apiBaseUrl: url };
+ payload[entryProvider] = { apiBaseUrl: url };
}
}
+ if (provider) {
+ payload.provider = provider;
+ }
return payload;
};
@@ -60,16 +89,20 @@ type ProjectGitProvidersSectionProps = {
};
/**
- * Per-project git provider API base URL overrides. Each provider's override is
- * persisted through the project-scoped `/api/projects/:id/git-providers` route;
- * empty overrides fall back to the global server settings value. Commits on
- * blur or Enter and re-hydrates the detection store so a new host applies
- * immediately.
+ * Per-project git provider overrides on top of auto-detection: a forced
+ * provider (auto-detect or github/gitlab/gitea) and a single API base URL
+ * override for the active provider. Persisted through the project-scoped
+ * `/api/projects/:id/git-providers` route; empty overrides fall back to the
+ * global server settings value. The one API URL field follows the provider
+ * selector — the selected provider when forced, otherwise the currently
+ * detected one. Commits on blur/Enter (or provider selection) and re-hydrates
+ * the detection store so a new host/provider applies immediately.
*/
export const ProjectGitProvidersSection: React.FC = ({ projectRef }) => {
const { t } = useI18n();
const globalApiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls);
const [drafts, setDrafts] = React.useState({ ...EMPTY_API_BASE_URLS });
+ const [provider, setProvider] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
const hasEditedRef = React.useRef(false);
const committedSnapshotRef = React.useRef('');
@@ -84,10 +117,12 @@ export const ProjectGitProvidersSection: React.FC {
- const payload = buildGitProvidersPayload(drafts);
+ const commit = React.useCallback((nextProvider?: GitProviderName | null) => {
+ const providerValue = nextProvider === undefined ? provider : nextProvider;
+ const payload = buildGitProvidersPayload(drafts, providerValue);
const snapshot = JSON.stringify(payload);
if (snapshot === committedSnapshotRef.current) {
// Blur with no real change: drop incidental whitespace from the drafts.
@@ -121,7 +157,75 @@ export const ProjectGitProvidersSection: React.FC {
+ hasEditedRef.current = true;
+ const next = value === 'auto' ? null : (value as GitProviderName);
+ setProvider(next);
+ commit(next);
+ }, [commit]);
+
+ // The live auto-detection result for this project's remote (null/'other'
+ // when nothing recognizable was found). Only feeds the field attribution
+ // when no provider is forced.
+ const detectedProvider = useGitProvider(projectRef.path);
+ const knownDetected = detectedProvider && detectedProvider !== 'other' ? detectedProvider : null;
+
+ // One API URL override, always for the active provider: the selected
+ // (forced) provider when set, otherwise whatever auto-detection currently
+ // yields for this project's remote.
+ const activeUrlProvider = provider ?? knownDetected;
+
+ const renderBaseUrlField = (entryProvider: GitProviderName) => {
+ const isEmpty = drafts[entryProvider].trim().length === 0;
+ // Show what the project inherits when no override is set: the global
+ // setting when present, otherwise the provider's default placeholder.
+ const inheritedUrl =
+ globalApiBaseUrls[entryProvider] || t(`settings.${entryProvider}.page.apiBaseUrl.placeholder`);
+ return (
+
+
+ {t(`settings.git.tabs.${entryProvider}`)}
+
+ )}
+ settingsItem={`projects.git-providers.${entryProvider}`}
+ descriptionPlacement="after"
+ description={
+ isEmpty && !isLoading
+ ? provider
+ ? t('settings.projects.page.gitProviders.inheritsGlobal', { url: inheritedUrl })
+ : t('settings.projects.page.gitProviders.provider.detectedAs', {
+ provider: t(`settings.git.tabs.${entryProvider}`),
+ url: inheritedUrl,
+ })
+ : undefined
+ }
+ >
+ {
+ hasEditedRef.current = true;
+ setDrafts((prev) => ({ ...prev, [entryProvider]: event.target.value }));
+ }}
+ onBlur={() => commit()}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ commit();
+ }
+ }}
+ placeholder={t(`settings.${entryProvider}.page.apiBaseUrl.placeholder`)}
+ aria-label={t(`settings.${entryProvider}.page.apiBaseUrl.label`)}
+ className="h-9"
+ />
+
+ );
+ };
return (
- {GIT_PROVIDERS.map((provider) => {
- const isEmpty = drafts[provider].trim().length === 0;
- // Show what the project inherits when no override is set: the global
- // setting when present, otherwise the provider's default placeholder.
- const inheritedUrl =
- globalApiBaseUrls[provider] || t(`settings.${provider}.page.apiBaseUrl.placeholder`);
- return (
-
- {
- hasEditedRef.current = true;
- setDrafts((prev) => ({ ...prev, [provider]: event.target.value }));
- }}
- onBlur={commit}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- commit();
- }
- }}
- placeholder={t(`settings.${provider}.page.apiBaseUrl.placeholder`)}
- aria-label={t(`settings.${provider}.page.apiBaseUrl.label`)}
- className="h-9"
- />
-
- );
- })}
+
+
+
+
+
+ {activeUrlProvider ? (
+ renderBaseUrlField(activeUrlProvider)
+ ) : (
+
+ {t('settings.projects.page.gitProviders.provider.autoUnknown')}
+
+ )}
+
);
-};
\ No newline at end of file
+};
diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx
index 8569379c..7f1c6b1c 100644
--- a/packages/ui/src/components/views/SettingsView.tsx
+++ b/packages/ui/src/components/views/SettingsView.tsx
@@ -9,6 +9,9 @@ import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useConfigStore } from '@/stores/useConfigStore';
+import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
+import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
+import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
@@ -236,6 +239,31 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
const runtimeCtx = React.useMemo(() => buildRuntimeContext(isDesktopApp, isMobile), [isDesktopApp, isMobile]);
+ const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false);
+ const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
+ const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
+ const gitlabConnected = useGitLabAuthStore((state) => state.status?.connected ?? false);
+ const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked);
+ const refreshGitLabAuthStatus = useGitLabAuthStore((state) => state.refreshStatus);
+ const giteaConnected = useGiteaAuthStore((state) => state.status?.connected ?? false);
+ const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked);
+ const refreshGiteaAuthStatus = useGiteaAuthStore((state) => state.refreshStatus);
+
+ // Populate git provider connection state on mount so search availability for
+ // the provider override fields matches what the settings page will render.
+ // refreshStatus dedupes when already checked and falls back to runtimeFetch.
+ React.useEffect(() => {
+ if (!githubAuthChecked) {
+ void refreshGitHubAuthStatus();
+ }
+ if (!gitlabAuthChecked) {
+ void refreshGitLabAuthStatus();
+ }
+ if (!giteaAuthChecked) {
+ void refreshGiteaAuthStatus();
+ }
+ }, [githubAuthChecked, refreshGitHubAuthStatus, gitlabAuthChecked, refreshGitLabAuthStatus, giteaAuthChecked, refreshGiteaAuthStatus]);
+
const visiblePages = React.useMemo(() => {
const allowedPages = visiblePageSlugs ? new Set(visiblePageSlugs) : null;
return SETTINGS_PAGE_METADATA
@@ -394,12 +422,20 @@ export const SettingsView: React.FC = ({ onClose, forceMobile
const settingsSearchResults = React.useMemo(() => {
return buildSettingsSearchResults({
query: settingsSearchQuery,
- runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac, isWindows, isLinux, isWindowsArm64 },
+ runtimeCtx: {
+ ...runtimeCtx,
+ isDesktopLocalOrigin,
+ isMac,
+ isWindows,
+ isLinux,
+ isWindowsArm64,
+ gitProvidersConnected: { github: githubConnected, gitlab: gitlabConnected, gitea: giteaConnected },
+ },
visiblePageSlugs,
t,
getPageTitle,
});
- }, [getPageTitle, isWindowsArm64, isDesktopLocalOrigin, isMac, isWindows, isLinux, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
+ }, [getPageTitle, githubConnected, gitlabConnected, giteaConnected, isWindowsArm64, isDesktopLocalOrigin, isMac, isWindows, isLinux, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
const prepareSettingsSearchTarget = React.useCallback((result: SettingsSearchResult): string => {
if (result.id.startsWith('agents.')) {
diff --git a/packages/ui/src/lib/gitProvider.ts b/packages/ui/src/lib/gitProvider.ts
index 1a21128f..666b96b5 100644
--- a/packages/ui/src/lib/gitProvider.ts
+++ b/packages/ui/src/lib/gitProvider.ts
@@ -8,6 +8,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import {
mergeGitProviderApiBaseUrls,
resolveProjectApiBaseUrls,
+ resolveProjectIdForDirectory,
} from '@/lib/projectGitProviders';
import {
useGitProviderDomainsStore,
@@ -181,8 +182,14 @@ export const useGitProvider = (directory: string | null | undefined): GitProvide
const domains = useGitProviderDomainsStore((state) => state.domains);
const apiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls);
const projectApiBaseUrls = useGitProviderDomainsStore((state) => state.projectApiBaseUrls);
+ const projectProviders = useGitProviderDomainsStore((state) => state.projectProviders);
const projects = useProjectsStore((state) => state.projects);
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
+ const projectId = useMemo(
+ () => resolveProjectIdForDirectory(directory, projects, worktreesByProject),
+ [directory, projects, worktreesByProject],
+ );
+ const forcedProvider = projectId ? (projectProviders[projectId] ?? null) : null;
const hosts = useMemo(
() => {
// Precedence per provider: project override > global server settings.
@@ -201,6 +208,11 @@ export const useGitProvider = (directory: string | null | undefined): GitProvide
setProvider(null);
return;
}
+ // A per-project forced provider wins over remote-host detection.
+ if (forcedProvider) {
+ setProvider(forcedProvider);
+ return;
+ }
let cancelled = false;
void resolveGitProvider(directory, hosts).then((resolved) => {
if (!cancelled) {
@@ -210,7 +222,7 @@ export const useGitProvider = (directory: string | null | undefined): GitProvide
return () => {
cancelled = true;
};
- }, [directory, hosts]);
+ }, [directory, hosts, forcedProvider]);
return provider;
};
diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts
index 7dd840b5..8e62a788 100644
--- a/packages/ui/src/lib/i18n/messages/de.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/de.settings.ts
@@ -1127,6 +1127,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'API-Basis-URLs der Git-Anbieter',
'settings.projects.page.gitProviders.description': 'Überschreibt die globale API-Basis-URL für dieses Projekt. Ist kein Wert gesetzt, wird die globale Einstellung verwendet.',
'settings.projects.page.gitProviders.inheritsGlobal': 'Erbt: {url}',
+ 'settings.projects.page.gitProviders.provider.label': 'Git-Anbieter',
+ 'settings.projects.page.gitProviders.provider.description': 'Erzwingt die Verwendung des ausgewählten Forges für das Repository dieses Projekts. Ohne Auswahl wird automatisch anhand des Remotes erkannt.',
+ 'settings.projects.page.gitProviders.provider.auto': 'Automatisch erkennen',
+ 'settings.projects.page.gitProviders.provider.autoUnknown': 'Der Anbieter konnte anhand des Remotes nicht ermittelt werden. Wähle oben einen Anbieter, um eine API-Basis-URL festzulegen.',
+ 'settings.projects.page.gitProviders.provider.detectedAs': 'Automatisch als {provider} erkannt. Erbt: {url}',
'settings.usage.sidebar.title': 'Nutzung',
'settings.usage.sidebar.total': 'Gesamt {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Automatisches Aktualisieren umschalten',
@@ -1708,6 +1713,7 @@ export const settingsDict = {
'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.gitProviders.overridesLocked.description': 'Verbinden Sie ein {provider}-Konto, um die API-Basis-URL und die Erkennungs-URLs zu konfigurieren.',
'settings.notifications.page.delivery.title': 'Benachrichtigungsübermittlung',
'settings.notifications.page.delivery.enableAria': 'Benachrichtigungen aktivieren',
'settings.notifications.page.delivery.enableLabel': 'Benachrichtigungen aktivieren',
diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts
index 3962a831..bcddd5ed 100644
--- a/packages/ui/src/lib/i18n/messages/en.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/en.settings.ts
@@ -1189,6 +1189,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git Provider API Base URLs',
'settings.projects.page.gitProviders.description': 'Override the global API base URL for this project. When unset, the global setting is used.',
'settings.projects.page.gitProviders.inheritsGlobal': 'Inherits: {url}',
+ 'settings.projects.page.gitProviders.provider.label': 'Git provider',
+ 'settings.projects.page.gitProviders.provider.description': 'Force this project\'s repository to use the selected forge. Auto-detects from the remote when unset.',
+ 'settings.projects.page.gitProviders.provider.auto': 'Auto-detect',
+ 'settings.projects.page.gitProviders.provider.autoUnknown': 'Couldn\'t identify the provider from this project\'s remote. Choose one above to set an API base URL.',
+ 'settings.projects.page.gitProviders.provider.detectedAs': 'Auto-detected as {provider}. Inherits: {url}',
'settings.usage.sidebar.title': 'Usage',
'settings.usage.sidebar.total': 'Total {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Toggle auto refresh',
@@ -1774,6 +1779,7 @@ export const settingsDict = {
'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.gitProviders.overridesLocked.description': 'Connect a {provider} account to configure the API base URL and detection URLs.',
'settings.notifications.page.delivery.title': 'Notification Delivery',
'settings.notifications.page.delivery.enableAria': 'Enable notifications',
'settings.notifications.page.delivery.enableLabel': 'Enable Notifications',
diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts
index ba65f7aa..217a386f 100644
--- a/packages/ui/src/lib/i18n/messages/es.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/es.settings.ts
@@ -1157,6 +1157,11 @@ export const settingsDict = {
"settings.projects.page.gitProviders.title": "URLs base de la API de los proveedores de Git",
"settings.projects.page.gitProviders.description": "Anula la URL base de la API global para este proyecto. Si no se define, se usa la configuración global.",
"settings.projects.page.gitProviders.inheritsGlobal": "Hereda: {url}",
+ "settings.projects.page.gitProviders.provider.label": "Proveedor de Git",
+ "settings.projects.page.gitProviders.provider.description": "Fuerza el repositorio de este proyecto a usar la plataforma seleccionada. Detecta automáticamente desde el remoto si no está definido.",
+ "settings.projects.page.gitProviders.provider.auto": "Detección automática",
+ "settings.projects.page.gitProviders.provider.autoUnknown": "No se pudo identificar el proveedor desde el remoto de este proyecto. Elige un proveedor arriba para definir una URL base de la API.",
+ "settings.projects.page.gitProviders.provider.detectedAs": "Detectado automáticamente como {provider}. Hereda: {url}",
"settings.usage.sidebar.title": "Uso",
"settings.usage.sidebar.total": "Total {count}",
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Alternar refresco automático",
@@ -1751,6 +1756,7 @@ export const settingsDict = {
"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.gitProviders.overridesLocked.description": "Conecta una cuenta de {provider} para configurar la URL base de la API y las URL de detección.",
"settings.notifications.page.delivery.title": "Entrega de notificaciones",
"settings.notifications.page.delivery.enableAria": "Habilitar notificaciones",
"settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones",
diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts
index 4dd7bc97..e7d97174 100644
--- a/packages/ui/src/lib/i18n/messages/fr.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts
@@ -1075,6 +1075,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'URL de base de l\'API des fournisseurs Git',
'settings.projects.page.gitProviders.description': 'Remplace l\'URL de base de l\'API globale pour ce projet. Si non défini, la valeur globale est utilisée.',
'settings.projects.page.gitProviders.inheritsGlobal': 'Hérite de : {url}',
+ 'settings.projects.page.gitProviders.provider.label': 'Fournisseur Git',
+ 'settings.projects.page.gitProviders.provider.description': 'Force le dépôt de ce projet à utiliser la forge sélectionnée. Détection automatique à partir du remote si non défini.',
+ 'settings.projects.page.gitProviders.provider.auto': 'Détection automatique',
+ 'settings.projects.page.gitProviders.provider.autoUnknown': 'Impossible d\'identifier le fournisseur depuis le remote de ce projet. Choisissez un fournisseur ci-dessus pour définir une URL de base de l\'API.',
+ 'settings.projects.page.gitProviders.provider.detectedAs': 'Détecté automatiquement comme {provider}. Hérite de : {url}',
'settings.usage.sidebar.title': 'Usage',
'settings.usage.sidebar.total': 'Total {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Activer l\'actualisation automatique',
@@ -1669,6 +1674,7 @@ export const settingsDict = {
'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.gitProviders.overridesLocked.description': 'Connectez un compte {provider} pour configurer l\'URL de base de l\'API et les URL de détection.',
'settings.notifications.page.delivery.title': 'Envoi des notifications',
'settings.notifications.page.delivery.enableAria': 'Activer les notifications',
'settings.notifications.page.delivery.enableLabel': 'Activer les notifications',
diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts
index 85037e32..54d261bd 100644
--- a/packages/ui/src/lib/i18n/messages/ja.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts
@@ -1190,6 +1190,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git プロバイダーの API ベース URL',
'settings.projects.page.gitProviders.description': 'このプロジェクトの API ベース URL をグローバル設定で上書きします。未設定の場合はグローバル設定が使用されます。',
'settings.projects.page.gitProviders.inheritsGlobal': '継承: {url}',
+ 'settings.projects.page.gitProviders.provider.label': 'Git プロバイダー',
+ 'settings.projects.page.gitProviders.provider.description': 'このプロジェクトのリポジトリを選択したフォージに固定します。未設定の場合はリモートから自動検出します。',
+ 'settings.projects.page.gitProviders.provider.auto': '自動検出',
+ 'settings.projects.page.gitProviders.provider.autoUnknown': 'このプロジェクトのリモートからプロバイダーを特定できませんでした。API ベース URL を設定するには、上でプロバイダーを選択してください。',
+ 'settings.projects.page.gitProviders.provider.detectedAs': 'リモートから {provider} として自動検出されました。継承: {url}',
'settings.usage.sidebar.title': '使用量',
'settings.usage.sidebar.total': '合計 {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '自動更新の切替',
@@ -1784,6 +1789,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': '検出 URL を追加',
'settings.gitProviders.detectUrls.remove': '{host} を削除',
'settings.gitProviders.detectUrls.invalid': '有効な SSH または HTTPS の URL またはホスト名を入力してください。',
+ 'settings.gitProviders.overridesLocked.description': '{provider} アカウントを接続して、API ベース URL と検出 URL を設定します。',
'settings.notifications.page.delivery.title': '通知配信',
'settings.notifications.page.delivery.enableAria': '通知を有効化',
'settings.notifications.page.delivery.enableLabel': '通知を有効化',
diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts
index 0108dee3..b4a5ace1 100644
--- a/packages/ui/src/lib/i18n/messages/ko.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts
@@ -1157,6 +1157,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git 공급자 API 기본 URL',
'settings.projects.page.gitProviders.description': '이 프로젝트의 API 기본 URL을 전역 설정으로 재정의합니다. 설정하지 않으면 전역 설정이 사용됩니다.',
'settings.projects.page.gitProviders.inheritsGlobal': '상속: {url}',
+ 'settings.projects.page.gitProviders.provider.label': 'Git 공급자',
+ 'settings.projects.page.gitProviders.provider.description': '이 프로젝트의 저장소를 선택한 포지로 강제합니다. 설정하지 않으면 원격 저장소에서 자동 감지합니다.',
+ 'settings.projects.page.gitProviders.provider.auto': '자동 감지',
+ 'settings.projects.page.gitProviders.provider.autoUnknown': '이 프로젝트의 원격 저장소에서 공급자를 식별할 수 없습니다. API 기본 URL을 설정하려면 위에서 공급자를 선택하세요.',
+ 'settings.projects.page.gitProviders.provider.detectedAs': '원격 저장소에서 {provider}(으)로 자동 감지되었습니다. 상속: {url}',
'settings.usage.sidebar.title': '사용량',
'settings.usage.sidebar.total': '총 {count}개',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '자동 새로고침 토글',
@@ -1751,6 +1756,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': '감지 URL 추가',
'settings.gitProviders.detectUrls.remove': '{host} 제거',
'settings.gitProviders.detectUrls.invalid': '유효한 SSH 또는 HTTPS URL이나 호스트 이름을 입력하세요.',
+ 'settings.gitProviders.overridesLocked.description': '{provider} 계정을 연결하여 API 기본 URL 및 감지 URL을 구성하세요.',
'settings.notifications.page.delivery.title': '알림',
'settings.notifications.page.delivery.enableAria': '알림 활성화',
'settings.notifications.page.delivery.enableLabel': '알림 활성화',
diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts
index 1c1b1288..3135ec67 100644
--- a/packages/ui/src/lib/i18n/messages/pl.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts
@@ -396,6 +396,7 @@ export const settingsDict = {
'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.gitProviders.overridesLocked.description': 'Połącz konto {provider}, aby skonfigurować adres URL API i adresy URL wykrywania.',
'settings.magicPrompts.page.actions.resetAllOverrides': 'Zresetuj wszystkie nadpisania',
'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych',
'settings.magicPrompts.page.actions.resetting': 'Resetowanie...',
@@ -1451,6 +1452,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Adresy URL API dostawców Git',
'settings.projects.page.gitProviders.description': 'Zastępuje globalny adres URL API dla tego projektu. Jeśli nie ustawiono, używany jest globalny adres URL.',
'settings.projects.page.gitProviders.inheritsGlobal': 'Dziedziczy: {url}',
+ 'settings.projects.page.gitProviders.provider.label': 'Dostawca Git',
+ 'settings.projects.page.gitProviders.provider.description': 'Wymusza używanie wybranego serwisu forów dla repozytorium tego projektu. Gdy nie ustawiono, wykrywane automatycznie na podstawie zdalnego repozytorium.',
+ 'settings.projects.page.gitProviders.provider.auto': 'Wykryj automatycznie',
+ 'settings.projects.page.gitProviders.provider.autoUnknown': 'Nie udało się zidentyfikować dostawcy na podstawie zdalnego repozytorium tego projektu. Wybierz dostawcę powyżej, aby ustawić adres URL API.',
+ 'settings.projects.page.gitProviders.provider.detectedAs': 'Automatycznie wykryto jako {provider}. Dziedziczy: {url}',
'settings.projects.page.toast.iconRemoved': 'Ikona projektu została usunięta',
'settings.projects.page.toast.iconUpdated': 'Ikona projektu została zaktualizowana',
'settings.projects.page.toast.removeIconFailed': 'Nie udało się usunąć ikony projektu',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
index f39689de..a7f950ef 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts
@@ -1157,6 +1157,11 @@ export const settingsDict = {
"settings.projects.page.gitProviders.title": "URLs base da API dos provedores de Git",
"settings.projects.page.gitProviders.description": "Substitui a URL base da API global para este projeto. Quando não definido, usa a configuração global.",
"settings.projects.page.gitProviders.inheritsGlobal": "Herda: {url}",
+ "settings.projects.page.gitProviders.provider.label": "Provedor Git",
+ "settings.projects.page.gitProviders.provider.description": "Força o repositório deste projeto a usar a plataforma selecionada. Detecta automaticamente pelo remote quando não definido.",
+ "settings.projects.page.gitProviders.provider.auto": "Detecção automática",
+ "settings.projects.page.gitProviders.provider.autoUnknown": "Não foi possível identificar o provedor pelo remote deste projeto. Escolha um provedor acima para definir uma URL base da API.",
+ "settings.projects.page.gitProviders.provider.detectedAs": "Detectado automaticamente como {provider}. Herda: {url}",
"settings.usage.sidebar.title": "Uso",
"settings.usage.sidebar.total": "Total {count}",
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Alternar atualização automática",
@@ -1751,6 +1756,7 @@ export const settingsDict = {
"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.gitProviders.overridesLocked.description": "Conecte uma conta do {provider} para configurar a URL base da API e as URLs de detecção.",
"settings.notifications.page.delivery.title": "Entrega de notificações",
"settings.notifications.page.delivery.enableAria": "Ativar notificações",
"settings.notifications.page.delivery.enableLabel": "Ativar notificações",
diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts
index b218c73d..0c301c30 100644
--- a/packages/ui/src/lib/i18n/messages/uk.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts
@@ -1157,6 +1157,11 @@ export const settingsDict = {
"settings.projects.page.gitProviders.title": "Базові URL-адреси API постачальників Git",
"settings.projects.page.gitProviders.description": "Перевизначає глобальну базову URL-адресу API для цього проєкту. Якщо не задано, використовується глобальне значення.",
"settings.projects.page.gitProviders.inheritsGlobal": "Успадковує: {url}",
+ "settings.projects.page.gitProviders.provider.label": "Git-провайдер",
+ "settings.projects.page.gitProviders.provider.description": "Примушує репозиторій цього проєкту використовувати вибрану платформу. Якщо не задано — визначається автоматично з віддаленого репозиторію.",
+ "settings.projects.page.gitProviders.provider.auto": "Автовизначення",
+ "settings.projects.page.gitProviders.provider.autoUnknown": "Не вдалося визначити провайдера з віддаленого репозиторію цього проєкту. Виберіть провайдера вище, щоб задати базову URL-адресу API.",
+ "settings.projects.page.gitProviders.provider.detectedAs": "Автоматично визначено як {provider}. Успадковує: {url}",
"settings.usage.sidebar.title": "Використання",
"settings.usage.sidebar.total": "Усього {count}",
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Увімкнути автоматичне оновлення",
@@ -1751,6 +1756,7 @@ export const settingsDict = {
"settings.gitProviders.detectUrls.add": "Додати URL-адресу виявлення",
"settings.gitProviders.detectUrls.remove": "Видалити {host}",
"settings.gitProviders.detectUrls.invalid": "Введіть коректну URL-адресу SSH або HTTPS чи ім'я хоста.",
+ "settings.gitProviders.overridesLocked.description": "Підключіть обліковий запис {provider}, щоб налаштувати базову URL-адресу API та URL-адреси виявлення.",
"settings.notifications.page.delivery.title": "Доставка сповіщень",
"settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення",
"settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
index 1cb51746..e1ce3fa2 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts
@@ -1157,6 +1157,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git 提供商的 API 基础 URL',
'settings.projects.page.gitProviders.description': '为此项目覆盖全局 API 基础 URL。未设置时使用全局配置。',
'settings.projects.page.gitProviders.inheritsGlobal': '继承:{url}',
+ 'settings.projects.page.gitProviders.provider.label': 'Git 提供方',
+ 'settings.projects.page.gitProviders.provider.description': '强制此项目的仓库使用选定的托管平台。未设置时根据远程仓库自动检测。',
+ 'settings.projects.page.gitProviders.provider.auto': '自动检测',
+ 'settings.projects.page.gitProviders.provider.autoUnknown': '无法根据此项目的远程仓库识别提供方。请在上方选择提供方以设置 API 基础 URL。',
+ 'settings.projects.page.gitProviders.provider.detectedAs': '已自动检测为 {provider}。继承:{url}',
'settings.usage.sidebar.title': '用量',
'settings.usage.sidebar.total': '总计 {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '切换自动刷新',
@@ -1751,6 +1756,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': '添加检测 URL',
'settings.gitProviders.detectUrls.remove': '移除 {host}',
'settings.gitProviders.detectUrls.invalid': '请输入有效的 SSH 或 HTTPS URL 或主机名。',
+ 'settings.gitProviders.overridesLocked.description': '连接 {provider} 账户,以配置 API 基础 URL 和检测 URL。',
'settings.notifications.page.delivery.title': '通知投递',
'settings.notifications.page.delivery.enableAria': '启用通知',
'settings.notifications.page.delivery.enableLabel': '启用通知',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
index 784864cc..7e96a260 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts
@@ -1064,6 +1064,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git 提供者的 API 基礎 URL',
'settings.projects.page.gitProviders.description': '為此專案覆寫全域 API 基礎 URL。未設定時使用全域設定。',
'settings.projects.page.gitProviders.inheritsGlobal': '繼承:{url}',
+ 'settings.projects.page.gitProviders.provider.label': 'Git 提供者',
+ 'settings.projects.page.gitProviders.provider.description': '強制此專案的儲存庫使用所選的程式碼代管平台。未設定時依遠端倉庫自動偵測。',
+ 'settings.projects.page.gitProviders.provider.auto': '自動偵測',
+ 'settings.projects.page.gitProviders.provider.autoUnknown': '無法依此專案的遠端倉庫識別提供者。請在上方選擇提供者以設定 API 基礎 URL。',
+ 'settings.projects.page.gitProviders.provider.detectedAs': '已自動偵測為 {provider}。繼承:{url}',
'settings.usage.sidebar.title': '用量',
'settings.usage.sidebar.total': '總計 {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '切換自動重新整理',
@@ -1658,6 +1663,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': '新增偵測 URL',
'settings.gitProviders.detectUrls.remove': '移除 {host}',
'settings.gitProviders.detectUrls.invalid': '請輸入有效的 SSH 或 HTTPS URL 或主機名稱。',
+ 'settings.gitProviders.overridesLocked.description': '連線 {provider} 帳號,以設定 API 基礎 URL 和偵測 URL。',
'settings.notifications.page.delivery.title': '通知傳遞',
'settings.notifications.page.delivery.enableAria': '啟用通知',
'settings.notifications.page.delivery.enableLabel': '啟用通知',
diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts
index 67e2b09d..6156c4b0 100644
--- a/packages/ui/src/lib/settings/search.ts
+++ b/packages/ui/src/lib/settings/search.ts
@@ -28,6 +28,8 @@ interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext {
isLinux: boolean;
// Windows ARM64 — temporary workaround gate (see opencode#19130).
isWindowsArm64: boolean;
+ // Git provider override fields only render once an account is connected.
+ gitProvidersConnected: { github: boolean; gitlab: boolean; gitea: boolean };
}
const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
@@ -507,6 +509,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.github.page.apiBaseUrl.label',
descriptionKey: 'settings.github.page.apiBaseUrl.description',
keywords: ['github', 'api', 'base url', 'enterprise', 'self-hosted', 'server'],
+ isAvailable: (ctx) => ctx.gitProvidersConnected.github,
},
{
id: 'git.github-detect-urls',
@@ -514,6 +517,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.github.page.detectUrls.label',
descriptionKey: 'settings.github.page.detectUrls.description',
keywords: ['github', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'],
+ isAvailable: (ctx) => ctx.gitProvidersConnected.github,
},
{
id: 'git.gitlab-api-base-url',
@@ -521,6 +525,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.gitlab.page.apiBaseUrl.label',
descriptionKey: 'settings.gitlab.page.apiBaseUrl.description',
keywords: ['gitlab', 'api', 'base url', 'self-hosted', 'server', 'instance'],
+ isAvailable: (ctx) => ctx.gitProvidersConnected.gitlab,
},
{
id: 'git.gitlab-detect-urls',
@@ -528,6 +533,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.gitlab.page.detectUrls.label',
descriptionKey: 'settings.gitlab.page.detectUrls.description',
keywords: ['gitlab', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'],
+ isAvailable: (ctx) => ctx.gitProvidersConnected.gitlab,
},
{
id: 'git.gitea-api-base-url',
@@ -535,6 +541,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.gitea.page.apiBaseUrl.label',
descriptionKey: 'settings.gitea.page.apiBaseUrl.description',
keywords: ['gitea', 'forgejo', 'api', 'base url', 'self-hosted', 'server', 'instance'],
+ isAvailable: (ctx) => ctx.gitProvidersConnected.gitea,
},
{
id: 'git.gitea-detect-urls',
@@ -542,6 +549,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.gitea.page.detectUrls.label',
descriptionKey: 'settings.gitea.page.detectUrls.description',
keywords: ['gitea', 'forgejo', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'],
+ isAvailable: (ctx) => ctx.gitProvidersConnected.gitea,
},
{
id: 'git.identities',
@@ -616,7 +624,14 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
page: 'projects',
titleKey: 'settings.projects.page.gitProviders.title',
descriptionKey: 'settings.projects.page.gitProviders.description',
- keywords: ['github', 'gitlab', 'gitea', 'api base url', 'self-hosted', 'override', 'enterprise', 'server'],
+ keywords: ['github', 'gitlab', 'gitea', 'api base url', 'self-hosted', 'override', 'enterprise', 'server', 'provider', 'forge'],
+ },
+ {
+ id: 'projects.git-providers.provider',
+ page: 'projects',
+ titleKey: 'settings.projects.page.gitProviders.provider.label',
+ descriptionKey: 'settings.projects.page.gitProviders.provider.description',
+ keywords: ['github', 'gitlab', 'gitea', 'provider', 'forge', 'auto-detect', 'detection'],
},
{
id: 'remote-instances.client-auth',
diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts
index 256c7e5a..b212f1d2 100644
--- a/packages/ui/src/stores/useConfigStore.ts
+++ b/packages/ui/src/stores/useConfigStore.ts
@@ -712,12 +712,28 @@ const toDirectoryKey = (directory: string | null | undefined): string => {
const fromDirectoryKey = (key: string): string | null => (key === DIRECTORY_KEY_GLOBAL ? null : key);
+/**
+ * The directory store is part of this store's circular import cluster
+ * (useConfigStore → persistence → session-ui-store → useConfigStore, with
+ * useDirectoryStore in the same strongly-connected component). In the bundled
+ * chunk its module body may not have run yet when this module evaluates, so the
+ * static import binding is in TDZ. Read it through the window registration that
+ * useDirectoryStore publishes as soon as it initializes; fall back to the
+ * client directory, which the directory store seeds at the same time.
+ */
+const getDirectoryStore = (): typeof useDirectoryStore | null => {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+ return window.__zustand_directory_store__ ?? null;
+};
+
const resolveInitialDirectoryKey = (): string => {
if (typeof window === 'undefined') {
return DIRECTORY_KEY_GLOBAL;
}
- const directory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory;
+ const directory = opencodeClient.getDirectory() ?? getDirectoryStore()?.getState().currentDirectory;
return toConfigDirectoryKey(directory);
};
@@ -3429,14 +3445,24 @@ if (!unsubscribeConfigStoreSyncConfigChanges) {
}
if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) {
- unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => {
- const nextKey = toDirectoryKey(state.currentDirectory);
- const prevKey = toDirectoryKey(prevState.currentDirectory);
- if (nextKey === prevKey) {
- return;
- }
+ // useDirectoryStore's module body may not have run yet when this module
+ // evaluates (the two stores share a circular import cluster, and the
+ // bundled chunk can evaluate either body first). Defer subscription setup
+ // until after module evaluation completes so the import binding is no
+ // longer in TDZ. The subscription is registered before any user-driven
+ // directory change can occur; the initial directory is reconciled by
+ // initializeApp.
+ queueMicrotask(() => {
+ if (unsubscribeConfigStoreDirectoryChanges) return;
+ unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => {
+ const nextKey = toDirectoryKey(state.currentDirectory);
+ const prevKey = toDirectoryKey(prevState.currentDirectory);
+ if (nextKey === prevKey) {
+ return;
+ }
- markStartupTrace('directoryStore:changed', { previous: prevKey, next: nextKey });
- void useConfigStore.getState().activateDirectory(state.currentDirectory);
+ markStartupTrace('directoryStore:changed', { previous: prevKey, next: nextKey });
+ void useConfigStore.getState().activateDirectory(state.currentDirectory);
+ });
});
}
diff --git a/packages/ui/src/stores/useDirectoryStore.ts b/packages/ui/src/stores/useDirectoryStore.ts
index b0b32af5..6ee65cbf 100644
--- a/packages/ui/src/stores/useDirectoryStore.ts
+++ b/packages/ui/src/stores/useDirectoryStore.ts
@@ -8,6 +8,12 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { getDeferredSafeStorage } from './utils/safeStorage';
+declare global {
+ interface Window {
+ __zustand_directory_store__?: typeof useDirectoryStore;
+ }
+}
+
interface DirectoryStore {
currentDirectory: string;
@@ -436,6 +442,11 @@ export const useDirectoryStore = create()(
);
if (typeof window !== 'undefined') {
+ // Registered on window so store modules inside the circular import cluster
+ // (useConfigStore, useAgentsStore, ...) can reach this store lazily without
+ // a static import that would resolve in TDZ at module-evaluation time.
+ window.__zustand_directory_store__ = useDirectoryStore;
+
initializeHomeDirectory().then((home) => {
useDirectoryStore.getState().synchronizeHomeDirectory(home);
});
diff --git a/packages/ui/src/stores/useGitProviderDomainsStore.test.ts b/packages/ui/src/stores/useGitProviderDomainsStore.test.ts
index d5581966..245cf721 100644
--- a/packages/ui/src/stores/useGitProviderDomainsStore.test.ts
+++ b/packages/ui/src/stores/useGitProviderDomainsStore.test.ts
@@ -6,6 +6,7 @@ const resetDomains = () => {
domains: { github: [], gitlab: [], gitea: [] },
apiBaseUrls: { github: '', gitlab: '', gitea: '' },
projectApiBaseUrls: {},
+ projectProviders: {},
});
};
@@ -217,4 +218,36 @@ describe('useGitProviderDomainsStore per-project overrides', () => {
gitea: '',
});
});
+
+ test('hydrateProjectFromServer stores a forced provider', () => {
+ resetDomains();
+ useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {
+ provider: 'gitlab',
+ gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
+ });
+ expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({ path_proj: 'gitlab' });
+ });
+
+ test('hydrateProjectFromServer normalizes and drops unknown providers', () => {
+ resetDomains();
+ useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_bad', { provider: 'Bitbucket' });
+ useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_empty', { provider: '' });
+ expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({});
+ });
+
+ test('hydrateProjectFromServer clears the forced provider when removed', () => {
+ resetDomains();
+ useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', { provider: 'gitea' });
+ expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({ path_proj: 'gitea' });
+ // A config without a provider removes it even when base urls stay empty.
+ useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {});
+ expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({});
+ });
+
+ test('clearProjectGitProviders clears the forced provider too', () => {
+ resetDomains();
+ useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', { provider: 'github' });
+ useGitProviderDomainsStore.getState().clearProjectGitProviders('path_proj');
+ expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({});
+ });
});
diff --git a/packages/ui/src/stores/useGitProviderDomainsStore.ts b/packages/ui/src/stores/useGitProviderDomainsStore.ts
index 17ee1a5a..bbe5a962 100644
--- a/packages/ui/src/stores/useGitProviderDomainsStore.ts
+++ b/packages/ui/src/stores/useGitProviderDomainsStore.ts
@@ -79,25 +79,32 @@ export const normalizeApiBaseUrl = (raw: unknown): string => {
/**
* 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.
+ * detectUrls }` pairs plus an optional forced `provider`. Unknown or malformed
+ * entries are dropped; provider keys outside the known set are ignored.
*/
const normalizeGitProvidersConfig = (config: unknown): {
apiBaseUrls: GitProviderApiBaseUrls;
domains: GitProviderDomains;
+ provider: GitProviderName | null;
} => {
const apiBaseUrls: GitProviderApiBaseUrls = { ...EMPTY_API_BASE_URLS };
const domains: GitProviderDomains = { github: [], gitlab: [], gitea: [] };
- if (!isRecord(config)) {
- return { apiBaseUrls, domains };
+ let provider: GitProviderName | null = null;
+ if (isRecord(config)) {
+ if (typeof config.provider === 'string') {
+ const forced = config.provider.trim().toLowerCase();
+ if (GIT_PROVIDERS.includes(forced as GitProviderName)) {
+ provider = forced as GitProviderName;
+ }
+ }
+ for (const entryProvider of GIT_PROVIDERS) {
+ const entry = config[entryProvider];
+ if (!isRecord(entry)) continue;
+ apiBaseUrls[entryProvider] = normalizeApiBaseUrl(entry.apiBaseUrl);
+ domains[entryProvider] = normalizeDomainList(entry.detectUrls);
+ }
}
- 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 };
+ return { apiBaseUrls, domains, provider };
};
type GitProviderDomainsStore = {
@@ -109,6 +116,12 @@ type GitProviderDomainsStore = {
* on demand and cleared when the override is removed server-side.
*/
projectApiBaseUrls: Record;
+ /**
+ * Per-project forced git provider (github|gitlab|gitea), keyed by project id.
+ * Overrides automatic provider detection for the project. Same
+ * server-authoritative, memory-only semantics as `projectApiBaseUrls`.
+ */
+ projectProviders: Record;
setDomains: (provider: GitProviderName, domains: string[]) => void;
setApiBaseUrl: (provider: GitProviderName, url: string) => void;
/** Apply the server's `gitProviders` settings, keeping the server authoritative. */
@@ -125,6 +138,7 @@ export const useGitProviderDomainsStore = create()(
domains: EMPTY_DOMAINS,
apiBaseUrls: EMPTY_API_BASE_URLS,
projectApiBaseUrls: {},
+ projectProviders: {},
setDomains: (provider, domains) => {
set({
domains: {
@@ -159,16 +173,18 @@ export const useGitProviderDomainsStore = create()(
},
hydrateProjectFromServer: (projectId, config) => {
if (!projectId) return;
- const { apiBaseUrls } = normalizeGitProvidersConfig(config);
+ const { apiBaseUrls, provider } = normalizeGitProvidersConfig(config);
const hasAny = Boolean(apiBaseUrls.github || apiBaseUrls.gitlab || apiBaseUrls.gitea);
const current = get().projectApiBaseUrls[projectId];
- const unchanged = hasAny
+ const currentProvider = get().projectProviders[projectId];
+ const baseUrlsUnchanged = hasAny
? current !== undefined
&& current.github === apiBaseUrls.github
&& current.gitlab === apiBaseUrls.gitlab
&& current.gitea === apiBaseUrls.gitea
: current === undefined;
- if (unchanged) return;
+ const providerUnchanged = provider ? currentProvider === provider : currentProvider === undefined;
+ if (baseUrlsUnchanged && providerUnchanged) return;
set((state) => {
const next = { ...state.projectApiBaseUrls };
if (hasAny) {
@@ -176,15 +192,24 @@ export const useGitProviderDomainsStore = create()(
} else {
delete next[projectId];
}
- return { projectApiBaseUrls: next };
+ const nextProviders = { ...state.projectProviders };
+ if (provider) {
+ nextProviders[projectId] = provider;
+ } else {
+ delete nextProviders[projectId];
+ }
+ return { projectApiBaseUrls: next, projectProviders: nextProviders };
});
},
clearProjectGitProviders: (projectId) => {
- if (!projectId || get().projectApiBaseUrls[projectId] === undefined) return;
+ if (!projectId
+ || (get().projectApiBaseUrls[projectId] === undefined && get().projectProviders[projectId] === undefined)) return;
set((state) => {
const next = { ...state.projectApiBaseUrls };
delete next[projectId];
- return { projectApiBaseUrls: next };
+ const nextProviders = { ...state.projectProviders };
+ delete nextProviders[projectId];
+ return { projectApiBaseUrls: next, projectProviders: nextProviders };
});
},
}),
diff --git a/packages/web/scripts/gitea-live-test.ts b/packages/web/scripts/gitea-live-test.ts
new file mode 100644
index 00000000..c105a9ee
--- /dev/null
+++ b/packages/web/scripts/gitea-live-test.ts
@@ -0,0 +1,659 @@
+#!/usr/bin/env bun
+
+/*
+ * Live test harness for the Gitea/Forgejo REST v1 client
+ * (packages/web/server/lib/gitea/client.js).
+ *
+ * Exercises every client method against a real Gitea server and prints a
+ * per-endpoint PASS/WARN/FAIL/SKIP table. Read-only calls run against an
+ * auto-discovered repo; a controlled write pass then creates a scratch issue
+ * (comment -> update -> close) and, when the token allows repo creation, runs
+ * the full PR lifecycle (branch -> commit -> PR -> review -> update -> merge)
+ * against a scratch repo that is deleted afterward.
+ *
+ * Usage:
+ * GITEA_TOKEN= GITEA_BASE_URL=https://git.example.com \
+ * bun packages/web/scripts/gitea-live-test.ts
+ *
+ * The token is read from the environment only and is never printed or stored.
+ *
+ * Exit codes:
+ * 0 every exercised endpoint passed (SKIP/WARN are not failures)
+ * 1 at least one endpoint failed
+ * 2 setup error (missing env, invalid token, or no repo found)
+ */
+
+import process from 'node:process';
+import { Buffer } from 'node:buffer';
+import { createGiteaClient } from '../server/lib/gitea/client.js';
+import type { GiteaClientResponse } from '../server/lib/gitea/client.js';
+
+const BASE_URL = (process.env.GITEA_BASE_URL || 'https://git.example.com').trim().replace(/\/+$/, '');
+const TOKEN = process.env.GITEA_TOKEN || '';
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+// ---- Result collection ------------------------------------------------------
+
+type Verdict = 'PASS' | 'WARN' | 'FAIL' | 'SKIP';
+
+interface ResultEntry {
+ name: string;
+ method: string;
+ path: string;
+ status: number | null;
+ verdict: Verdict;
+ note: string;
+}
+
+const results: ResultEntry[] = [];
+
+function record(entry: ResultEntry): void {
+ results.push(entry);
+ const statusText = entry.status === null ? ' ' : String(entry.status);
+ const line = `[${entry.verdict}] ${entry.method.padEnd(6)} ${entry.path.padEnd(52)} ${statusText}`;
+ console.log(entry.note ? `${line} ${entry.note}` : line);
+}
+
+function summarize(): number {
+ const counts: Record = { PASS: 0, WARN: 0, FAIL: 0, SKIP: 0 };
+ for (const result of results) {
+ counts[result.verdict] += 1;
+ }
+ console.log('\n----------------------------------------');
+ console.log(
+ `Summary: ${counts.PASS} passed, ${counts.WARN} warn, ${counts.FAIL} failed, ${counts.SKIP} skipped`,
+ );
+ return counts.FAIL > 0 ? 1 : 0;
+}
+
+const fail = (message: string): never => {
+ console.error(`\nError: ${message}`);
+ process.exit(2);
+};
+
+// ---- Untyped JSON payload helpers -------------------------------------------
+
+const asRecord = (data: unknown): Record | null =>
+ data !== null && typeof data === 'object' && !Array.isArray(data)
+ ? (data as Record)
+ : null;
+
+const asArray = (data: unknown): Array> =>
+ Array.isArray(data)
+ ? data.filter((item): item is Record => item !== null && typeof item === 'object')
+ : [];
+
+const asString = (data: unknown): string | null =>
+ typeof data === 'string' ? data : null;
+
+const asNumber = (data: unknown): number | null =>
+ typeof data === 'number' ? data : null;
+
+const isOk = (response: GiteaClientResponse): boolean =>
+ response.status === 200 || response.status === 201;
+
+const isOkOr204 = (response: GiteaClientResponse): boolean =>
+ isOk(response) || response.status === 204;
+
+// ---- Main -------------------------------------------------------------------
+
+async function main(): Promise {
+ if (!TOKEN) {
+ fail('GITEA_TOKEN is required (set it in the environment; it is never printed or stored).');
+ }
+ console.log(`Gitea live test against ${BASE_URL}\n`);
+
+ const client = createGiteaClient({ token: TOKEN, baseUrl: BASE_URL });
+
+ // ================= Phase 0: identity + discovery =================
+
+ const userResp = await client.user();
+ record({
+ name: 'user',
+ method: 'GET',
+ path: '/user',
+ status: userResp.status,
+ verdict: userResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+ if (userResp.status !== 200) {
+ fail(`GET /user failed with status ${userResp.status} — check GITEA_TOKEN and GITEA_BASE_URL.`);
+ }
+ const user = asRecord(userResp.data) ?? {};
+ const login = asString(user.login) ?? asString(user.username) ?? 'unknown';
+ console.log(`Authenticated as ${login}\n`);
+
+ const reposResp = await client.request('/user/repos', { query: { limit: 50 } });
+ record({
+ name: 'list my repos',
+ method: 'GET',
+ path: '/user/repos',
+ status: reposResp.status,
+ verdict: reposResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+ const repos = asArray(reposResp.data);
+ if (repos.length === 0) {
+ fail(`No repos found for ${login} on ${BASE_URL}.`);
+ }
+
+ // Probe up to 10 repos for issues/PRs so read paths can pick a repo that has
+ // both; fall back to the first repo otherwise.
+ interface RepoRef {
+ owner: string;
+ name: string;
+ }
+ let best: RepoRef | null = null;
+ let bestIssues = false;
+ let bestPrs = false;
+ const probeCap = Math.min(repos.length, 10);
+ for (let i = 0; i < probeCap; i += 1) {
+ const repo = repos[i];
+ const ownerObj = asRecord(repo.owner);
+ const owner = asString(ownerObj?.login) ?? asString(ownerObj?.username) ?? '';
+ const name = asString(repo.name) ?? '';
+ if (!owner || !name) continue;
+
+ const issuesProbe = await client.issues(owner, name, { state: 'all', limit: 1 });
+ const prsProbe = await client.pullRequests(owner, name, { state: 'all', limit: 1 });
+ const hasIssues = issuesProbe.status === 200 && asArray(issuesProbe.data).length > 0;
+ const hasPrs = prsProbe.status === 200 && asArray(prsProbe.data).length > 0;
+
+ if (best === null || (hasIssues && hasPrs && !(bestIssues && bestPrs))) {
+ best = { owner, name };
+ bestIssues = hasIssues;
+ bestPrs = hasPrs;
+ }
+ if (hasIssues && hasPrs) break;
+ }
+ if (best === null) {
+ const first = repos[0];
+ const firstOwner = asRecord(first.owner);
+ best = {
+ owner: asString(firstOwner?.login) ?? asString(firstOwner?.username) ?? '',
+ name: asString(first.name) ?? '',
+ };
+ }
+
+ const { owner, name } = best;
+ const target = `${owner}/${name}`;
+ console.log(`Target repo: ${target} (issues: ${bestIssues}, PRs: ${bestPrs})\n`);
+
+ // Fetch one issue number and one PR number for detail endpoints.
+ const issuesList = await client.issues(owner, name, { state: 'all', limit: 1 });
+ const firstIssue = asArray(issuesList.data)[0] ?? null;
+ const issueNumber = firstIssue === null ? null : asNumber(firstIssue.number);
+ const prsList = await client.pullRequests(owner, name, { state: 'all', limit: 1 });
+ const firstPr = asArray(prsList.data)[0] ?? null;
+ const prNumber = firstPr === null ? null : asNumber(firstPr.number);
+
+ const skipIssue = bestIssues === false || issueNumber === null;
+ const skipPr = bestPrs === false || prNumber === null;
+
+ // ================= Phase 1: read-only pass =================
+
+ const repoResp = await client.repo(owner, name);
+ record({
+ name: 'repo',
+ method: 'GET',
+ path: `/repos/${target}`,
+ status: repoResp.status,
+ verdict: repoResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+ const repoData = asRecord(repoResp.data);
+ const defaultBranch = asString(repoData?.default_branch) ?? 'main';
+
+ const issuesResp = await client.issues(owner, name, { state: 'open', type: 'issues', limit: 50 });
+ let issuesNote = '';
+ if (issuesResp.status === 200 && issuesResp.page?.hasMore) {
+ const page2 = await client.issues(owner, name, { state: 'open', type: 'issues', limit: 50, page: 2 });
+ issuesNote = page2.status === 200 ? '(page 2 OK, hasMore honored)' : `(page 2 failed: ${page2.status})`;
+ }
+ record({
+ name: 'issues list',
+ method: 'GET',
+ path: `/repos/${target}/issues`,
+ status: issuesResp.status,
+ verdict: issuesResp.status === 200 ? 'PASS' : 'FAIL',
+ note: issuesNote,
+ });
+
+ // ETag conditional-GET check: a repeat call must be replayed from the cache
+ // (the client converts the 304 into a 200) rather than failing.
+ const etagReplay = await client.issues(owner, name, { state: 'open', type: 'issues', limit: 50 });
+ record({
+ name: 'issues list (repeat, ETag)',
+ method: 'GET',
+ path: `/repos/${target}/issues`,
+ status: etagReplay.status,
+ verdict: etagReplay.status === 200 ? 'PASS' : 'FAIL',
+ note: '(second call replayed from ETag cache)',
+ });
+
+ if (skipIssue) {
+ record({ name: 'issue get', method: 'GET', path: `/repos/${target}/issues/:number`, status: null, verdict: 'SKIP', note: '(repo has no issues)' });
+ record({ name: 'issue comments', method: 'GET', path: `/repos/${target}/issues/:number/comments`, status: null, verdict: 'SKIP', note: '(repo has no issues)' });
+ } else {
+ const issueResp = await client.issue(owner, name, issueNumber as number);
+ record({
+ name: 'issue get',
+ method: 'GET',
+ path: `/repos/${target}/issues/${issueNumber}`,
+ status: issueResp.status,
+ verdict: issueResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+ const commentsResp = await client.issueComments(owner, name, issueNumber as number, { limit: 100 });
+ record({
+ name: 'issue comments',
+ method: 'GET',
+ path: `/repos/${target}/issues/${issueNumber}/comments`,
+ status: commentsResp.status,
+ verdict: commentsResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+ }
+
+ const milestonesResp = await client.milestones(owner, name, { state: 'all', limit: 50 });
+ record({
+ name: 'milestones',
+ method: 'GET',
+ path: `/repos/${target}/milestones`,
+ status: milestonesResp.status,
+ verdict: milestonesResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ const labelsResp = await client.repoLabels(owner, name, { limit: 100 });
+ record({
+ name: 'repo labels',
+ method: 'GET',
+ path: `/repos/${target}/labels`,
+ status: labelsResp.status,
+ verdict: labelsResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ const pullsResp = await client.pullRequests(owner, name, { state: 'open', limit: 50 });
+ record({
+ name: 'PR list',
+ method: 'GET',
+ path: `/repos/${target}/pulls`,
+ status: pullsResp.status,
+ verdict: pullsResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ if (skipPr) {
+ record({ name: 'PR get', method: 'GET', path: `/repos/${target}/pulls/:number`, status: null, verdict: 'SKIP', note: '(repo has no PRs)' });
+ record({ name: 'PR diff', method: 'GET', path: `/repos/${target}/pulls/:number.diff`, status: null, verdict: 'SKIP', note: '(repo has no PRs)' });
+ record({ name: 'PR files', method: 'GET', path: `/repos/${target}/pulls/:number/files`, status: null, verdict: 'SKIP', note: '(repo has no PRs)' });
+ record({ name: 'PR commits', method: 'GET', path: `/repos/${target}/pulls/:number/commits`, status: null, verdict: 'SKIP', note: '(repo has no PRs)' });
+ record({ name: 'PR reviews', method: 'GET', path: `/repos/${target}/pulls/:number/reviews`, status: null, verdict: 'SKIP', note: '(repo has no PRs)' });
+ record({ name: 'PR statuses', method: 'GET', path: `/repos/${target}/pulls/:number/statuses`, status: null, verdict: 'SKIP', note: '(repo has no PRs)' });
+ } else {
+ const prResp = await client.pullRequest(owner, name, prNumber as number);
+ record({
+ name: 'PR get',
+ method: 'GET',
+ path: `/repos/${target}/pulls/${prNumber}`,
+ status: prResp.status,
+ verdict: prResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+ const prData = asRecord(prResp.data);
+ const headSha = asString(asRecord(prData?.head)?.sha);
+
+ // Raw unified diff endpoint; WARN (not FAIL) when it fails because the
+ // module falls back to concatenated per-file patches.
+ const diffResp = await client.pullRequestDiff(owner, name, prNumber as number);
+ const diffOk = diffResp.status === 200 && (asString(diffResp.data) ?? '').length > 0;
+ record({
+ name: 'PR diff',
+ method: 'GET',
+ path: `/repos/${target}/pulls/${prNumber}.diff`,
+ status: diffResp.status,
+ verdict: diffOk ? 'PASS' : 'WARN',
+ note: diffOk ? '' : '(fallback: concatenated per-file patches)',
+ });
+
+ // Per-file patches; older Gitea instances 404 here and the module falls
+ // back to an empty list, so a 404 is a WARN.
+ const filesResp = await client.pullRequestFiles(owner, name, prNumber as number, { patch: 'true' });
+ record({
+ name: 'PR files',
+ method: 'GET',
+ path: `/repos/${target}/pulls/${prNumber}/files`,
+ status: filesResp.status,
+ verdict: filesResp.status === 200 ? 'PASS' : (filesResp.status === 404 ? 'WARN' : 'FAIL'),
+ note: filesResp.status === 404 ? '(older Gitea: files endpoint unsupported)' : '',
+ });
+
+ const commitsResp = await client.pullRequestCommits(owner, name, prNumber as number, { limit: 100 });
+ record({
+ name: 'PR commits',
+ method: 'GET',
+ path: `/repos/${target}/pulls/${prNumber}/commits`,
+ status: commitsResp.status,
+ verdict: commitsResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ const reviewsResp = await client.pullRequestReviews(owner, name, prNumber as number, { limit: 100 });
+ record({
+ name: 'PR reviews',
+ method: 'GET',
+ path: `/repos/${target}/pulls/${prNumber}/reviews`,
+ status: reviewsResp.status,
+ verdict: reviewsResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ // Commit statuses are keyed by SHA; resolve the PR head SHA first.
+ if (headSha === null) {
+ record({ name: 'PR statuses', method: 'GET', path: `/repos/${target}/pulls/${prNumber}/statuses`, status: null, verdict: 'SKIP', note: '(PR has no head.sha)' });
+ } else {
+ const statusesResp = await client.commitStatuses(owner, name, headSha, { limit: 100 });
+ record({
+ name: 'PR statuses',
+ method: 'GET',
+ path: `/repos/${target}/commits/${headSha.slice(0, 8)}/statuses`,
+ status: statusesResp.status,
+ verdict: statusesResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+ }
+ }
+
+ const branchesResp = await client.branches(owner, name, { limit: 50 });
+ record({
+ name: 'branches',
+ method: 'GET',
+ path: `/repos/${target}/branches`,
+ status: branchesResp.status,
+ verdict: branchesResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ const assigneesResp = await client.assignees(owner, name, { limit: 50 });
+ record({
+ name: 'assignees',
+ method: 'GET',
+ path: `/repos/${target}/assignees`,
+ status: assigneesResp.status,
+ verdict: assigneesResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ const tagsResp = await client.tags(owner, name, { limit: 50 });
+ record({
+ name: 'tags',
+ method: 'GET',
+ path: `/repos/${target}/tags`,
+ status: tagsResp.status,
+ verdict: tagsResp.status === 200 ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ // ================= Phase 2: controlled write pass =================
+
+ console.log('\n--- write pass (scratch issue on target repo) ---');
+
+ const createdIssue = await client.createIssue(owner, name, {
+ title: `[OpenChamber live test] ${new Date().toISOString()}`,
+ body: 'Automated OpenChamber live-test issue. Safe to close.',
+ });
+ if (createdIssue.status === 403) {
+ record({
+ name: 'create issue',
+ method: 'POST',
+ path: `/repos/${target}/issues`,
+ status: createdIssue.status,
+ verdict: 'WARN',
+ note: '(token lacks write:repository scope — write pass skipped)',
+ });
+ record({ name: 'issue comment write', method: 'POST', path: `/repos/${target}/issues/:number/comments`, status: null, verdict: 'SKIP', note: '(write scope unavailable)' });
+ record({ name: 'issue update', method: 'PATCH', path: `/repos/${target}/issues/:number`, status: null, verdict: 'SKIP', note: '(write scope unavailable)' });
+ } else {
+ const createOk = isOk(createdIssue);
+ record({
+ name: 'create issue',
+ method: 'POST',
+ path: `/repos/${target}/issues`,
+ status: createdIssue.status,
+ verdict: createOk ? 'PASS' : 'FAIL',
+ note: '',
+ });
+ const createdData = asRecord(createdIssue.data);
+ const newIssueNumber = createOk ? asNumber(createdData?.number) : null;
+ if (createOk && newIssueNumber !== null) {
+ const commentResp = await client.createIssueComment(owner, name, newIssueNumber, 'OpenChamber live-test comment.');
+ record({
+ name: 'issue comment write',
+ method: 'POST',
+ path: `/repos/${target}/issues/${newIssueNumber}/comments`,
+ status: commentResp.status,
+ verdict: isOk(commentResp) ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ const updateResp = await client.updateIssue(owner, name, newIssueNumber, {
+ title: `[OpenChamber live test] updated ${new Date().toISOString()}`,
+ body: 'Updated by the OpenChamber live-test harness.',
+ });
+ record({
+ name: 'issue update',
+ method: 'PATCH',
+ path: `/repos/${target}/issues/${newIssueNumber}`,
+ status: updateResp.status,
+ verdict: isOk(updateResp) ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ const closeResp = await client.updateIssue(owner, name, newIssueNumber, { state: 'closed' });
+ record({
+ name: 'issue close',
+ method: 'PATCH',
+ path: `/repos/${target}/issues/${newIssueNumber}`,
+ status: closeResp.status,
+ verdict: isOk(closeResp) ? 'PASS' : 'FAIL',
+ note: isOk(closeResp) ? `(closed #${newIssueNumber})` : '',
+ });
+ }
+ }
+
+ // ================= Phase 3: PR lifecycle (scratch repo) =================
+
+ console.log('\n--- PR write pass (scratch repo, deleted afterward) ---');
+ // POST /user/repos creates repos in the authenticated user's namespace, not
+ // the discovered repo's owner namespace.
+ const scratchOwner = login;
+ const scratchName = `openchamber-live-test-${Date.now()}`;
+
+ const createRepoResp = await client.request('/user/repos', {
+ method: 'POST',
+ body: {
+ name: scratchName,
+ auto_init: true,
+ default_branch: defaultBranch,
+ private: true,
+ description: 'Temporary OpenChamber live-test repository; deleted after the test.',
+ },
+ });
+ if (createRepoResp.status === 403 || createRepoResp.status === 422) {
+ record({
+ name: 'create scratch repo',
+ method: 'POST',
+ path: '/user/repos',
+ status: createRepoResp.status,
+ verdict: 'WARN',
+ note: '(token cannot create repos — PR lifecycle skipped)',
+ });
+ record({ name: 'PR create', method: 'POST', path: `/repos/${scratchOwner}/${scratchName}/pulls`, status: null, verdict: 'SKIP', note: '(no scratch repo)' });
+ record({ name: 'PR review write', method: 'POST', path: `/repos/${scratchOwner}/${scratchName}/pulls/:number/reviews`, status: null, verdict: 'SKIP', note: '(no scratch repo)' });
+ record({ name: 'PR update', method: 'PATCH', path: `/repos/${scratchOwner}/${scratchName}/pulls/:number`, status: null, verdict: 'SKIP', note: '(no scratch repo)' });
+ record({ name: 'PR merge', method: 'POST', path: `/repos/${scratchOwner}/${scratchName}/pulls/:number/merge`, status: null, verdict: 'SKIP', note: '(no scratch repo)' });
+ } else {
+ const repoOk = createRepoResp.status === 201 || createRepoResp.status === 200;
+ record({
+ name: 'create scratch repo',
+ method: 'POST',
+ path: '/user/repos',
+ status: createRepoResp.status,
+ verdict: repoOk ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ if (repoOk) {
+ const headBranch = 'oc-live-test';
+ const branchResp = await client.request(`/repos/${scratchOwner}/${scratchName}/branches`, {
+ method: 'POST',
+ body: { new_branch_name: headBranch, old_ref_name: defaultBranch },
+ });
+ const branchOk = branchResp.status === 201 || branchResp.status === 200;
+ record({
+ name: 'create branch',
+ method: 'POST',
+ path: `/repos/${scratchOwner}/${scratchName}/branches`,
+ status: branchResp.status,
+ verdict: branchOk ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ // Add a commit on the head branch so it differs from the base branch.
+ let commitOk = false;
+ if (branchOk) {
+ const fileResp = await client.request(`/repos/${scratchOwner}/${scratchName}/contents/live-test.txt`, {
+ method: 'POST',
+ body: {
+ branch: headBranch,
+ message: 'OpenChamber live-test commit',
+ content: Buffer.from('OpenChamber live test\n').toString('base64'),
+ },
+ });
+ commitOk = fileResp.status === 201 || fileResp.status === 200;
+ record({
+ name: 'create commit on branch',
+ method: 'POST',
+ path: `/repos/${scratchOwner}/${scratchName}/contents/live-test.txt`,
+ status: fileResp.status,
+ verdict: commitOk ? 'PASS' : 'FAIL',
+ note: '',
+ });
+ }
+
+ let scratchPrNumber: number | null = null;
+ if (commitOk) {
+ const prCreateResp = await client.createPullRequest(scratchOwner, scratchName, {
+ title: 'OpenChamber live-test PR',
+ head: headBranch,
+ base: defaultBranch,
+ body: 'Automated OpenChamber live-test pull request.',
+ });
+ const prCreateOk = isOk(prCreateResp);
+ const prCreateData = asRecord(prCreateResp.data);
+ scratchPrNumber = prCreateOk ? asNumber(prCreateData?.number) : null;
+ record({
+ name: 'PR create',
+ method: 'POST',
+ path: `/repos/${scratchOwner}/${scratchName}/pulls`,
+ status: prCreateResp.status,
+ verdict: prCreateOk ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ if (prCreateOk && scratchPrNumber !== null) {
+ const reviewResp = await client.createPullReview(scratchOwner, scratchName, scratchPrNumber, {
+ event: 'COMMENT',
+ body: 'OpenChamber live-test review comment.',
+ });
+ record({
+ name: 'PR review write',
+ method: 'POST',
+ path: `/repos/${scratchOwner}/${scratchName}/pulls/${scratchPrNumber}/reviews`,
+ status: reviewResp.status,
+ verdict: isOk(reviewResp) ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ const updatePrResp = await client.updatePullRequest(scratchOwner, scratchName, scratchPrNumber, {
+ title: 'OpenChamber live-test PR (updated)',
+ });
+ record({
+ name: 'PR update',
+ method: 'PATCH',
+ path: `/repos/${scratchOwner}/${scratchName}/pulls/${scratchPrNumber}`,
+ status: updatePrResp.status,
+ verdict: isOk(updatePrResp) ? 'PASS' : 'FAIL',
+ note: '',
+ });
+
+ // Gitea computes mergeability in a background worker after PR
+ // creation; merging before that finishes returns 405. Poll the PR
+ // until it reports mergeable (or give up after ~15s).
+ let mergeable = false;
+ for (let attempt = 0; attempt < 15; attempt += 1) {
+ await sleep(1000);
+ const checkResp = await client.pullRequest(scratchOwner, scratchName, scratchPrNumber);
+ if (checkResp.status !== 200) continue;
+ const checkData = asRecord(checkResp.data);
+ if (asNumber(checkData?.mergeable) === 1 || checkData?.mergeable === true) {
+ mergeable = true;
+ break;
+ }
+ }
+
+ // The correct Gitea merge payload is { Do: