Files
paseo/docs/forge-providers.md
nllptrx 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

7.7 KiB

Adding a Git Forge to Paseo

Paseo's forge layer is a registry/manifest system. A forge is a runtime concern: shared protocol messages carry neutral/open facts, the server adapter owns behavior, and the app owns bundled presentation/runtime interpretation.

The maintainer litmus test is the rule of thumb:

Adding a new forge means adding files in a new directory/module that implement an interface, plus one entry in the centralized registry/manifest for that package.

The Three Registrations

For forge acme, the expected end state is:

  1. Protocol manifest - optional, only when the forge should be presented by shared manifest data. Add one ForgeDefinition to packages/protocol/src/forge-manifest.ts.

  2. Server adapter - add packages/server/src/services/acme-service.ts implementing ForgeService, any adapter-owned fact types/guards/constants beside it, and one defaultForgeRegistry entry in packages/server/src/services/forge-registry.ts.

  3. App modules - a forge splits into a pure logic half and a view half so logic consumers (URL builders, merge-capability, native checks, and the Node-based e2e harness) never pull the client rendering stack:

    • packages/app/src/git/forges/acme.ts - logic: id, optional urlGrammar, optional facts (schema, merge-capability, native-check fallbacks). No React/React-Native imports. Register in CLIENT_FORGE_LOGIC_MODULES in packages/app/src/git/forges/index.ts.
    • packages/app/src/git/forges/acme.view.tsx - view: icon (SVG component under packages/app/src/components/icons/), optional brandColor, optional paneContributions. Register in CLIENT_FORGE_VIEW_MODULES in packages/app/src/git/forges/view.ts.

There should be no protocol typed-union arm, no central app icon/color/url/facts map, and no central server union of known forge facts.

Protocol

forgeSpecific on PR status is an open envelope:

z.object({ forge: z.string() }).passthrough();

The forgeSpecific.forge field is a facts-family tag, not the workspace brand id. Gitea, Forgejo, and Codeberg can all emit forgeSpecific.forge === "gitea" when they share the same facts shape, while top-level status.forge keeps the brand id ("gitea", "forgejo", "codeberg").

Protocol does not validate per-forge fact fields. Consumers that understand a facts family validate at runtime with their own schema/guard. Unknown or schema-mismatched facts render neutrally instead of failing the whole message parse. This is the version-skew win: an old client can receive facts from a newer forge and still show the PR/MR in a neutral state.

Shipped GitHub compatibility stays separate:

  • status.github remains accepted for released peers.
  • The server keeps the COMPAT(forgeSpecific) mirror that copies GitHub facts into status.github for older clients.
  • Do not add a compatibility shim unless a released peer (<= 0.1.102) can actually produce the state.

Server

The server-wide status type only promises:

type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;

Adapter-owned files define the typed shapes and guards, for example github-facts.ts, gitlab-facts.ts, and gitea-facts.ts. The adapter can keep strong internal types for construction and command guards, but shared server code must not grow a central list of forge fact arms.

Register the adapter in defaultForgeRegistry with:

  • createService
  • matchesHost from manifest cloudHosts
  • probeHost when self-hosted/Enterprise detection is supported

Cloud hosts in the manifest are a bounded public-host list, not a self-host allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge host that is either a known cloud host or one the CLI is already authenticated to. Adapter probes must not make anonymous HTTP requests to remote-derived hosts, and adapters must not route credentials to an unauthenticated host.

App

Each app forge splits into two modules so pure logic never imports the client rendering stack:

acme.ts exports a ClientForgeLogicModule:

  • id
  • optional urlGrammar
  • optional facts registration (schema, merge-capability, native-check fallbacks)

acme.view.tsx exports a ClientForgeViewModule:

  • id
  • icon
  • brandColor (null for neutral; GitHub intentionally uses null)
  • optional paneContributions

Two registries live under packages/app/src/git/forges/: CLIENT_FORGE_LOGIC_MODULES (index.ts) drives URL grammar, merge-capability derivation, and native fallback checks; CLIENT_FORGE_VIEW_MODULES (view.ts) drives icon/color lookup and PR-pane contributions. Logic consumers must import the logic registry only — importing the view registry (or a .view.tsx module) from a logic path pulls react-native and breaks the Node-based e2e harness.

Per-forge brand colors live on the module, not in styles/theme.ts. Use the Unistyles-safe pattern from docs/unistyles.md: no useUnistyles(). Brand icon call sites use withUnistyles and a uniProps mapping such as:

(theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });

Facts modules use one source of truth: a Zod schema. Helpers like defineForgeFacts, defineNativeFallbackCheck, and definePaneContribution derive guards from schema.safeParse and re-parse before invoking typed derivers/renderers. That keeps typed derivers away from the open wire envelope.

Checklist

To add acme:

  1. Add acme to FORGE_DEFINITIONS if the shared manifest should know its label, nouns, icon kind, sign-in CLI, or cloud hosts.
  2. Add acme-service.ts implementing ForgeService.
  3. Add acme-facts.ts beside the adapter if it reports native facts.
  4. Add one defaultForgeRegistry entry.
  5. Add packages/app/src/git/forges/acme.ts (logic) and packages/app/src/git/forges/acme.view.tsx (view).
  6. Add one CLIENT_FORGE_LOGIC_MODULES entry (index.ts) and one CLIENT_FORGE_VIEW_MODULES entry (view.ts).
  7. Add/update the icon component only if the client bundle should show a brand mark.
  8. If the forge's CI/data model does not fit an existing required ForgeService field, widen the shared interface (plus the protocol schema and its guards) instead of faking a value — e.g. Gitea Actions runs carry no check-run id, so GetCheckDetailsOptions.checkRunId became optional with workflowRunId as the alternative address. Expect this step to touch forge-service.ts, messages.ts, and the call-site guards of the other adapters. Widening a shared field is not forge-local: it also affects the already-shipped forges/GitHub call sites and the capability-gated RPC (e.g. forgeCheckDetails), so verify every consumer rather than assuming the change only reaches the new adapter.
  9. Run targeted tests: manifest/registry/resolver, the adapter test, protocol checkout PR schema, app forge URL/presentation tests, app merge capability, and any PR-pane native data tests touched.

Run npm run typecheck after each implementation slice. If protocol or client declarations are stale, run npm run build:client; if server/CLI declarations are stale, run npm run build:server.

Gotchas

  • GitHub is a normal registry entry plus released compatibility shims. Keep all real shims tagged with COMPAT(name).
  • Gitea-family facts use forgeSpecific.forge === "gitea" even when the top-level brand is Forgejo or Codeberg.
  • Brand icons are bundled React components, so they cannot come from protocol manifest data.
  • Source URL grammars are app-side because blob/tree path syntax is forge-specific. If a forge has no grammar, omit the "Open on ..." source link rather than constructing a wrong URL.
  • GitLab pipeline status constants belong to the GitLab adapter/client module, not protocol.