← the Atlas

Multi-forge PR integration — the anyforge leaf

Features·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-adapter.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, then 0d inverts the default — only github.com → github, every other remote → unsupported (never reaching gh) — so phase 1 just adds the forgejo entry above that default. Dashed = phase 1.
Phase Gist User-facing change Status
0a classifyGhError: 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
0b Extract 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
0c Pre-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
0d Invert detectForge’s default to be honest about what’s GitHub. A new forge-neutral PrResult kind unsupported (distinct from absent: “no adapter for this remote” ≠ “this branch has no PR”), and detectForge now maps only github.comgithub; every other remote → unsupported (a trivial non-gh FORGE_ADAPTERS arm). The decision is made at the knowing endpoint (the remote host) instead of guessed from gh’s stderr, and kolu no longer claims an arbitrary URL is GitHub — so the bug below can’t be expressed for any non-GitHub remote. Accepted cost: a GitHub Enterprise remote loses its PR pill (out of scope, #1240). Codeberg / self-hosted-forge / no-remote terminals stop emitting error-level log noise and the scary “gh: unknown error” popover; they show nothing (no GitHub PR), like a branch with no PR but for the honest reason. shipped #1631
1 kolu-forgejo sibling adapter — now purely additive: the detection/registry/remoteUrl plumbing already exists (0c–0d), so this is one PR_REGISTRY entry + one host match in detectForge (codeberg.org / KOLU_FORGEJO_HOSTSforgejo, above the unsupported default) plus the Forgejo REST resolver (zod-validated, typed errors) 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
later GitLab 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 | unsupported | 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 a real forgejo adapter is wanted, it’s one case arm in detectForge’s host switch. 0d revisits the default itself. 0c’s “every host → github, let gh be the fallback prober” leans on an unfounded assertion — that an arbitrary clone URL is GitHub — and on gh’s brittle, unversioned refusal stderr to walk it back. 0d inverts it: only github.comgithub; every other remote → unsupported (a non-gh arm returning { kind: "unsupported" }), so a non-GitHub remote never reaches gh and the log noise / popover (juspay/kolu#1627) can no longer be expressed. The honest trade, accepted: a GitHub Enterprise remote — an arbitrary corporate host gh may be authed for, indistinguishable from any unknown host by URL — loses its PR pill, reopened by the per-host config / real adapter work (#1240). Phase 1 then adds case "codeberg.org": return "forgejo" (and $KOLU_FORGEJO_HOSTS) above the unsupported default.

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

# Decision Rationale
D0 The 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.
D1 Closed PrUnavailableSource union composed in kolu-common (gh arm owned by kolu-github), exhaustive match in UI A 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.)
D2 (revised by 0d) Only github.comgithub; every other remote → unsupported, no probe 0c’s original cut routed every host → github, leaning on gh as the fallback prober (GHE included) and on its brittle, unversioned refusal stderr to walk non-GitHub hosts back to silent. 0d inverts the default: we refuse to claim an arbitrary clone URL is GitHub, so non-GitHub remotes never reach gh — killing the #1627 error-log noise and “gh: unknown error” popover at the source. Still sync + pure: no async-detection race, no egress. Accepted cost: a GitHub Enterprise remote loses its PR pill until per-host config / a real adapter lands (#1240).
D3 Provider = pure resolve(git); injected, not constructed; dispatch is itself a provider 0b 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.
D4 fj CLI not used The 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).