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

177 lines
7.7 KiB
Markdown

# 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:
```ts
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:
```ts
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:
```ts
(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.