Files
paseo/docs/forge-providers.md
nllptrxandMohamed Boudra b79b4bd5fc Feat/forge check semantics and followup fixes (#2337)
* feat(protocol): add forge check presentation metadata

Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible.

* fix(gitea): model Actions jobs as tasks

Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata.

* feat(app): distinguish forge check presentation states

* fix(gitlab): preserve pipeline job semantics

* refactor(app): centralize forge check presentation policy

Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces.

* refactor: consolidate forge check handling

* feat(forge): auto-attach pasted forge references

* docs(compat): correct forge rollout metadata

* refactor(forge): model check nuances as traits

* fix(forge): harden checks from branch review

Structural Gitea Actions shadow dedup by run id (verified upstream),
rerun grouping without workflow_id, shared trait/status constants and
Gitea state mapping in the protocol package, all cloud host aliases in
pasted-link matching, logged glab job-detail failures, concurrent
approvals/pipeline fetch, checks aggregate from the same pipeline as
the job list, and removal of the unread check-level rawStatus. The
released pipeline-level rawStatus is staged for removal under
COMPAT(pipelineRawStatus).

* fix(gitea): detect fork-PR Actions approval hold as action_required

gitea.com represents a fork PR awaiting Actions approval as a run in
status "waiting" with no tasks and pending shadow commit statuses; it
does not expose need_approval. The previous mapping only set
action_required from a task with blocked+need_approval, so the gate
never surfaced (0 tasks -> early return; need_approval absent). Detect
the hold from /actions/runs: a waiting/blocked run whose PR is
cross-repo (head.repo.id != base.repo.id), or one that does expose
need_approval (self-hosted). Tag the run's pending shadow statuses
action_required. Runs are only fetched when a pending shadow has no
covering task, keeping the common path at two requests. Verified live
against gitea.com fork PR (run 715170, waiting, cross-repo).

* fix(gitea): resolve cross-fork PRs by head sha

The current-PR matcher gated candidates on the checkout owner
(giteaHeadOwner === expectedHeadOwner), which excludes every fork PR:
its head carries the fork owner while the checkout is the base repo, so
a fork PR - the only shape that can be approval-held on Gitea - never
resolved and its checks never showed in-app. GitLab has no such gate
(it resolves fork MRs from the target project by source branch). Accept
a recent PR whose head sha equals the workspace head sha regardless of
owner; the sha is an unambiguous identity, so the anti-collision owner
gate still applies when no sha match is available. Verified live against
gitea.com fork PR #6 (resolves + renders action_required).

* fix: resolve forge rebase drift

* fix: type forge auto-attach boundary

* fix(i18n): add Korean check status copy

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-22 12:42:54 +02:00

221 lines
10 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`
(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:
```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.
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:
```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
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:
```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.
- 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.