Multi-forge PR integration — the anyforge leaf
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
| 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 GitHubPrInfo→PrInfo 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 |
| 1 | kolu-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 |
| 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:
- The
classifyGhErrorreclassification (with its tripwire test) — the actual bug fix, done at the right layer (classification, not call-site log suppression). remoteUrlonGitInfo— right home (kolu-gitowns remote discovery), best-effortgit remote get-url origin→null,gitInfoEqualextended so a remote change re-triggers downstream. Adopted in 0c: it lands withdetectForgeand 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).- The
subscribePrResolverextraction — the poll/dedup/pending/emit-guard machinery genuinely is forge-neutral, and the draft’s Forgejo adapter correctly does not duplicate it. - The registry idea mirroring
AgentProvider, the exhaustivets-patterndispatch onPrUnavailableSource.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:
- Async detection races the git channel. The draft probes unknown hosts over the network (
GET https://host/api/v1/version) andawaits that inside the git channel’sonEvent— but the channel contract is synchronous fire-and-forget, so two quick git events can interleave and a stale slow probe can tear down the fresh watcher. Detection must be sync and pure. - Auto-egress to arbitrary hosts parsed out of git remotes is a privacy decision smuggled in as an implementation detail — and it’s what makes the global
KOLU_FORGEJO_TOKENdangerous (a token sent to any host that answers the probe). - Lifecycle churn from interface shape.
subscribe(repoRoot, branch, remoteUrl, …)bakes git state into construction, so the orchestrator grows alastKeystring and a teardown/rebuild dance just to survive a remote change; meanwhileresolveGitHubPrgained an unused_branchparam mid-signature andresolveForgejoPrignores itsrepoRoot. - Resolver rigor: responses parsed with
as Tcasts despite zod being a dependency; HTTP status re-parsed out of error message strings (msg.includes("401")); branch matched byhead.refalone — the exact fork-PR bug GitHub’s resolver documents avoiding — and onlystate=openqueried, so Forgejo PRs can never show the merged/closed pill. Zero tests in the new package.
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 ForgeKind→PrProvider 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_HOSTS → forgejo 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
- Create
packages/integrations/anyforge/; move (not copy) the neutral schemas + helpers out ofkolu-github/src/schemas.ts, renaming on the way (GitHubPrInfo→PrInfo,GitHubCheckStatus→CheckStatus,GitHubCheck→CheckRun,GitHubPrState→PrState) — the rename and the move are one mechanical commit. The leaf gets only the genericPrResult<S>over{ provider: string; code: string }. TheGh*unavailable codes stay inkolu-github(on a browser-safekolu-github/schemasarm, likekolu-claude-code’s); the closedPrUnavailableSourceunion + the wirePrResultSchemacompose inkolu-common, next toAgentInfoSchema. - Move the poll loop as
subscribePr(provider, onChange, log)taking one injectedPrProvider; deletesubscribeGitHubPrand theGitHubPrWatcheralias rather than shimming (private monorepo, one test file to update).PrProvider.kindisstring— noForgeKindunion in the leaf (theanyagentprecedent:AgentProvider.kind: string, concrete enum inkolu-common). kolu-githubshrinks to the gh adapter:getGhBin/resolveGitHubPr,classifyGhError,deriveCheckStatus/extractChecks, exportinggithubPrProvider: PrProvider(kind: "github").startGitHubPrProviderbecamestartPrProvider(packages/server/src/terminalBackend/providers.ts:251-292), which injectsgithubPrProviderintosubscribePrand feeds it{repoRoot, branch}off the git channel. No registry, no detection — there is one forge, so there is nothing to dispatch.onEventstays synchronous.- Re-point imports: the neutral helpers (
prValue,prLabel,PrInfo, …) →anyforge/schemas; the closed-union helpers (PrUnavailableSource,reasonForSource,prUnavailableReason/Source) →kolu-common/surface; the gh codes (GhUnavailableCode) →kolu-github/schemas. README package table gains the anyforge row. - Explicitly not in 0b:
GitInfo.remoteUrl,detectForge, the provider registry — those land in 0c. 0b’s kernel ships GitHub-only with zero forge knowledge and an injected single provider.
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.
kolu-gitlearns the remote.GitInfo.remoteUrl(best-effortgit remote get-url origin→null, credentials stripped viastripRemoteCredentialsbefore it’s persisted or published),gitInfoEqualextended so agit remote set-urlre-triggers downstream, and a refcounted shared.git/configwatcher (watchGitConfigover the common git dir, mirroringwatchGitHead) wired intosubscribeGitInfo’s in-repo mode so a remote change re-resolves without a branch switch. The gh adapter reads none of it.- The server gains the dispatch seam.
PrGitContext.remoteUrl; aForgeKind→PrProviderPR_REGISTRY(one entry:github); a sync/puredetectForge(remoteUrl)that switches onparseRemoteHost(remoteUrl)(every host →githubtoday); and a dispatchingPrProviderwhoseresolveconsults the registry per call — handed to the otherwise-unchangedsubscribePr.ForgeKindlives here in the app, not the leaf (theAgentKindSchemaprecedent). - The leaf gains the neutral grammar + the helpers the gh adapter was hoarding.
parseRemoteHost(URL- and scp-shaped remotes → host) joinsanyforge. And the two genuinely forge-neutral pieces 0b left insidekolu-githubmove out: the check-status fold (foldCheckOutcomes— thefail-terminal/pending-sticky rule, distinct from gh’sclassifyCheckstring-mapping, which stays) and the resolve-failure log policy (logPrResolveFailure(err, result, log, label)).kolu-githubnow adopts both — so it no longer owns anything a sibling forge would have to import from the GitHub adapter.
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
- Detection/registry/
remoteUrlalready exist (0c) — so phase 1 adds, it doesn’t restitch: a singlePR_REGISTRYentry (forgejo: forgejoPrProvider) and onecasearm indetectForge(codeberg.org+$KOLU_FORGEJO_HOSTS→forgejo).kolu-githubandkolu-gitare untouched; the watcher andsubscribePrnever change. packages/integrations/forgejo/depending onanyforge+kolu-sharedonly —kolu-githubappears nowhere in its tree.- Resolver over the Forgejo REST API (keep the draft’s endpoints:
/repos/{owner}/{repo}/pulls+/commits/{sha}/status), fixed: zod-parse responses; a typed fetch error carryingstatus: number(classify by field, not substring); match head repo + ref so fork branches with the same name don’t false-positive; query so merged/closed states are reachable; honor port/scheme from the remote where derivable. ForgejoUnavailableSchemalives inkolu-forgejo(its own browser-safeschemasarm, likekolu-github’s) and its arm joins the closedPrUnavailableSourceunion inkolu-common(per D1). The compiler then walks the plan to the client:reasonForSourceandProviderUnavailableContenteach need their forgejo arm (keep the draft’s per-code popover copy: token setup, timeout, not-found).- Tokens:
KOLU_FORGEJO_TOKEN, injected as a parameter (not read fromprocess.envinside the resolver), sent only to explicitly configured hosts — safe precisely because detection has no auto-probe. - Detection:
codeberg.orgbuilt in; self-hosted viaKOLU_FORGEJO_HOSTS(comma-separated), documented in the README the same change. - Unit tests for remote parsing, state/check mapping, and error classification; changelog entry (keep the draft’s); evidence: PR pill screenshot against a real Codeberg repo.
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 | Unknown 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. |
| 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).