← the Atlas

Multi-forge PR integration — the anyforge leaf

feature · budding ·accepted ·

Phased plan for kolu#1240 — phase 0a ships the gh log-noise fix, phase 0b extracts the forge-neutral PR kernel (schemas · poll loop · PrProvider) into a new anyforge leaf, phase 0c pre-stages every cross-package seam a second forge crosses (remoteUrl · the server registry + detection · the lifted neutral helpers) GitHub-only and zero-behavior, so phase 1 — the Forgejo/Codeberg adapter — is a purely additive sibling package. Grounded in a structural critique of the ic4-y/kolu#1 draft.

The plan for #1240 — PR metadata on forges beyond GitHub. Kolu’s PR pill was hardcoded to gh pr view: on a Forgejo/Codeberg/GitLab remote it failed, was misclassified as not-authenticated, and WARN-logged every 30s per terminal (fixed in phase 0a, #1256 ). ic4-y’s draft #1 · ic4-y/kolu#1 proves the feature end-to-end (open PRs resolve on a real Forgejo instance) and lands several right calls — but it grows the forge-neutral kernel inside kolu-github, so the Forgejo adapter depends on the GitHub adapter and even Forgejo’s error codes live in GitHub’s package. This plan re-cuts the same feature on the repo’s own precedent: anyagent is the neutral leaf agents share (packages/integrations/anyagent/src/agent-provider.ts:77-178); anyforge is the same move for forges. Phase 0b is a pure refactor (GitHub-only, zero behavior change); phase 0c then pre-stages every cross-package seam a second forge has to cross — remoteUrl, the server’s forge registry + detection, and the two forge-neutral helpers the gh adapter was still hoarding — all GitHub-only and zero-behavior, so phase 1 (the Forgejo adapter) adds a sibling package and one registry line, and edits no shared mechanism. The goal is a contributor diff that is purely additive: new Forgejo code plus the irreducible compile-time union/popover arms, nothing structural.

Phases at a glance

clientPR pill · tooltip · recovery popoverkolu-server providers.tsstartPrProvider — one watcher per terminalPR_REGISTRY + detectForge + dispatching provider (0c, gh-only)forgejo = +1 registry entry (phase 1)kolu-commonterminalMetadata wire schemacomposes closed PrUnavailableSource union(like AgentInfoSchema)kolu-gitGitInfo.remoteUrl + config-watcher (0c)sibling adapters — never see each otheranyforge — the leaf · names no forgePrInfo · generic PrResult<S> · PrUnavailableSourceBasePrProvider (kind: string) · subscribePrparseRemoteHost · foldCheckOutcomes · logPrResolveFailurekolu-githubgh pr view spawnclassifyGhError · classifyCheckowns GhUnavailable codeskolu-forgejo (phase 1)Forgejo/Codeberg REST WebSocket (m.pr)TerminalMetadatagit channel (incl. remoteUrl) registry dispatch on detectForgesubscribePr · detectForgePrResultSchemaimplement PrProvider · reuse helpers
Target package graph. anyforge is the stable kernel — wire schemas, the PrProvider contract (kind: string, naming no forge), the generic poll loop, plus the forge-neutral grammar/helpers (parseRemoteHost · foldCheckOutcomes · logPrResolveFailure) — and nothing forge-specific. kolu-github and kolu-forgejo are sibling adapters that never see each other. Forge detection (which adapter resolves a remote) is a server concern: phase 0c lands the registry + detectForge GitHub-only (one entry, every host → gh), so phase 1 just adds the forgejo entry. Dashed = phase 1.
PhaseGistUser-facing changeStatus
0aclassifyGhError: gh’s “point to a known GitHub host” refusal reclassified absent, not not-authenticated. One file + two tests.Non-GitHub repos stop WARN-logging every 30s and stop showing a lying auth warning.shipped #1256
0bExtract the anyforge leaf: neutral schemas (renamed GitHubPrInfoPrInfo as part of the move), subscribePr poll loop, PrProvider contract (kind: string — the kernel names no forge, mirroring anyagent). startPrProvider injects the one gh adapter.None — behavior preserved (plus a stale-resolve guard surfaced in review: switching branches mid-lookup no longer flashes the prior branch’s PR).shipped #1257
0cPre-stage the cross-package seams a second forge crosses, GitHub-only: GitInfo.remoteUrl (credential-stripped) + a .git/config watcher; PrGitContext.remoteUrl; the server’s PR_REGISTRY + sync/pure detectForge + a dispatching PrProvider (one entry, every host → gh); parseRemoteHost in the leaf; and lift the two forge-neutral helpers the gh adapter still owned — the check-status fold (foldCheckOutcomes) and the resolve-failure log policy (logPrResolveFailure) — into anyforge.None — every host still resolves through gh; behavior identical.shipped #1283
1kolu-forgejo sibling adapter — now purely additive: the detection/registry/remoteUrl plumbing already exists (0c), so this is one PR_REGISTRY entry + one host match in detectForge plus the Forgejo REST resolver (zod-validated, typed errors), Codeberg + KOLU_FORGEJO_HOSTS, and the irreducible compile-time union/popover arms. kolu-github and kolu-git are untouched.Full PR pill — number, title, open/merged/closed state, CI checks — on Codeberg and self-hosted Forgejo/Gitea.closes #1240
laterGitLab adapter; per-host tokens; opt-in auto-detection probe.not scheduled

What the draft teaches

A structural pass (Hickey + Lowy lenses) over #1 · ic4-y/kolu#1 sorted its ~1.5k-line diff into keep / re-home / redo:

Keep as-is — the parts a clean implementation adopts verbatim:

  1. The classifyGhError reclassification (with its tripwire test) — the actual bug fix, done at the right layer (classification, not call-site log suppression).
  2. remoteUrl on GitInfo — right home (kolu-git owns remote discovery), best-effort git remote get-url originnull, gitInfoEqual extended so a remote change re-triggers downstream. Adopted in 0c: it lands with detectForge and the registry, GitHub-only — the one deliberate “field with no live consumer yet” the plan accepts, traded for a phase-1 contributor diff that adds rather than restitches (the draft’s coupling came from where it put the logic, not from staging it early).
  3. The subscribePrResolver extraction — the poll/dedup/pending/emit-guard machinery genuinely is forge-neutral, and the draft’s Forgejo adapter correctly does not duplicate it.
  4. The registry idea mirroring AgentProvider, the exhaustive ts-pattern dispatch on PrUnavailableSource.provider (a new forge arm is a compile error at every render site), and the per-code popover recovery copy.

Re-home — right code, wrong package. The neutral contract (PrProvider, PrWatcher, subscribePrResolver, detectForge, the PrInfo/PrResult schemas) all landed inside kolu-github, so kolu-forgejo imports its own types from the GitHub adapter, and ForgejoUnavailableCodeSchema is defined in GitHub’s schemas file. The codebase already named this smell: the schemas header had carried a “when a second provider lands, promote the neutrals to their own leaf” note since day one — the draft is the second provider, and it edited the comment instead of doing the promotion; 0b ( #1257 ) performed it. kolu-common importing PrResultSchema from kolu-github/schemas was the same coupling on the wire side — removed in 0b, where the closed union and wire schema now compose in kolu-common itself.

Redo — design faults, not placement faults:

The anyforge leaf

packages/integrations/anyforge/ (package name anyforge, mirroring anyagent), a leaf depending only on kolu-shared + zod + ts-pattern. It owns the two things that are stable while forges vary — and, crucially, names no specific forge (the kernel is the receptacle, not a registry of plugs):

The wire vocabulary — the neutral half. PrInfoSchema (number/title/url/state/checks/checkRuns — already forge-neutral in shape), PrStateSchema, CheckStatusSchema, CheckRunSchema, the generic PrResult<S> (pending | ok | absent | unavailable{ source: S }) over the open PrUnavailableSourceBase = { provider: string; code: string }, plus the prValue / prLabel / prResultEqual helpers. The leaf names no forge. The closed PrUnavailableSource union and the wire PrResultSchema — the part the client must match(...).exhaustive() — are composed one layer up, in the app (kolu-common), and each adapter’s failure codes live in that adapter (GhUnavailableCodeSchema + reasonForGhCode in kolu-github). This is exactly the anyagent split: AgentInfoShape (generic, in the leaf) → ClaudeCodeInfoSchema (concrete, in kolu-claude-code) → AgentInfoSchema (the closed union, composed in kolu-common). The closed-union exhaustiveness trade-off is preserved — just at the layer that owns it.

The provider contract — a pure resolver. Mirrors anyagent’s AgentProvider exactly: a kind: string discriminator (the leaf enumerates no forge — just as AgentProvider.kind is string and the concrete AgentKindSchema enum lives in the app, kolu-common, not the leaf) plus one stateless resolve:

// remoteUrl added in 0c — the gh adapter ignores it; the server's dispatcher
// reads it to pick the forge. The leaf still names no forge.
type PrGitContext = { repoRoot: string; branch: string; remoteUrl: string | null };

interface PrProvider {
  readonly kind: string; // e.g. "github" — set by the adapter, not a closed union here
  resolve(git: PrGitContext, log?: Logger): Promise<PrResult>;
}

The generic watcher subscribePr(provider, onChange, log) keeps everything subscribeGitHubPr owns today — branch-change dedup via prResultEqual, synchronous pending emit so stale info never lingers, the 30s poll, the emit-guard, and a stale-resolve guard (an async resolve whose {repoRoot, branch} is no longer current is dropped before emit) — and takes one injected PrProvider, exactly as startAgentProvider takes one AgentProvider. The watcher itself stays forge-blind in every phase: the dispatch lives in the provider it’s handed (below), never in the loop.

Forge detection is a server concern, not the kernel’s — and it lands in 0c, GitHub-only. Which adapter resolves a given remote (detectForge(remoteUrl), the ForgeKindPrProvider registry) is a server job: only the server imports more than one adapter, so only the server can hold the registry. The leaf contributes the forge-neutral grammar (parseRemoteHost) and never the mapping. The dispatch is implemented as a PrProvider whose resolve consults PR_REGISTRY[detectForge(git.remoteUrl)] — so subscribePr still takes one provider and the watcher never learns the registry exists. 0c lands this GitHub-only: detectForge maps every host → github (sync and pure — no network probe; gh is the fallback prober, GHE included, and post-0a it degrades to a silent absent on hosts it doesn’t know). An earlier cut deferred this to phase 1 as “a registry with one entry is premature coupling.” The deliberate reversal: pre-building the seam GitHub-only costs one no-op indirection now and buys a phase-1 contributor diff that adds a sibling package and one registry line instead of restitching the server, the git layer, and the gh adapter. When codeberg.org / $KOLU_FORGEJO_HOSTSforgejo is wanted, it’s one case arm in detectForge’s host switch.

The phases in detail

0a — stop the lying WARN shipped #1256

classifyGhError: stderr containing "point to a known github host"{ kind: "absent" }, slotted above the "gh auth login" substring check that currently steals it (the refusal message mentions gh auth login, hence the misclassification). Match the specific “known GitHub host” sentence, not the bare "none of the git remotes" prefix — gh’s remoteResolver emits a second message with that same prefix (“…correspond to the GH_HOST environment variable…”) for a misconfigured GH_HOST that matches no remote, which is a real config failure the user should still see (unavailable). Known false-negative: the same refusal fires for a GitHub Enterprise remote the user hasn’t run gh auth login --hostname <ghe> for (gh’s known-host set = its authenticated hosts + the default host + github.com), where not-authenticated was the correct call — indistinguishable on stderr — and remote-URL detection can’t tell a GHE host from any other unknown host without configuration, so the gap stays until per-host config lands (open question below). Plus two table-test rows pinning both strings. Verify: unit tests; a terminal in a Forgejo-remote repo shows no warning icon and logs nothing above debug.

0b — extract anyforge shipped #1257

Verify: every existing unit + e2e test green; no new env vars. One behavior change, surfaced by the review gauntlet and worth a changelog line: the stale-resolve guard (a latent bug carried by the old subscribeGitHubPr — switching branches mid-lookup could briefly show the old branch’s PR — is now fixed in the moved loop).

0c — pre-stage the forge seams (GitHub-only) shipped #1283

Everything a second forge crosses outside its own package, landed now so phase 1 adds and never edits. All GitHub-only, zero behavior change — every host still resolves through gh.

Verify: every existing unit + e2e test green; parseRemoteHost, foldCheckOutcomes, and the remoteUrl equality case get direct unit tests; no new env vars; the gh PR pill is byte-identical (detectForge always picks github).

1 — the Forgejo adapter closes #1240

Verify: e2e on a Codeberg public repo (no token) and a token-bearing private instance; GitHub repos regression-checked; unknown forges still silent.

Decisions and open questions

#DecisionRationale
D0The kernel enumerates no forge. PrProvider.kind: string; no ForgeKind union and no forge mapping in anyforge — only the neutral grammar (parseRemoteHost) and the fold/log helpers.The anyagent precedent, exactly: AgentProvider.kind is string and the leaf never names claude/codex/opencode; the closed AgentKindSchema enum lives in the app (kolu-common). ForgeKind, detectForge, and PR_REGISTRY live server-side (only the server imports >1 adapter), landing in 0c. The leaf knows that forges vary (it parses a host, folds checks), never which ones.
D1Closed PrUnavailableSource union composed in kolu-common (gh arm owned by kolu-github), exhaustive match in UIA new forge should be a compile error at every recovery-UX render site. But that closed union names forges, so it composes in the app — discriminatedUnion("provider", [GhUnavailableSchema, …]) — exactly where AgentInfoSchema = discriminatedUnion("kind", [ClaudeCodeInfoSchema, …]) lives, with each arm owned by its adapter package. The leaf carries only the generic PrResult<S> over { provider: string; code: string }; the adapter produces PrResult<GhUnavailableSource> (a member of the closed union, assignable covariantly, no cast). (Corrects an earlier cut that put the closed union and the Gh* codes in the leaf.)
D2Unknown forge → github adapter, no probe (detect lands 0c)gh handles GHE; 0a makes everything else silent. Kills the async-detection race and the egress/privacy question in one move. Cost: one no-op gh spawn per 30s poll on non-GitHub repos — exactly today’s behavior.
D3Provider = pure resolve(git); injected, not constructed; dispatch is itself a provider0b injects one provider into subscribePr (mirrors startAgentProvider). 0c keeps that contract intact by making the dispatcher a PrProvider whose resolve consults PR_REGISTRY[detectForge(git.remoteUrl)] — so per-resolve forge selection needs no change to the watcher: no teardown/rebuild, no lastKey, channel handler stays sync. If a forge ever needs push/webhooks, widen the contract then — not before.
D4fj CLI not usedThe issue thread already found it rough; Forgejo’s REST API is one authenticated fetch and needs no binary on $PATH.

Open: per-host tokens (KOLU_FORGEJO_TOKEN is one secret for all configured hosts — fine while hosts are explicit, revisit if auto-detection ever lands); whether KOLU_FORGEJO_HOSTS should graduate from env var to a settings-UI surface; GitLab (different API shape, separate adapter — the union and registry are ready for it).