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>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -0,0 +1,257 @@
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user