feat(gitea): add Gitea/Forgejo as a git provider

Full parity with the existing GitLab provider:
- Server module packages/web/server/lib/gitea (auth/client/repo/routes + docs + tests)
  with Gitea REST v1 API, PAT + base URL auth, multi-account storage
- Shared GiteaAPI types and web API client
- Provider detection generalized with user-configurable custom domains
  per provider (github/gitlab/gitea), additive with built-in defaults
  (github.com, gitlab.com) and connected-account hosts; precedence
  github -> gitlab -> gitea
- Gitea PR view, issues section, pickers, integration dialog, branch
  PR status helper, settings UI (PAT + base URL + custom domains)
- Magic prompts (gitea.pr.review, gitea.issue.review) and full 11-locale
  i18n parity
This commit is contained in:
2026-08-16 16:27:49 +00:00
parent be13272eb1
commit ca91fd7e2d
65 changed files with 10667 additions and 101 deletions
@@ -0,0 +1,101 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
export type GitProviderName = 'github' | 'gitlab' | 'gitea';
/**
* Per-provider custom hostnames used to generalize git provider detection.
* Every entry is a normalized hostname: lowercase, no scheme, port, or path.
*/
export type GitProviderDomains = {
github: string[];
gitlab: string[];
gitea: string[];
};
const DOMAINS_STORAGE_KEY = 'openchamber.git-provider-domains';
const EMPTY_DOMAINS: GitProviderDomains = { github: [], gitlab: [], gitea: [] };
/**
* Normalize a raw user-supplied domain into a bare hostname. Accepts plain
* hostnames, URLs (scheme/port/path stripped), and scp-like git remotes
* (`git@host:owner/repo.git`). Returns null for empty or unparseable input.
*/
export const normalizeProviderDomain = (raw: string): string | null => {
const value = (raw ?? '').trim();
if (!value) {
return null;
}
// scp-like form: git@host:owner/repo.git
const at = value.indexOf('@');
if (at >= 0) {
const rest = value.slice(at + 1);
const colon = rest.indexOf(':');
if (colon > 0 && !rest.slice(0, colon).includes('/')) {
return rest.slice(0, colon).toLowerCase().replace(/\.$/, '');
}
}
try {
const parsed = new URL(value.includes('://') ? value : `ssh://${value}`);
return parsed.hostname.toLowerCase().replace(/\.$/, '');
} catch {
return null;
}
};
const normalizeDomainList = (entries: unknown): string[] => {
if (!Array.isArray(entries)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of entries) {
const host = normalizeProviderDomain(typeof entry === 'string' ? entry : '');
if (host && !seen.has(host)) {
seen.add(host);
result.push(host);
}
}
return result;
};
type GitProviderDomainsStore = {
domains: GitProviderDomains;
setDomains: (provider: GitProviderName, domains: string[]) => void;
};
export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
persist(
(set, get) => ({
domains: EMPTY_DOMAINS,
setDomains: (provider, domains) => {
set({
domains: {
...get().domains,
[provider]: normalizeDomainList(domains),
},
} as Partial<GitProviderDomainsStore>);
},
}),
{
name: DOMAINS_STORAGE_KEY,
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ domains: state.domains }),
merge: (persistedState, currentState) => {
const persisted = (persistedState as { domains?: Partial<GitProviderDomains> } | null)?.domains;
return {
...currentState,
// Missing or malformed entries collapse to empty arrays so the full
// three-provider shape is always produced after hydration.
domains: {
github: normalizeDomainList(persisted?.github),
gitlab: normalizeDomainList(persisted?.gitlab),
gitea: normalizeDomainList(persisted?.gitea),
},
};
},
},
),
);
@@ -0,0 +1,72 @@
import { create } from 'zustand';
import type { GiteaAuthStatus, RuntimeAPIs } from '@/lib/api/types';
import { runtimeFetch } from '@/lib/runtime-fetch';
type GiteaAuthStatusWithError = GiteaAuthStatus & { error?: string };
type GiteaAuthStore = {
status: GiteaAuthStatusWithError | null;
isLoading: boolean;
hasChecked: boolean;
setStatus: (status: GiteaAuthStatusWithError | null) => void;
refreshStatus: (
runtimeGitea?: RuntimeAPIs['gitea'],
options?: { force?: boolean }
) => Promise<GiteaAuthStatusWithError | null>;
};
const fetchStatus = async (
runtimeGitea?: RuntimeAPIs['gitea']
): Promise<GiteaAuthStatusWithError> => {
if (runtimeGitea) {
const payload = await runtimeGitea.authStatus();
return payload as GiteaAuthStatus;
}
const response = await runtimeFetch('/api/gitea/auth/status', {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = (await response.json().catch(() => null)) as GiteaAuthStatusWithError | null;
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea status');
}
return payload;
};
// In-flight dedup for refreshStatus
let _inFlightAuthRefresh: Promise<GiteaAuthStatusWithError | null> | null = null;
export const useGiteaAuthStore = create<GiteaAuthStore>((set, get) => ({
status: null,
isLoading: false,
hasChecked: false,
setStatus: (status) => set({ status, hasChecked: true }),
refreshStatus: async (runtimeGitea, options) => {
const { hasChecked, status } = get();
if (hasChecked && !options?.force) {
return status;
}
if (_inFlightAuthRefresh) return _inFlightAuthRefresh;
set({ isLoading: true });
_inFlightAuthRefresh = (async () => {
try {
const payload = await fetchStatus(runtimeGitea);
set({ status: payload, isLoading: false, hasChecked: true });
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
set({
status: { connected: false, accounts: [], error: message },
isLoading: false,
hasChecked: true,
});
return null;
}
})().finally(() => { _inFlightAuthRefresh = null; });
return _inFlightAuthRefresh;
},
}));