Files
paseo/docs/forge-providers.md
Desmond ChaiandMohamed Boudra a1ed5a8bad Stop PR status polling from rate-limiting your entire GitHub account (#3825)
* Batch GitHub PR status polls into aliased GraphQL requests

The PR status poller ran one `gh pr view` (GraphQL-backed) plus a
per-PR facts query for every workspace on its own timer, so cost
scaled linearly with workspace count and exhausted the 5,000 pts/hour
GraphQL quota machine-wide past ~50 workspaces (#3587, #2470).

Replace the per-target timers with a global scheduler that snaps poll
targets to a shared 5s grid and flushes all due targets together:

- One aliased discovery query per batch of up to 25 targets (grouped
  by GH host) resolves PR identity, state, mergeability, and the merge
  facts that previously required a second GraphQL call per PR.
- A second aliased query fetches statusCheckRollup contexts only for
  the PRs that matched. Checks for merged/closed PRs are frozen and
  cached by repo#number@headOid, so idle workspaces settle to the
  discovery query alone.
- A failed batch backs off every target in it with one request spent;
  healthy aliases are recovered from the body gh still prints on
  stdout when a single alias errors (ForgeCommandError now carries
  stdout for this).
- Targets whose origin slug/host is still resolving, and fork repos
  whose PR lives in the parent, fall back to the legacy per-target
  path for that cycle. Unowned lookups filter out other forks' PRs
  that share a branch name.

Fixes #3587. Addresses the API budget in #2470.

* fix(server): reserve the shared GitHub polling budget

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-28 11:29:34 +02:00

11 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 (source links and pasted issue/change-request reference paths), 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.

Check results keep a stable normalized status for aggregation and an optional open traits: string[] for composable forge-neutral refinements such as manual, action_required, and warning. Consumers ignore unknown traits and fall back to status; rawStatus preserves provider-native detail. Do not add provider vocabulary to the normalized status union. Producers and the presentation layer share the trait constants in packages/protocol/src/check-traits.ts — use those instead of string literals so classification and emission cannot drift apart.

Shipped GitHub compatibility stays separate:

  • The forge compatibility layer first shipped in v0.2.0-beta.1. Its initial six-month cleanup target is 2027-01-17, after supported client and daemon floors reach stable v0.2.0.
  • 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

Current change-request lookup uses two identities deliberately:

  • An open PR/MR belongs to the checkout when its head branch and head repository match. Its remote head SHA may differ because the checkout can be ahead, behind, or contain commits that have not been pushed yet.
  • A merged or closed PR/MR belongs to the checkout only when its recorded head SHA exactly matches the checkout's current HEAD. Branch names are reusable; selecting the newest terminal request by branch alone can silently attach an old promotion or feature request to new work.

Thread the checkout head SHA through adapter cache and poll identities as well as the lookup itself. Otherwise a commit made on the same branch can inherit the previous commit's cached terminal status until the cache expires.

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 (including referencePaths for composer auto-attachment)
  • 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.
  • Gitea's legacy Actions task endpoint cannot filter by commit, may enforce a page size below the requested limit, and omits rerun-attempt numbers. Walk a bounded number of pages using total_count and filter locally. Preserve ambiguous same-run tasks: display names are not stable job identities, so name-based deduplication can hide a real failure.
  • A blocked Gitea Actions task is not necessarily manual: only add the action_required trait when the matching run metadata reports need_approval.
  • 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.
  • Pasted issue/change-request URL recognition is also app-side and registry driven. Put the forge's route infixes in urlGrammar.referencePaths; the neutral extractor matches the pasted host and full repository path against the current remote before it invokes cwd-routed forge search. Do not add a central forge switch or guess a self-hosted Gitea-family brand from URL shape.
  • GitLab pipeline status constants belong to the GitLab adapter/client module, not protocol.
  • GitLab manual jobs need allow_failure to distinguish optional gray jobs (manual) from blocking jobs (manual plus action_required). A failed allowed-failure job carries the warning trait, and canceling remains active until GitLab reports a terminal status.
  • GitHub PR polling owns one account-wide GraphQL budget. Coordinate retained targets per host, batch their reads, and stop until GitHub's reset time when the reserve is exhausted. Never add a per-target GitHub request to the poll path. Resolve fork PRs through their parent repository without abandoning the shared batch.