Files
paseo/packages/server/src/services/forge-registry.ts
T
a8ebd390fa feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-17 15:03:26 +08:00

179 lines
5.9 KiB
TypeScript

import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost } from "@getpaseo/protocol/git-remote";
import { createGitHubService, probeGitHubHost } from "./github-service.js";
import type { ForgeService } from "./forge-service.js";
import { createGiteaService, resolveGiteaFamilyForge } from "./gitea-service.js";
import { createGitLabService, probeGitLabHost } from "./gitlab-service.js";
export type ForgeServiceFactory = () => ForgeService;
export interface ForgeAdapterRegistration {
createService: ForgeServiceFactory;
matchesHost?: (host: string) => boolean;
probeHost?: (host: string) => Promise<boolean>;
}
/**
* Open composition boundary for forge adapters. Resolver code depends only on
* these registration hooks, so a new adapter does not require another branch.
*/
export class ForgeRegistry {
readonly #adapters = new Map<string, ForgeAdapterRegistration>();
readonly #warnedAmbiguousHosts = new Set<string>();
constructor(entries: Iterable<readonly [string, ForgeAdapterRegistration]> = []) {
for (const [forge, adapter] of entries) {
this.register(forge, adapter);
}
}
register(forge: string, adapter: ForgeAdapterRegistration): () => void {
const normalizedForge = parseForgeId(forge);
if (!normalizedForge) {
throw new Error(`Invalid forge adapter id: ${forge}`);
}
if (this.#adapters.has(normalizedForge)) {
throw new Error(`Forge adapter already registered: ${normalizedForge}`);
}
this.#adapters.set(normalizedForge, adapter);
return () => {
if (this.#adapters.get(normalizedForge) === adapter) {
this.#adapters.delete(normalizedForge);
}
};
}
ids(): string[] {
return [...this.#adapters.keys()];
}
has(forge: string): boolean {
const normalizedForge = parseForgeId(forge);
return normalizedForge ? this.#adapters.has(normalizedForge) : false;
}
create(forge: string): ForgeService | null {
const normalizedForge = parseForgeId(forge);
if (!normalizedForge) {
return null;
}
const adapter = this.#adapters.get(normalizedForge);
return adapter ? adapter.createService() : null;
}
matchHost(host: string): string | null {
const matches: string[] = [];
for (const [forge, adapter] of this.#adapters) {
if (adapter.matchesHost?.(host)) {
matches.push(forge);
}
}
if (matches.length > 1) {
this.#warnAmbiguous("matched", host, matches);
return null;
}
return matches[0] ?? null;
}
async probeHost(host: string): Promise<string | null> {
const entries = [...this.#adapters];
// allSettled, not all: a third-party probe that throws means "not this
// forge", never a crash of the shared resolution path.
const settled = await Promise.allSettled(
entries.map(async ([, adapter]) =>
adapter.probeHost ? await adapter.probeHost(host) : false,
),
);
const matches = entries
.filter((_, index) => {
const result = settled[index];
return result.status === "fulfilled" && result.value === true;
})
.map(([forge]) => forge);
if (matches.length > 1) {
this.#warnAmbiguous("recognized", host, matches);
return null;
}
return matches[0] ?? null;
}
// Genuine ambiguity (two adapters both claiming a host) degrades to "no
// forge" rather than crashing the shared resolution path used by every
// workspace's PR-status poll; warn once per host so the misconfiguration
// is still visible.
#warnAmbiguous(verb: string, host: string, matches: string[]): void {
const key = `${verb}:${host}`;
if (this.#warnedAmbiguousHosts.has(key)) {
return;
}
this.#warnedAmbiguousHosts.add(key);
console.warn(`Multiple forge adapters ${verb} host ${host}: ${matches.join(", ")}`);
}
}
function parseForgeId(forge: string): string | null {
const normalized = forge.trim().toLowerCase();
return /^[a-z0-9][a-z0-9._-]*$/.test(normalized) ? normalized : null;
}
/**
* Build a host matcher from a forge's declared cloud hosts in the manifest, so
* the registry never hardcodes a host list. Returns undefined for forges with
* no cloud hosts (recognized only by runtime probe, e.g. Forgejo).
*/
function matchesCloudHost(forgeId: string): ((host: string) => boolean) | undefined {
const hosts = getForgeDefinition(forgeId)?.cloudHosts;
if (!hosts || hosts.length === 0) {
return undefined;
}
const normalized = new Set(hosts.map(normalizeHost));
return (host) => normalized.has(normalizeHost(host));
}
export const defaultForgeRegistry = new ForgeRegistry([
// GitHub Enterprise Server is recognized at runtime by probeHost, exactly like
// self-hosted GitLab/Gitea: github.com short-circuits via matchHost, so the
// probe only runs on non-cloud hosts. The PR-status poll gates on the resolver
// alone (no cloud-identity check), so a probed GHES host polls normally.
[
"github",
{
createService: createGitHubService,
matchesHost: matchesCloudHost("github"),
probeHost: probeGitHubHost,
},
],
[
"gitlab",
{
createService: createGitLabService,
matchesHost: matchesCloudHost("gitlab"),
probeHost: probeGitLabHost,
},
],
[
"gitea",
{
createService: createGiteaService,
matchesHost: matchesCloudHost("gitea"),
probeHost: async (host) => (await resolveGiteaFamilyForge(host)) === "gitea",
},
],
[
"forgejo",
{
createService: createGiteaService,
probeHost: async (host) => (await resolveGiteaFamilyForge(host)) === "forgejo",
},
],
["codeberg", { createService: createGiteaService, matchesHost: matchesCloudHost("codeberg") }],
]);
export function createForgeService(forge: string): ForgeService | null {
return defaultForgeRegistry.create(forge);
}
export function probeRegisteredForgeHost(host: string): Promise<string | null> {
return defaultForgeRegistry.probeHost(host);
}