Files
paseo/packages/server/src/services/forge-resolver.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

258 lines
9.1 KiB
TypeScript

import { LRUCache } from "lru-cache";
import { parseGitRemoteLocation, type GitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { resolveSshHostname, type SshHostnameResolver } from "../utils/ssh-hostname.js";
import { defaultResolveRemoteUrl } from "./forge-cli-command.js";
import type { ForgeService } from "./forge-service.js";
import {
createForgeService,
defaultForgeRegistry,
probeRegisteredForgeHost,
} from "./forge-registry.js";
export interface ForgeResolution {
/** Registered forge id, e.g. "github" or "gitlab". */
forge: string;
/** Remote host the cwd resolves to, e.g. "github.com" or "gitlab.example.com". */
host: string;
/** Adapter for {@link forge}, shared across resolutions of the same forge. */
service: ForgeService;
}
/** Probe a host for a forge id when the name heuristic is inconclusive. */
export type ForgeHostProbe = (host: string) => Promise<string | null>;
export interface CreateForgeResolverOptions {
resolveRemoteUrl?: (cwd: string) => Promise<string | null>;
createService?: (forge: string) => ForgeService | null;
probeForge?: ForgeHostProbe;
resolveSshHostname?: SshHostnameResolver;
now?: () => number;
}
export interface ForgeResolver {
/** Resolve the forge for a working directory, or null when none applies. */
resolve(cwd: string): Promise<ForgeResolution | null>;
/** Resolve from a known origin remote URL using only the name heuristic + probe cache. */
resolveFromRemoteUrl(remoteUrl: string | null): ForgeResolution | null;
/** Resolve from a known origin remote URL, running the per-host probe when needed. */
resolveFromRemoteUrlAsync(remoteUrl: string | null): Promise<ForgeResolution | null>;
/**
* Invalidate every cached adapter's state for a cwd. Routed through the
* resolver so invalidation hits the same adapter instance the poller reads,
* regardless of which forge backs the workspace.
*/
invalidate(cwd: string): void;
}
// A positive probe (host IS a known forge) is cached permanently; a negative one
// expires so a CLI installed/authenticated later is picked up without a restart.
const NEGATIVE_PROBE_TTL_MS = 60_000;
// Cap the per-resolver caches: session handlers resolve request-supplied cwds,
// so unbounded Maps would grow for the daemon's lifetime. Distinct hosts/cwds
// are few in practice; LRU eviction keeps the working set without a leak.
const FORGE_RESOLVER_CACHE_MAX = 512;
export function parseRemoteHost(url: string): string | null {
return parseGitRemoteLocation(url)?.host ?? null;
}
/** Map a remote host through the matchers owned by registered adapters. */
export function forgeForHost(host: string): string | null {
return defaultForgeRegistry.matchHost(host);
}
export function createForgeResolver(options: CreateForgeResolverOptions = {}): ForgeResolver {
const resolveRemoteUrl = options.resolveRemoteUrl ?? defaultResolveRemoteUrl;
const create = options.createService ?? createForgeService;
const probeForge = options.probeForge ?? probeRegisteredForgeHost;
const resolveSsh = options.resolveSshHostname ?? resolveSshHostname;
const now = options.now ?? Date.now;
const services = new Map<string, ForgeService>();
// Cache the per-host probe result so the synchronous resolveFromRemoteUrl can
// reuse a forge discovered by an earlier async resolve. Positive results are
// permanent; negative ones expire (NEGATIVE_PROBE_TTL_MS) so a CLI installed
// or authenticated later is picked up without a daemon restart.
const probedForgeByHost = new LRUCache<
string,
{ forge: string | null; expiresAt: number | null }
>({ max: FORGE_RESOLVER_CACHE_MAX });
// Coalesce concurrent probes of the same host so "never re-probe" holds under
// concurrency: callers racing on the same host await one shared probe.
const inFlightProbes = new Map<string, Promise<string | null>>();
// resolveRemoteUrl spawns `git config` — memoize per cwd so repeated resolve()
// calls (the PR-status poll hits this every cycle) don't re-spawn it. TTL'd
// (not permanent, unlike a positive host probe) so a remote added/changed
// from outside Paseo — `git remote add`/`set-url` run in a terminal — is
// still picked up within a cycle instead of staying pinned forever; a Paseo
// git mutation still busts it immediately via invalidate(cwd).
const remoteUrlByCwd = new LRUCache<
string,
{ promise: Promise<string | null>; expiresAt: number }
>({ max: FORGE_RESOLVER_CACHE_MAX });
function resolveRemoteUrlCached(cwd: string): Promise<string | null> {
const cached = remoteUrlByCwd.get(cwd);
if (cached && cached.expiresAt > now()) {
return cached.promise;
}
const pending = resolveRemoteUrl(cwd).catch((error: unknown) => {
if (remoteUrlByCwd.get(cwd)?.promise === pending) {
remoteUrlByCwd.delete(cwd);
}
throw error;
});
remoteUrlByCwd.set(cwd, { promise: pending, expiresAt: now() + NEGATIVE_PROBE_TTL_MS });
return pending;
}
function readFreshProbe(host: string): string | null | undefined {
const entry = probedForgeByHost.get(host);
if (!entry) {
return undefined;
}
if (entry.expiresAt !== null && Date.now() >= entry.expiresAt) {
probedForgeByHost.delete(host);
return undefined;
}
return entry.forge;
}
function buildResolution(forge: string, host: string): ForgeResolution | null {
let service = services.get(forge);
if (!service) {
const created = create(forge);
if (!created) {
return null;
}
service = created;
services.set(forge, service);
}
return { forge, host, service };
}
async function probeHostForge(host: string): Promise<string | null> {
const cached = readFreshProbe(host);
if (cached !== undefined) {
return cached;
}
const existing = inFlightProbes.get(host);
if (existing) {
return existing;
}
const pending = (async () => {
try {
// A caller-injected probe that throws degrades to "no forge" and is
// cached negatively, so a transient failure is retried after the TTL.
let probed: string | null;
try {
probed = await probeForge(host);
} catch {
probed = null;
}
probedForgeByHost.set(host, {
forge: probed,
expiresAt: probed === null ? Date.now() + NEGATIVE_PROBE_TTL_MS : null,
});
return probed;
} finally {
inFlightProbes.delete(host);
}
})();
inFlightProbes.set(host, pending);
return pending;
}
function resolveFromRemoteUrl(remoteUrl: string | null): ForgeResolution | null {
if (!remoteUrl) {
return null;
}
const host = parseRemoteHost(remoteUrl);
if (!host) {
return null;
}
const forge = forgeForHost(host) ?? readFreshProbe(host) ?? null;
if (!forge) {
return null;
}
return buildResolution(forge, host);
}
async function resolveFromRemoteUrlAsync(
remoteUrl: string | null,
): Promise<ForgeResolution | null> {
if (!remoteUrl) {
return null;
}
const location = parseGitRemoteLocation(remoteUrl);
if (!location) {
return null;
}
const directForge = forgeForHost(location.host);
if (directForge) {
return buildResolution(directForge, location.host);
}
const resolved = await resolveHostForProbe(location);
if (resolved.forge) {
rememberAliasForge(location.host, resolved.host, resolved.forge);
return buildResolution(resolved.forge, resolved.host);
}
const forge = await probeHostForge(resolved.host);
rememberAliasForge(location.host, resolved.host, forge);
if (!forge) {
return null;
}
return buildResolution(forge, resolved.host);
}
// Seed the probe cache under the raw SSH-alias host too: the synchronous
// resolveFromRemoteUrl (PR-status poll gating) sees the alias, not the
// ssh -G resolved host, and would otherwise never find the forge.
function rememberAliasForge(aliasHost: string, resolvedHost: string, forge: string | null): void {
if (aliasHost === resolvedHost) {
return;
}
probedForgeByHost.set(aliasHost, {
forge,
expiresAt: forge === null ? Date.now() + NEGATIVE_PROBE_TTL_MS : null,
});
}
async function resolveHostForProbe(
location: GitRemoteLocation,
): Promise<{ host: string; forge?: string }> {
if (location.transport !== "scp" && location.transport !== "ssh") {
return { host: location.host };
}
const resolvedHost = await resolveSsh(location.host);
if (!resolvedHost) {
return { host: location.host };
}
const resolvedForge = forgeForHost(resolvedHost);
if (resolvedForge) {
return { host: resolvedHost, forge: resolvedForge };
}
return { host: resolvedHost };
}
function invalidate(cwd: string): void {
remoteUrlByCwd.delete(cwd);
for (const service of services.values()) {
service.invalidate({ cwd });
}
}
return {
resolveFromRemoteUrl,
resolveFromRemoteUrlAsync,
invalidate,
async resolve(cwd: string): Promise<ForgeResolution | null> {
return resolveFromRemoteUrlAsync(await resolveRemoteUrlCached(cwd));
},
};
}