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:
@@ -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;
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user