From 45437a42217a093ecba6f12da6820bf49b0b11ac Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Sun, 16 Aug 2026 15:42:15 +0000 Subject: [PATCH] feat(web,ui): per-project forced git provider and settings gating Adds a per-project forced provider (github|gitlab|gitea) on top of the per-project API base URL overrides: stored under gitProviders.provider in projects/.json, sanitized server-side, and winning over remote-host detection both in useGitProvider and in server repo resolution (parseGitLabRemoteUrl/parseGiteaRemoteUrl accept any host when the provider is forced). The Projects settings page replaces the three always-visible URL fields with a provider selector (auto-detect + the three forges) and one URL override for the active provider. Global provider override fields on the GitHub/GitLab/Gitea settings tabs now render only once an account is connected, and Settings search availability matches that gating. Also fixes the useConfigStore/useDirectoryStore circular-import TDZ in the bundled chunk via the window-registered store handle and defers the directory subscription to a microtask; fixes the Gitea PR merge payload (Do carries the merge-style string enum, not a boolean + MergeMethod); and adds a documented Gitea client live-test harness (scripts/gitea-live-test.ts + client.d.ts). --- package.json | 1 + .../sections/openchamber/GitHubSettings.tsx | 12 +- .../sections/openchamber/GitLabSettings.tsx | 12 +- .../sections/openchamber/GiteaSettings.tsx | 12 +- .../projects/ProjectGitProvidersSection.tsx | 208 ++++-- .../ui/src/components/views/SettingsView.tsx | 40 +- packages/ui/src/lib/gitProvider.ts | 14 +- .../ui/src/lib/i18n/messages/de.settings.ts | 6 + .../ui/src/lib/i18n/messages/en.settings.ts | 6 + .../ui/src/lib/i18n/messages/es.settings.ts | 6 + .../ui/src/lib/i18n/messages/fr.settings.ts | 6 + .../ui/src/lib/i18n/messages/ja.settings.ts | 6 + .../ui/src/lib/i18n/messages/ko.settings.ts | 6 + .../ui/src/lib/i18n/messages/pl.settings.ts | 6 + .../src/lib/i18n/messages/pt-BR.settings.ts | 6 + .../ui/src/lib/i18n/messages/uk.settings.ts | 6 + .../src/lib/i18n/messages/zh-CN.settings.ts | 6 + .../src/lib/i18n/messages/zh-TW.settings.ts | 6 + packages/ui/src/lib/settings/search.ts | 17 +- packages/ui/src/stores/useConfigStore.ts | 44 +- packages/ui/src/stores/useDirectoryStore.ts | 11 + .../stores/useGitProviderDomainsStore.test.ts | 33 + .../src/stores/useGitProviderDomainsStore.ts | 59 +- packages/web/scripts/gitea-live-test.ts | 659 ++++++++++++++++++ .../server/lib/git-providers/DOCUMENTATION.md | 6 +- .../lib/git-providers/project-config.js | 55 +- .../lib/git-providers/project-config.test.js | 42 ++ .../web/server/lib/gitea/DOCUMENTATION.md | 6 +- packages/web/server/lib/gitea/client.d.ts | 63 ++ packages/web/server/lib/gitea/client.test.js | 8 +- packages/web/server/lib/gitea/repo.js | 10 +- packages/web/server/lib/gitea/repo.test.js | 13 + packages/web/server/lib/gitea/routes.js | 5 +- packages/web/server/lib/gitea/routes.test.js | 8 +- .../web/server/lib/gitlab/DOCUMENTATION.md | 2 +- packages/web/server/lib/gitlab/repo.js | 10 +- packages/web/server/lib/gitlab/repo.test.js | 13 + packages/web/tsconfig.json | 2 +- 38 files changed, 1306 insertions(+), 125 deletions(-) create mode 100644 packages/web/scripts/gitea-live-test.ts create mode 100644 packages/web/server/lib/gitea/client.d.ts 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: