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/<projectId>.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).
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -442,8 +442,16 @@ export const GitHubSettings: React.FC<{ embedded?: boolean }> = ({ embedded = fa
|
||||
)}
|
||||
|
||||
<div className={cn('flex flex-col gap-4', embedded ? 'border-t border-[var(--surface-subtle)] pt-4' : 'pt-4')}>
|
||||
<ProviderApiBaseUrlInput provider="github" />
|
||||
<ProviderDetectUrlsInput provider="github" />
|
||||
{connected ? (
|
||||
<>
|
||||
<ProviderApiBaseUrlInput provider="github" />
|
||||
<ProviderDetectUrlsInput provider="github" />
|
||||
</>
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.github') })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -292,8 +292,16 @@ export const GitLabSettings: React.FC<{ embedded?: boolean }> = ({ embedded = fa
|
||||
</div>
|
||||
|
||||
<div className={cn('flex flex-col gap-4', embedded ? 'border-t border-[var(--surface-subtle)] pt-4' : 'pt-4')}>
|
||||
<ProviderApiBaseUrlInput provider="gitlab" />
|
||||
<ProviderDetectUrlsInput provider="gitlab" />
|
||||
{connected ? (
|
||||
<>
|
||||
<ProviderApiBaseUrlInput provider="gitlab" />
|
||||
<ProviderDetectUrlsInput provider="gitlab" />
|
||||
</>
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.gitlab') })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -302,8 +302,16 @@ export const GiteaSettings: React.FC<{ embedded?: boolean }> = ({ embedded = fal
|
||||
</div>
|
||||
|
||||
<div className={cn('flex flex-col gap-4', embedded ? 'border-t border-[var(--surface-subtle)] pt-4' : 'pt-4')}>
|
||||
<ProviderApiBaseUrlInput provider="gitea" />
|
||||
<ProviderDetectUrlsInput provider="gitea" />
|
||||
{connected ? (
|
||||
<>
|
||||
<ProviderApiBaseUrlInput provider="gitea" />
|
||||
<ProviderDetectUrlsInput provider="gitea" />
|
||||
</>
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.gitea') })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<GitProviderName, IconName> = {
|
||||
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<string, unknown>).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<Record<GitProviderName, { apiBaseUrl: string }>> => {
|
||||
const payload: Partial<Record<GitProviderName, { apiBaseUrl: string }>> = {};
|
||||
for (const provider of GIT_PROVIDERS) {
|
||||
const url = drafts[provider].trim();
|
||||
provider: GitProviderName | null,
|
||||
): { provider?: GitProviderName } & Partial<Record<GitProviderName, { apiBaseUrl: string }>> => {
|
||||
const payload: Partial<Record<GitProviderName, { apiBaseUrl: string }>> & { 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<ProjectGitProvidersSectionProps> = ({ projectRef }) => {
|
||||
const { t } = useI18n();
|
||||
const globalApiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls);
|
||||
const [drafts, setDrafts] = React.useState<GitProviderApiBaseUrls>({ ...EMPTY_API_BASE_URLS });
|
||||
const [provider, setProvider] = React.useState<GitProviderName | null>(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<ProjectGitProvidersSectionProp
|
||||
return;
|
||||
}
|
||||
const loaded = readProjectApiBaseUrls(gitProviders);
|
||||
const loadedProvider = readProjectProvider(gitProviders);
|
||||
// Never clobber an edit the user started before the read resolved.
|
||||
if (!hasEditedRef.current) {
|
||||
setDrafts(loaded);
|
||||
committedSnapshotRef.current = JSON.stringify(buildGitProvidersPayload(loaded));
|
||||
setProvider(loadedProvider);
|
||||
committedSnapshotRef.current = JSON.stringify(buildGitProvidersPayload(loaded, loadedProvider));
|
||||
}
|
||||
setIsLoading(false);
|
||||
})();
|
||||
@@ -96,8 +131,9 @@ export const ProjectGitProvidersSection: React.FC<ProjectGitProvidersSectionProp
|
||||
};
|
||||
}, [projectRef.id]);
|
||||
|
||||
const commit = React.useCallback(() => {
|
||||
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<ProjectGitProvidersSectionProp
|
||||
reportSettingsSaveState('error');
|
||||
}
|
||||
});
|
||||
}, [drafts, projectRef.id]);
|
||||
}, [drafts, provider, projectRef.id]);
|
||||
|
||||
const handleProviderChange = React.useCallback((value: string) => {
|
||||
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 (
|
||||
<SettingsStackedField
|
||||
key={entryProvider}
|
||||
label={(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Icon name={PROVIDER_ICONS[entryProvider]} className="h-3.5 w-3.5" />
|
||||
{t(`settings.git.tabs.${entryProvider}`)}
|
||||
</span>
|
||||
)}
|
||||
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
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={drafts[entryProvider]}
|
||||
onChange={(event) => {
|
||||
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"
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectSettingsSubsection
|
||||
@@ -129,45 +233,39 @@ export const ProjectGitProvidersSection: React.FC<ProjectGitProvidersSectionProp
|
||||
info={t('settings.projects.page.gitProviders.description')}
|
||||
settingsItem="projects.git-providers"
|
||||
>
|
||||
{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 (
|
||||
<SettingsStackedField
|
||||
key={provider}
|
||||
label={t(`settings.${provider}.page.apiBaseUrl.label`)}
|
||||
settingsItem={`projects.git-providers.${provider}`}
|
||||
descriptionPlacement="after"
|
||||
description={
|
||||
isEmpty && !isLoading
|
||||
? t('settings.projects.page.gitProviders.inheritsGlobal', { url: inheritedUrl })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={drafts[provider]}
|
||||
onChange={(event) => {
|
||||
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"
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-muted)] p-4">
|
||||
<SettingsStackedField
|
||||
label={t('settings.projects.page.gitProviders.provider.label')}
|
||||
description={t('settings.projects.page.gitProviders.provider.description')}
|
||||
descriptionPlacement="after"
|
||||
settingsItem="projects.git-providers.provider"
|
||||
>
|
||||
<Select value={provider ?? 'auto'} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('settings.projects.page.gitProviders.provider.auto')}</SelectItem>
|
||||
{GIT_PROVIDERS.map((entryProvider) => (
|
||||
<SelectItem key={entryProvider} value={entryProvider}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Icon name={PROVIDER_ICONS[entryProvider]} className="h-3.5 w-3.5" />
|
||||
{t(`settings.git.tabs.${entryProvider}`)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsStackedField>
|
||||
|
||||
{activeUrlProvider ? (
|
||||
renderBaseUrlField(activeUrlProvider)
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.projects.page.gitProviders.provider.autoUnknown')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ProjectSettingsSubsection>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<SettingsViewProps> = ({ 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<SettingsPageSlug>(visiblePageSlugs) : null;
|
||||
return SETTINGS_PAGE_METADATA
|
||||
@@ -394,12 +422,20 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ 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.')) {
|
||||
|
||||
@@ -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<GitProviderHosts>(
|
||||
() => {
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '通知を有効化',
|
||||
|
||||
@@ -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': '알림 활성화',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Увімкнути сповіщення",
|
||||
|
||||
@@ -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': '启用通知',
|
||||
|
||||
@@ -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': '啟用通知',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<DirectoryStore>()(
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, GitProviderApiBaseUrls>;
|
||||
/**
|
||||
* 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<string, GitProviderName>;
|
||||
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<GitProviderDomainsStore>()(
|
||||
domains: EMPTY_DOMAINS,
|
||||
apiBaseUrls: EMPTY_API_BASE_URLS,
|
||||
projectApiBaseUrls: {},
|
||||
projectProviders: {},
|
||||
setDomains: (provider, domains) => {
|
||||
set({
|
||||
domains: {
|
||||
@@ -159,16 +173,18 @@ export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
|
||||
},
|
||||
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<GitProviderDomainsStore>()(
|
||||
} 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 };
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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=<pat> 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<Verdict, number> = { 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<string, unknown> | null =>
|
||||
data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||
? (data as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const asArray = (data: unknown): Array<Record<string, unknown>> =>
|
||||
Array.isArray(data)
|
||||
? data.filter((item): item is Record<string, unknown> => 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<number> {
|
||||
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: <style> } — `Do` is a
|
||||
// string enum of the merge style; there is no `MergeMethod` field.
|
||||
const mergeResp = await client.mergePullRequest(scratchOwner, scratchName, scratchPrNumber, {
|
||||
Do: 'merge',
|
||||
});
|
||||
const mergeOk = isOkOr204(mergeResp);
|
||||
record({
|
||||
name: 'PR merge',
|
||||
method: 'POST',
|
||||
path: `/repos/${scratchOwner}/${scratchName}/pulls/${scratchPrNumber}/merge`,
|
||||
status: mergeResp.status,
|
||||
verdict: mergeOk ? 'PASS' : 'WARN',
|
||||
note: mergeOk
|
||||
? ''
|
||||
: `(merge rejected${mergeable ? '' : ' before mergeability check finished'}; endpoint exercised)`,
|
||||
});
|
||||
} else {
|
||||
record({ name: 'PR review write', method: 'POST', path: `/repos/${scratchOwner}/${scratchName}/pulls/:number/reviews`, status: null, verdict: 'SKIP', note: '(PR create failed)' });
|
||||
record({ name: 'PR update', method: 'PATCH', path: `/repos/${scratchOwner}/${scratchName}/pulls/:number`, status: null, verdict: 'SKIP', note: '(PR create failed)' });
|
||||
record({ name: 'PR merge', method: 'POST', path: `/repos/${scratchOwner}/${scratchName}/pulls/:number/merge`, status: null, verdict: 'SKIP', note: '(PR create failed)' });
|
||||
}
|
||||
} else {
|
||||
record({ name: 'PR create', method: 'POST', path: `/repos/${scratchOwner}/${scratchName}/pulls`, status: null, verdict: 'SKIP', note: '(no commit on branch)' });
|
||||
record({ name: 'PR review write', method: 'POST', path: `/repos/${scratchOwner}/${scratchName}/pulls/:number/reviews`, status: null, verdict: 'SKIP', note: '(no commit on branch)' });
|
||||
record({ name: 'PR update', method: 'PATCH', path: `/repos/${scratchOwner}/${scratchName}/pulls/:number`, status: null, verdict: 'SKIP', note: '(no commit on branch)' });
|
||||
record({ name: 'PR merge', method: 'POST', path: `/repos/${scratchOwner}/${scratchName}/pulls/:number/merge`, status: null, verdict: 'SKIP', note: '(no commit on branch)' });
|
||||
}
|
||||
|
||||
// Always attempt scratch-repo cleanup, even on partial failure.
|
||||
const deleteResp = await client.request(`/repos/${scratchOwner}/${scratchName}`, { method: 'DELETE' });
|
||||
const deleteOk = deleteResp.status === 204 || deleteResp.status === 200 || deleteResp.status === 202;
|
||||
record({
|
||||
name: 'delete scratch repo',
|
||||
method: 'DELETE',
|
||||
path: `/repos/${scratchOwner}/${scratchName}`,
|
||||
status: deleteResp.status,
|
||||
verdict: deleteOk ? 'PASS' : 'WARN',
|
||||
note: deleteOk ? '' : `(left behind: ${BASE_URL}/${scratchOwner}/${scratchName} — delete manually)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n----------------------------------------');
|
||||
console.log(`Gitea live test against ${BASE_URL} (user: ${login})`);
|
||||
return summarize();
|
||||
}
|
||||
|
||||
main().then((code) => process.exit(code)).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`\nUnhandled failure: ${message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -27,11 +27,13 @@
|
||||
## Public exports — `project-config.js`
|
||||
|
||||
- `OPENCHAMBER_PROJECTS_DIR`: `path.join(OPENCHAMBER_DATA_DIR, 'projects')` (same `OPENCHAMBER_DATA_DIR` env logic as `config.js`).
|
||||
- `sanitizeProjectGitProviders(payload)`: same provider allowlist/`normalizeBaseUrl` rules as `sanitizeGitProviders`, but the per-project shape only carries `apiBaseUrl` (`detectUrls` tolerated and stripped); `undefined` when nothing valid remains.
|
||||
- `sanitizeProjectGitProviders(payload)`: same provider allowlist/`normalizeBaseUrl` rules as `sanitizeGitProviders`, but the per-project shape only carries `apiBaseUrl` (`detectUrls` tolerated and stripped) plus an optional forced `provider` (`github|gitlab|gitea`, normalized lowercase; unknown values dropped); `undefined` when nothing valid remains.
|
||||
- `readProjectJson(projectId)`: raw JSON object from `projects/<projectId>.json`; `{}` on missing/malformed file, `null` for an invalid projectId; never throws.
|
||||
- `getProjectGitProviders(projectId)`: effective per-project overrides (`{}` when unset or invalid projectId).
|
||||
- `resolveProjectIdFromDirectory(directory)`: projectId for a directory — worktree-aware: the directory is first resolved to its main repo root via `git rev-parse --git-common-dir` (handles linked worktrees created outside the project root, and a project rooted at the filesystem `/`), then the longest matching project path from the settings.json `projects` list that equals it or is a path-prefix wins; when git is unavailable or the directory is not a git repo, the directory's own exact/containment match applies; fallback `createProjectIdFromPath(directory)`; `null` for empty input. Results are cached per-directory for 60s (TTL cache, negative results included) so forge hot paths don't exec git / re-read settings.json per request. `_clearResolveProjectIdCache()` is a test-only hook to drop the cache.
|
||||
- `getProjectProviderApiBaseUrl(provider, projectId)`: per-project `apiBaseUrl` override or `null`.
|
||||
- `getProjectProvider(projectId)`: the project's forced provider (`github|gitlab|gitea`) or `null` when auto-detected.
|
||||
- `getProjectProviderFromDirectory(directory)`: forced provider for a directory's owning project (via `resolveProjectIdFromDirectory` → `getProjectProvider`), or `null`.
|
||||
- `getEffectiveProviderApiBaseUrl(provider, directory)`: project override -> `getProviderApiBaseUrl(provider)` (global -> built-in default); `null` only when nothing resolves.
|
||||
- `saveProjectGitProviders(projectId, payload)`: persist the per-project overrides, preserving all other project JSON keys (atomic tmp-file + rename write); returns the saved `gitProviders` object (or `{}`); throws for an invalid projectId.
|
||||
|
||||
@@ -65,6 +67,7 @@
|
||||
"version": 1,
|
||||
"projectNotes": "...",
|
||||
"gitProviders": {
|
||||
"provider": "gitlab",
|
||||
"github": { "apiBaseUrl": "https://project.github.example.com" },
|
||||
"gitlab": { "apiBaseUrl": "https://project.gitlab.example.com" }
|
||||
}
|
||||
@@ -72,6 +75,7 @@
|
||||
```
|
||||
|
||||
- Per-project `gitProviders` carry `apiBaseUrl` only (no `detectUrls`); unknown provider keys are dropped and `apiBaseUrl` is normalized with the same rules as the global settings.
|
||||
- Optional `provider` (`github|gitlab|gitea`) forces the project's git provider instead of auto-detection. Client-side, a forced provider short-circuits remote-host detection (`useGitProvider`); server-side, it makes any remote host acceptable for that provider's repo parsing (`gitlab/repo.js`, `gitea/repo.js`).
|
||||
- `projects/<projectId>.json` is shared with the scheduled-tasks/projectNotes config; reading and saving preserve all other keys (the `gitProviders` key is omitted entirely when empty).
|
||||
- **Precedence per provider:** project override (`projects/<projectId>.json` → `getProjectProviderApiBaseUrl`) > global `settings.json` (`getProviderApiBaseUrl`) > built-in default (`GIT_PROVIDER_DEFAULTS`).
|
||||
|
||||
|
||||
@@ -30,26 +30,35 @@ const normalizeProjectPathForMatch = (value) => {
|
||||
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || value;
|
||||
};
|
||||
|
||||
const GIT_PROVIDER_SET = new Set(['github', 'gitlab', 'gitea']);
|
||||
|
||||
/**
|
||||
* Validate/normalize a per-project `gitProviders` value. Same provider
|
||||
* allowlist and `normalizeBaseUrl` rules as `sanitizeGitProviders`, but the
|
||||
* per-project shape only carries `apiBaseUrl` (any `detectUrls` are tolerated
|
||||
* and stripped). Returns undefined when nothing valid remains.
|
||||
* and stripped) plus an optional forced `provider` (github|gitlab|gitea) that
|
||||
* overrides automatic provider detection for the project. Returns undefined
|
||||
* when nothing valid remains.
|
||||
*/
|
||||
export function sanitizeProjectGitProviders(payload) {
|
||||
const sanitized = sanitizeGitProviders(payload);
|
||||
if (!sanitized) {
|
||||
return undefined;
|
||||
}
|
||||
const result = {};
|
||||
for (const provider of Object.keys(sanitized)) {
|
||||
const entry = sanitized[provider];
|
||||
const normalized = {};
|
||||
if (entry.apiBaseUrl) {
|
||||
normalized.apiBaseUrl = entry.apiBaseUrl;
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const forcedProvider = typeof payload.provider === 'string' ? payload.provider.trim().toLowerCase() : '';
|
||||
if (GIT_PROVIDER_SET.has(forcedProvider)) {
|
||||
result.provider = forcedProvider;
|
||||
}
|
||||
if (Object.keys(normalized).length > 0) {
|
||||
result[provider] = normalized;
|
||||
}
|
||||
const sanitized = sanitizeGitProviders(payload);
|
||||
if (sanitized) {
|
||||
for (const provider of Object.keys(sanitized)) {
|
||||
const entry = sanitized[provider];
|
||||
const normalized = {};
|
||||
if (entry.apiBaseUrl) {
|
||||
normalized.apiBaseUrl = entry.apiBaseUrl;
|
||||
}
|
||||
if (Object.keys(normalized).length > 0) {
|
||||
result[provider] = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
@@ -270,6 +279,28 @@ export function getProjectProviderApiBaseUrl(provider, projectId) {
|
||||
return getProjectGitProviders(projectId)[provider]?.apiBaseUrl || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The project's forced git provider (github|gitlab|gitea), or null when the
|
||||
* provider is auto-detected from the remote. Only meaningful for a projectId
|
||||
* that resolves to a project config; invalid ids yield null.
|
||||
*/
|
||||
export function getProjectProvider(projectId) {
|
||||
const providers = getProjectGitProviders(projectId);
|
||||
return providers.provider || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The forced git provider for a directory's owning project, or null when
|
||||
* unset or when the directory resolves to no project.
|
||||
*/
|
||||
export function getProjectProviderFromDirectory(directory) {
|
||||
const projectId = resolveProjectIdFromDirectory(directory);
|
||||
if (!projectId) {
|
||||
return null;
|
||||
}
|
||||
return getProjectProvider(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective API base URL for a provider given a directory: the project override
|
||||
* (when the directory resolves to a project with one) wins, else the global
|
||||
|
||||
@@ -12,6 +12,8 @@ process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
|
||||
const {
|
||||
sanitizeProjectGitProviders,
|
||||
getProjectGitProviders,
|
||||
getProjectProvider,
|
||||
getProjectProviderFromDirectory,
|
||||
resolveProjectIdFromDirectory,
|
||||
getProjectProviderApiBaseUrl,
|
||||
getEffectiveProviderApiBaseUrl,
|
||||
@@ -73,6 +75,46 @@ describe('sanitizeProjectGitProviders', () => {
|
||||
expect(sanitizeProjectGitProviders([])).toBeUndefined();
|
||||
expect(getProjectGitProviders('proj_1')).toEqual({});
|
||||
});
|
||||
|
||||
test('keeps a forced provider when it is one of the known providers', () => {
|
||||
expect(sanitizeProjectGitProviders({
|
||||
provider: 'GitLab',
|
||||
gitlab: { apiBaseUrl: 'gitlab.example.com' },
|
||||
})).toEqual({
|
||||
provider: 'gitlab',
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
test('drops an unknown or empty forced provider', () => {
|
||||
expect(sanitizeProjectGitProviders({
|
||||
provider: 'bitbucket',
|
||||
github: { apiBaseUrl: 'github.example.com' },
|
||||
})).toEqual({ github: { apiBaseUrl: 'https://github.example.com' } });
|
||||
expect(sanitizeProjectGitProviders({ provider: '' })).toBeUndefined();
|
||||
expect(sanitizeProjectGitProviders({ provider: ' ' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectProvider / getProjectProviderFromDirectory', () => {
|
||||
test('returns the forced provider or null', () => {
|
||||
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(projectFile('proj_provider'), JSON.stringify({
|
||||
gitProviders: { provider: 'gitea', gitea: { apiBaseUrl: 'https://gitea.example.com' } },
|
||||
}, null, 2));
|
||||
expect(getProjectProvider('proj_provider')).toBe('gitea');
|
||||
expect(getProjectProvider('proj_missing')).toBeNull();
|
||||
});
|
||||
|
||||
test('resolves the forced provider through the directory', () => {
|
||||
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(projectFile('proj_forced'), JSON.stringify({
|
||||
gitProviders: { provider: 'gitlab' },
|
||||
}, null, 2));
|
||||
writeSettingsProjects([{ id: 'proj_forced', path: '/home/user/gl' }]);
|
||||
expect(getProjectProviderFromDirectory('/home/user/gl')).toBe('gitlab');
|
||||
expect(getProjectProviderFromDirectory('/home/user/unregistered')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveProjectGitProviders round-trip', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- `packages/web/server/lib/gitea/routes.js`: Express route registration for `/api/gitea/*` endpoints.
|
||||
- `packages/web/server/lib/gitea/auth.js`: PAT auth storage, multi-account support, base URL normalization.
|
||||
- `packages/web/server/lib/gitea/client.js`: raw `fetch` Gitea REST v1 client (timeout, ETag conditional GET, rate-limit cooldown, `Link`-header pagination, redirect handling).
|
||||
- `packages/web/server/lib/gitea/client.d.ts`: hand-written type declaration for `client.js` (the module is plain JS); consumed by the live-test harness.
|
||||
- `packages/web/server/lib/gitea/repo.js`: Gitea remote URL parsing (flat `owner/repo`) and directory-to-repo resolution.
|
||||
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: API route layer that calls this module (via `registerGiteaRoutes`).
|
||||
- `packages/web/src/api/gitea.ts`: web client wrapper for Gitea endpoints.
|
||||
@@ -48,7 +49,7 @@
|
||||
- Auth storage: `~/.config/openchamber/gitea-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
|
||||
- Writes are atomic (tmp file + rename) and file mode is `0o600`.
|
||||
- Base URL resolution: the caller-supplied `baseUrl` (normalized) is the primary source, then the effective default (configured `settings.json` `gitProviders.gitea.apiBaseUrl`, else `https://codeberg.org`). Stored entries without a usable base URL are dropped.
|
||||
- Per-project overrides: a per-project `gitProviders.gitea.apiBaseUrl` override (stored under `projects/<projectId>.json`, resolved via `getEffectiveProviderApiBaseUrl('gitea', directory)` in `packages/web/server/lib/git-providers/project-config.js`) replaces the account's base URL for that project's data routes (`getGiteaClientOrNull(directory)`), and its host is accepted for directory-to-repo resolution (`resolveGiteaRepoFromDirectory`). Global routes (`auth/status`, `auth/connect`, `auth/activate`, DELETE auth, `me`, `repo/branches`) stay global.
|
||||
- Per-project overrides: a per-project `gitProviders.gitea.apiBaseUrl` override (stored under `projects/<projectId>.json`, resolved via `getEffectiveProviderApiBaseUrl('gitea', directory)` in `packages/web/server/lib/git-providers/project-config.js`) replaces the account's base URL for that project's data routes (`getGiteaClientOrNull(directory)`), and its host is accepted for directory-to-repo resolution (`resolveGiteaRepoFromDirectory`). A forced `gitProviders.provider: 'gitea'` accepts any remote host for directory resolution. Global routes (`auth/status`, `auth/connect`, `auth/activate`, DELETE auth, `me`, `repo/branches`) stay global.
|
||||
- Account id: `` `${host}:${username}` `` (e.g. `gitea.example.com:alice`), falling back to `token:<first8>` when the username is missing.
|
||||
- Auth header on every request: `Authorization: token <pat>`.
|
||||
- Gitea's `GET /user` uses `login`/`full_name`/`html_url`; `setGiteaAuth` accepts both that and the `username`/`web_url` variants.
|
||||
@@ -80,7 +81,7 @@
|
||||
- Commit statuses: `GET /repos/{owner}/{repo}/commits/{sha}/statuses?limit=100` (the `prs/statuses` route resolves the PR `head.sha` first, then maps statuses to `{ state, name, description, url, createdAt }` with `state` lowercased).
|
||||
- PR create: `POST /repos/{owner}/{repo}/pulls` with `{ title, head, base, body? }` (body omitted when absent).
|
||||
- PR update: `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{ title?, body?, state? }` (undefined fields omitted; the PR number IS the issue index, so the edit-issue `state` transition applies directly).
|
||||
- PR merge: `POST /repos/{owner}/{repo}/pulls/{number}/merge` with `{ Do: true, MergeMethod: 'merge' | 'squash' | 'rebase' }` (`method` defaults to `'merge'`).
|
||||
- PR merge: `POST /repos/{owner}/{repo}/pulls/{number}/merge` with `{ Do: 'merge' | 'squash' | 'rebase' }` (`method` defaults to `'merge'`). `Do` is a string enum of the merge style — Gitea has no separate `MergeMethod` field.
|
||||
- Issue comment write: `POST /repos/{owner}/{repo}/issues/{number}/comments` with `{ body }` (PRs are issues at the API level, so `prs/comment` uses the same endpoint with the PR number as the index).
|
||||
- Issue update: `PATCH /repos/{owner}/{repo}/issues/{number}` with `{ title?, body?, state?, labels?, assignees?, milestone?, unset_milestone? }` (labels are label **names**, assignees are logins; `milestone` is resolved from a title to a milestone id and `null` sets `unset_milestone: true`).
|
||||
- Pull review write: `POST /repos/{owner}/{repo}/pulls/{number}/reviews` with `{ event, body? }` (`event` is `APPROVED`/`REQUEST_CHANGES`/`COMMENT`).
|
||||
@@ -131,6 +132,7 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
|
||||
|
||||
- `packages/web/src/api/gitea.ts` calls every `/api/gitea/*` endpoint and maps them to the shared types.
|
||||
- `packages/ui/src/lib/api/types.ts` defines the shared `Gitea*` response types used across web, desktop, VS Code, and mobile.
|
||||
- `packages/web/scripts/gitea-live-test.ts` is a live-test harness for the raw client: run with `bun run gitea:live-test` (requires `GITEA_TOKEN`; `GITEA_BASE_URL` defaults to `https://git.example.com`). It exercises every client method against a real instance, reports PASS/WARN/FAIL/SKIP per endpoint, and runs a controlled write pass (scratch issue plus a scratch-repo PR lifecycle that is deleted afterward).
|
||||
|
||||
## Failure handling
|
||||
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Hand-written declaration for the plain-JS Gitea/Forgejo REST v1 client
|
||||
// (client.js). Kept in sync with the client's public surface; the web package
|
||||
// type-checks the gitea live-test harness which imports this module.
|
||||
|
||||
export interface GiteaClientPageInfo {
|
||||
page: number | null;
|
||||
next: string | null;
|
||||
total: number | null;
|
||||
hasMore: boolean;
|
||||
nextUrl?: string;
|
||||
}
|
||||
|
||||
export interface GiteaClientResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
data: unknown;
|
||||
page: GiteaClientPageInfo | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type GiteaQuery = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
export interface GiteaRequestOptions {
|
||||
method?: string;
|
||||
query?: GiteaQuery;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
export interface GiteaClient {
|
||||
baseUrl: string;
|
||||
request: (path: string, options?: GiteaRequestOptions) => Promise<GiteaClientResponse>;
|
||||
user: () => Promise<GiteaClientResponse>;
|
||||
repo: (owner: string, repo: string) => Promise<GiteaClientResponse>;
|
||||
issues: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
issue: (owner: string, repo: string, number: number) => Promise<GiteaClientResponse>;
|
||||
issueComments: (owner: string, repo: string, number: number, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
createIssueComment: (owner: string, repo: string, number: number, body: string) => Promise<GiteaClientResponse>;
|
||||
createIssue: (owner: string, repo: string, params: Record<string, unknown>) => Promise<GiteaClientResponse>;
|
||||
updateIssue: (owner: string, repo: string, number: number, params: Record<string, unknown>) => Promise<GiteaClientResponse>;
|
||||
milestones: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
repoLabels: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
pullRequests: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
pullRequest: (owner: string, repo: string, number: number) => Promise<GiteaClientResponse>;
|
||||
pullRequestDiff: (owner: string, repo: string, number: number) => Promise<GiteaClientResponse>;
|
||||
pullRequestFiles: (owner: string, repo: string, number: number, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
pullRequestCommits: (owner: string, repo: string, number: number, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
pullRequestReviews: (owner: string, repo: string, number: number, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
createPullReview: (owner: string, repo: string, number: number, params: Record<string, unknown>) => Promise<GiteaClientResponse>;
|
||||
commitStatuses: (owner: string, repo: string, sha: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
createPullRequest: (owner: string, repo: string, body: Record<string, unknown>) => Promise<GiteaClientResponse>;
|
||||
updatePullRequest: (owner: string, repo: string, number: number, body: Record<string, unknown>) => Promise<GiteaClientResponse>;
|
||||
mergePullRequest: (owner: string, repo: string, number: number, body: Record<string, unknown>) => Promise<GiteaClientResponse>;
|
||||
branches: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
assignees: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
tags: (owner: string, repo: string, params?: GiteaQuery) => Promise<GiteaClientResponse>;
|
||||
}
|
||||
|
||||
export function createGiteaClient(options: { token: string; baseUrl: string }): GiteaClient;
|
||||
export function getGiteaClientOrNull(directory?: string): GiteaClient | null;
|
||||
export function isGiteaRateLimited(): boolean;
|
||||
export function noteGiteaRateLimit(error: unknown): void;
|
||||
@@ -242,17 +242,17 @@ describe('pull request write methods', () => {
|
||||
expect(JSON.parse(options.body)).toEqual({ title: 'Updated', body: 'Body text' });
|
||||
});
|
||||
|
||||
test('mergePullRequest POSTs Do and MergeMethod to the merge endpoint', async () => {
|
||||
test('mergePullRequest POSTs the merge style in Do to the merge endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ merged: true }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.mergePullRequest('owner', 'repo', 5, { Do: true, MergeMethod: 'squash' });
|
||||
await client.mergePullRequest('owner', 'repo', 5, { Do: 'squash' });
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/5/merge');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ Do: true, MergeMethod: 'squash' });
|
||||
expect(JSON.parse(options.body)).toEqual({ Do: 'squash' });
|
||||
});
|
||||
|
||||
test('write methods surface error statuses without throwing', async () => {
|
||||
@@ -260,7 +260,7 @@ describe('pull request write methods', () => {
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.mergePullRequest('owner', 'repo', 5, { Do: true, MergeMethod: 'merge' });
|
||||
const result = await client.mergePullRequest('owner', 'repo', 5, { Do: 'merge' });
|
||||
expect(result.status).toBe(409);
|
||||
expect(result.data).toEqual({ message: 'Conflict' });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRemoteUrl } from '../git/index.js';
|
||||
import { getGiteaAuthAccounts, normalizeBaseUrl } from './auth.js';
|
||||
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
|
||||
import { getEffectiveProviderApiBaseUrl, getProjectProviderFromDirectory } from '../git-providers/project-config.js';
|
||||
|
||||
// When no explicit host allowlist is provided, accept any host that matches the
|
||||
// base URL of a stored Gitea account. Gitea/Forgejo is self-hosted, so there is
|
||||
@@ -50,7 +50,7 @@ function acceptedHosts(knownHosts) {
|
||||
* When omitted, hosts from stored auth accounts are accepted. `github.com` and
|
||||
* `gitlab.com` are never accepted.
|
||||
*/
|
||||
export const parseGiteaRemoteUrl = (raw, knownHosts) => {
|
||||
export const parseGiteaRemoteUrl = (raw, knownHosts, options = {}) => {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export const parseGiteaRemoteUrl = (raw, knownHosts) => {
|
||||
if (host === 'github.com' || host === 'gitlab.com') {
|
||||
return null;
|
||||
}
|
||||
if (!acceptedHosts(knownHosts).has(host)) {
|
||||
if (!options.allowAnyHost && !acceptedHosts(knownHosts).has(host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -133,8 +133,10 @@ export async function resolveGiteaRepoFromDirectory(directory, remoteName = 'ori
|
||||
// ignore a malformed override base URL
|
||||
}
|
||||
}
|
||||
// A forced gitea provider (per-project override) accepts any remote host.
|
||||
const forcedProvider = getProjectProviderFromDirectory(directory);
|
||||
return {
|
||||
repo: parseGiteaRemoteUrl(remoteUrl, knownHosts),
|
||||
repo: parseGiteaRemoteUrl(remoteUrl, knownHosts, { allowAnyHost: forcedProvider === 'gitea' }),
|
||||
remoteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ vi.mock('../git-providers/project-config.js', async (importOriginal) => {
|
||||
}
|
||||
return actual.getEffectiveProviderApiBaseUrl(provider, directory);
|
||||
}),
|
||||
getProjectProviderFromDirectory: vi.fn((directory) => {
|
||||
if (directory === '/forced/project') {
|
||||
return 'gitea';
|
||||
}
|
||||
return actual.getProjectProviderFromDirectory(directory);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -150,4 +156,11 @@ describe('resolveGiteaRepoFromDirectory', () => {
|
||||
const { repo } = await resolveGiteaRepoFromDirectory('/some/project');
|
||||
expect(repo).toBeNull();
|
||||
});
|
||||
|
||||
test('accepts any remote host when the provider is forced to gitea', async () => {
|
||||
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitea.internal.corp:team/app.git');
|
||||
const { repo, remoteUrl } = await resolveGiteaRepoFromDirectory('/forced/project');
|
||||
expect(remoteUrl).toBe('git@gitea.internal.corp:team/app.git');
|
||||
expect(repo).toMatchObject({ owner: 'team', repo: 'app', host: 'gitea.internal.corp' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1225,7 +1225,10 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
||||
}
|
||||
|
||||
const body = { Do: true, MergeMethod: method };
|
||||
// Gitea's merge endpoint takes the merge style directly in `Do` (a
|
||||
// string enum: merge/rebase/rebase-merge/squash/fast-forward-only/
|
||||
// manually-merged). There is no separate `MergeMethod` field.
|
||||
const body = { Do: method };
|
||||
|
||||
const resp = await withTimeout(client.mergePullRequest(owner, repo, number, body), ROUTE_TIMEOUT_MS, 'gitea pr merge');
|
||||
if (resp.status === 429) {
|
||||
|
||||
@@ -889,7 +889,7 @@ describe('Gitea data routes', () => {
|
||||
expect(response.body).toEqual({ error: 'Pull request not found' });
|
||||
});
|
||||
|
||||
test('pr/merge POSTs Do/MergeMethod and reports merged:true', async () => {
|
||||
test('pr/merge POSTs the merge style in Do and reports merged:true', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') {
|
||||
@@ -908,10 +908,10 @@ describe('Gitea data routes', () => {
|
||||
expect(response.body).toEqual({ connected: true, merged: true });
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ Do: true, MergeMethod: 'merge' });
|
||||
expect(JSON.parse(options.body)).toEqual({ Do: 'merge' });
|
||||
});
|
||||
|
||||
test('pr/merge maps the method to MergeMethod', async () => {
|
||||
test('pr/merge maps the method to Do', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') {
|
||||
@@ -927,7 +927,7 @@ describe('Gitea data routes', () => {
|
||||
.send({ directory: '/tmp/work', number: 12, method: 'squash' });
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ Do: true, MergeMethod: 'squash' });
|
||||
expect(JSON.parse(options.body)).toEqual({ Do: 'squash' });
|
||||
});
|
||||
|
||||
test('pr/merge passes through a Gitea merge rejection as merged:false', async () => {
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
- Auth storage: `~/.config/openchamber/gitlab-auth.json` (override with `OPENCHAMBER_DATA_DIR`).
|
||||
- Writes are atomic (tmp file + rename) and file mode is `0o600`.
|
||||
- Base URL resolution: caller-supplied `baseUrl` (normalized) -> effective default via `getGitLabDefaultBaseUrl()` (configured `settings.json` `gitProviders.gitlab.apiBaseUrl`, else `https://gitlab.com`).
|
||||
- Per-project overrides: data routes resolve a directory-scoped API base via `getEffectiveProviderApiBaseUrl('gitlab', directory)` (in `packages/web/server/lib/git-providers/project-config.js`). A per-project `gitProviders.gitlab.apiBaseUrl` override (stored under `projects/<projectId>.json`) replaces the global default for that project's routes, and its host is accepted for directory-to-repo resolution (`resolveGitLabRepoFromDirectory`); a connected account whose host matches the remote keeps its own base URL. Global routes (`auth/connect`, `auth/status`, `auth/activate`, `me`, `repo/branches`) stay global.
|
||||
- Per-project overrides: data routes resolve a directory-scoped API base via `getEffectiveProviderApiBaseUrl('gitlab', directory)` (in `packages/web/server/lib/git-providers/project-config.js`). A per-project `gitProviders.gitlab.apiBaseUrl` override (stored under `projects/<projectId>.json`) replaces the global default for that project's routes, and its host is accepted for directory-to-repo resolution (`resolveGitLabRepoFromDirectory`); a connected account whose host matches the remote keeps its own base URL. A forced `gitProviders.provider: 'gitlab'` accepts any remote host for directory resolution. Global routes (`auth/connect`, `auth/status`, `auth/activate`, `me`, `repo/branches`) stay global.
|
||||
- Account id: `` `${host}:${username}` `` (e.g. `gitlab.com:alice`), falling back to `token:<first8>` when the username is missing.
|
||||
- Auth header on every request: `PRIVATE-TOKEN: <pat>`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRemoteUrl } from '../git/index.js';
|
||||
import { getGitLabAuthAccounts, normalizeBaseUrl } from './auth.js';
|
||||
import { getEffectiveProviderApiBaseUrl } from '../git-providers/project-config.js';
|
||||
import { getEffectiveProviderApiBaseUrl, getProjectProviderFromDirectory } from '../git-providers/project-config.js';
|
||||
|
||||
// When no explicit host allowlist is provided, accept gitlab.com or any host
|
||||
// that matches the base URL of a stored GitLab account. Never github.com.
|
||||
@@ -49,7 +49,7 @@ function acceptedHosts(knownHosts) {
|
||||
* When omitted, `gitlab.com` and hosts from stored auth accounts are accepted.
|
||||
* github.com is never accepted.
|
||||
*/
|
||||
export const parseGitLabRemoteUrl = (raw, knownHosts) => {
|
||||
export const parseGitLabRemoteUrl = (raw, knownHosts, options = {}) => {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export const parseGitLabRemoteUrl = (raw, knownHosts) => {
|
||||
if (host === 'github.com') {
|
||||
return null;
|
||||
}
|
||||
if (!acceptedHosts(knownHosts).has(host)) {
|
||||
if (!options.allowAnyHost && !acceptedHosts(knownHosts).has(host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,8 +131,10 @@ export async function resolveGitLabRepoFromDirectory(directory, remoteName = 'or
|
||||
// ignore a malformed override base URL
|
||||
}
|
||||
}
|
||||
// A forced gitlab provider (per-project override) accepts any remote host.
|
||||
const forcedProvider = getProjectProviderFromDirectory(directory);
|
||||
return {
|
||||
repo: parseGitLabRemoteUrl(remoteUrl, knownHosts),
|
||||
repo: parseGitLabRemoteUrl(remoteUrl, knownHosts, { allowAnyHost: forcedProvider === 'gitlab' }),
|
||||
remoteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ vi.mock('../git-providers/project-config.js', async (importOriginal) => {
|
||||
}
|
||||
return actual.getEffectiveProviderApiBaseUrl(provider, directory);
|
||||
}),
|
||||
getProjectProviderFromDirectory: vi.fn((directory) => {
|
||||
if (directory === '/forced/project') {
|
||||
return 'gitlab';
|
||||
}
|
||||
return actual.getProjectProviderFromDirectory(directory);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -147,4 +153,11 @@ describe('resolveGitLabRepoFromDirectory', () => {
|
||||
const { repo } = await resolveGitLabRepoFromDirectory('/some/project');
|
||||
expect(repo).toBeNull();
|
||||
});
|
||||
|
||||
test('accepts any remote host when the provider is forced to gitlab', async () => {
|
||||
vi.mocked(getRemoteUrl).mockResolvedValue('git@gitlab.internal.corp:team/app.git');
|
||||
const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/forced/project');
|
||||
expect(remoteUrl).toBe('git@gitlab.internal.corp:team/app.git');
|
||||
expect(repo).toMatchObject({ namespace: 'team', project: 'app', host: 'gitlab.internal.corp' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"@openchamber/web/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "../ui/src", "../ui/src/types/**/*"]
|
||||
"include": ["src", "scripts", "../ui/src", "../ui/src/types/**/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user