Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .seeds/issues.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -1469,7 +1469,7 @@
{"id":"warren-9b6b","title":"Multi-forge support: Forgejo, Gitea, GitLab behind the Forge seam","status":"open","type":"feature","priority":2,"createdAt":"2026-08-19T13:16:37.072Z","updatedAt":"2026-08-19T13:27:26.644Z","description":"Umbrella seed for multi-forge support. Full research: docs/design/multi-forge-support.md — that document, not this seed, is the spec (mulch mx-9cc840).\n\nSCOPE DECISIONS (operator, 2026-08-19):\n- Target upstreamable: work lands in this fork but shaped so jayminwest/warren could accept it. The upstream refusal at planning-session-record:252 ('Gitea/GitLab demand — refused for now, capability-minimal Forge') is a live constraint. The falsification test (doc §8 step 6) is the evidence that answers it.\n- Multi-forge from the start: one instance hosts GitHub + Forgejo + GitLab simultaneously. User SELECTS the host at project creation. No automatic forge discovery.\n- Warren VALIDATES the selection automatically (doc §4b). Selection is explicit; validation is automatic.\n\nKEY FINDINGS:\n- The Forge interface needs no widening except one validation method. RepoRef.forge is already typed string ('registry key'), so instance ids fit.\n- URL-grammar routing (forge-contract.md §1.1) CANNOT work for self-hosted forges: https://git.example.com/o/r is a valid Forgejo, Gitea and GitLab URL. Explicit selection is the correctness fix, not a UX preference.\n- Forge identity IS verifiable, probes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that path, 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id. All four unauthenticated.\n- Requires a schema change (projects gains a forge discriminator) and forge INSTANCES not kinds — two self-hosted Forgejo servers are two registry entries. WARREN_FORGE as a single env selector cannot express this. Biggest undecided question (doc §7 Q3).\n- Router blast radius measured: deps.forge is 11 refs across 7 files. Roughly two PRs.\n- Leaks found outside the seam: src/workspace/git/credential-env.ts:38 hardcodes github.com + x-access-token (a §0 invariant violation check:layers cannot see); two process-global resolveForgeKind gates (github-app-gate.ts:86, forge-heartbeat-wiring.ts:46) have no correct answer under multi-forge.\n\nSTILL OPEN: in-core provider vs RemoteForge bridge (doc §5, §7 Q1). Not decided. Everything else follows from it.","plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-1154","warren-56bb","warren-834e","warren-09ea","warren-99a6","warren-9449","warren-f6b9"]}
{"id":"warren-f012","title":"Forge-instance config surface: forges: schema, loader, boot resolution, backward compat with WARREN_FORGE","status":"closed","type":"task","priority":2,"plan_step_index":0,"description":"SPEC: docs/design/multi-forge-support.md §2a (Forge-instance configuration, DECIDED). Also §7 Q3/Q4.\n\nDeliver the 'forges:' config surface: id/kind/baseUrl/tokenEnv per entry, credentials resolved from named env vars only (never stored — precedent src/forge/github-app/registration.ts:28). Reuse the zod conventions in src/warren-config/schema.ts; note that module is per-project, so this is warren's FIRST server-level config file and the PR must justify why env was not enough.\n\nMUST: (1) no config file present => WARREN_FORGE + GITHUB_TOKEN behaves exactly as today, resolving to a single-entry registry — this backward compatibility is the upstream-acceptability lever; (2) baseUrl required for self-hosted kinds, forbidden for github; (3) a missing tokenEnv variable fails LOUDLY at boot in the UnknownForgeError style (forge-contract.md §1.1 — no silent fallback); (4) decide whether instance ids are constrained — they land in RepoRef.forge, appear in logs and on persisted rows, and want the path-safety discipline of src/forge/github/repo-ref.ts.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T14:32:01.870Z","labels":["multi-forge","config"],"plan_id":"pl-f0e3","blocks":["warren-56bb","warren-834e","warren-9b6b"],"closedAt":"2026-08-20T14:32:01.870Z"}
{"id":"warren-1154","title":"Seam invariant fix: credential-env.ts must not hardcode github.com or x-access-token; widen check:layers to the bare host","status":"closed","type":"task","priority":2,"plan_step_index":1,"description":"SPEC: docs/design/multi-forge-support.md §3 Leak 1, and §8 step 2 for the framing.\n\nsrc/workspace/git/credential-env.ts:38 hardcodes both the host and GitHub's x-access-token username in the GIT_CONFIG insteadOf rewrite. forge-contract.md §0 names x-access-token as one of six things the domain must never leak. Take a GitCredential (which carries a provider-chosen username, contract.ts:61) plus the remote host instead. Widen the check:layers pattern from api\\\\.github\\\\.com to catch the bare github.com host outside src/forge/.\n\nFRAMING MATTERS: justify as an INVARIANT fix, not a multi-forge fix. No test fails today and FakeForge's fake:// URLs never exercise an authenticated non-GitHub remote, so a multi-forge argument here is the speculative generality planning-session-record:117 refused. The argument that survives review is §0's.\n\nCall sites threading a raw token: src/projects/clone.ts:165, refresh.ts, manage.ts, src/plan-runs/dispatch.ts, src/runs/retry/infra-lost-retry.ts, src/triggers/project-heal.ts, src/runtime/k8s/git-tokens.ts:75. Per mx-06bd81 any new credential-carrying field name must be added to SECRET_FIELDS in src/observability/log-redact.ts.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T20:19:13.946Z","labels":["multi-forge","seam"],"plan_id":"pl-f0e3","blocks":["warren-99a6","warren-9b6b"],"closedAt":"2026-08-20T20:19:13.946Z"}
{"id":"warren-56bb","title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","status":"open","type":"task","priority":2,"plan_step_index":2,"description":"SPEC: docs/design/multi-forge-support.md §4b (Forge identity validation).\n\nAdd ONE contract method so a provider can prove the software at a configured base URL is the kind the operator selected. Implement for GitHubForge and FakeForge (which satisfies it by owning fake://). Run it at forge-INSTANCE registration, not project creation — §4b splits the two checks and explains why.\n\nProbes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that exact path but 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id + x-github-media-type. All unauthenticated. Assert the NEGATIVE too — selecting Gitea as Forgejo must fail.\n\nJUSTIFY BY INVARIANT: this is the only widening of the Forge interface the design proposes, and §1's 'the contract needs no widening' is the strongest argument in the upstream case. The line that survives review: §0 forbids the domain learning what software a host runs, so the probe belongs behind the seam.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.095Z","labels":["multi-forge","validation"],"plan_id":"pl-f0e3","blockedBy":["warren-f012"],"blocks":["warren-834e","warren-9b6b"]}
{"id":"warren-56bb","title":"Forge identity probe: contract method + GitHubForge/FakeForge impls, run at instance registration to validate the operator's stated kind","status":"closed","type":"task","priority":2,"plan_step_index":2,"description":"SPEC: docs/design/multi-forge-support.md §4b (Forge identity validation).\n\nAdd ONE contract method so a provider can prove the software at a configured base URL is the kind the operator selected. Implement for GitHubForge and FakeForge (which satisfies it by owning fake://). Run it at forge-INSTANCE registration, not project creation — §4b splits the two checks and explains why.\n\nProbes measured live 2026-08-19: Forgejo GET /api/forgejo/v1/version -> 200 (codeberg.org); Gitea -> 404 on that exact path but 200 on /api/v1/version (gitea.com); GitLab GET /api/v4/version -> 401 carrying an x-gitlab-meta header; GitHub GET /meta -> 200 with x-github-request-id + x-github-media-type. All unauthenticated. Assert the NEGATIVE too — selecting Gitea as Forgejo must fail.\n\nJUSTIFY BY INVARIANT: this is the only widening of the Forge interface the design proposes, and §1's 'the contract needs no widening' is the strongest argument in the upstream case. The line that survives review: §0 forbids the domain learning what software a host runs, so the probe belongs behind the seam.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-20T23:13:16.652Z","labels":["multi-forge","validation"],"plan_id":"pl-f0e3","blockedBy":["warren-f012"],"blocks":["warren-834e","warren-9b6b"],"closedAt":"2026-08-20T23:13:16.652Z"}
{"id":"warren-834e","title":"The router: projects forge discriminator, POST /projects selection, ServerDeps resolver, instance-scoped parseRepoRef, UI picker, Leak 4 dispositions","status":"open","type":"task","priority":2,"plan_step_index":3,"description":"SPEC: docs/design/multi-forge-support.md §2 (routing) + §3 Leak 2/3/4 + §8 step 4 (measured blast radius).\n\nLand with GitHub and FakeForge ONLY — two forges already prove the plural path, and doing it before Forgejo exists keeps the router honest rather than Forgejo-shaped.\n\nScope: projects gains a forge discriminator (schema change IS required — URL re-derivation does not survive explicit selection); POST /projects gains the field (it accepts only gitUrl today, handlers/projects.ts:110); ServerDeps carries a resolver instead of one Forge; the §4b ownership check; the UI picker.\n\nCRITICAL (§2): parseGitHubRepoRef (src/forge/github/repo-ref.ts:31) is a PURE function of the URL — github.com baked into five grammars, key templated as github.com/owner/repo, forge field set to the module constant GITHUB_FORGE_KIND. Two self-hosted Forgejo instances would each claim the other's URLs. Providers must close over a configured baseUrl and RepoRef.forge must carry the INSTANCE id. The contract permits it (typed string, 'registry key') but every provider hardcodes its kind, so this is a per-provider change and it touches the one SHIPPED forge — a regression here breaks GitHub.\n\nLeak 4 dispositions (§3): make the credential heartbeat per-instance, looping over registered forges and probing each whose credentialLifetime is short-lived; keep the App registration gate instance-scoped. Both resolveForgeKind callers (src/server/github-app-gate.ts:86, src/server/main/forge-heartbeat-wiring.ts:46) ask a process-global question with no correct answer under multi-forge.\n\nBlast radius: deps.forge is 11 refs across 7 files (handlers/projects.ts, plan-runs.ts, alerts.ts, runs/dispatch.ts, runs/pause-resume.ts, runs/git-credential.ts, main/bridges-wiring.ts). Per mx-195e69 both inline-reap cancel sites migrate together through cancelRunWiring. Per mx-7f711e wiring lands in a NEW module: src/server/main/index.ts is at 486/500 check:size lines. Estimate two PRs.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.170Z","labels":["multi-forge","router"],"plan_id":"pl-f0e3","blockedBy":["warren-f012","warren-56bb"],"blocks":["warren-99a6","warren-9b6b"]}
{"id":"warren-09ea","title":"Forgejo empirical spike: answer the six research questions against the operator's own instance and amend the design doc with observed evidence","status":"open","type":"task","priority":2,"plan_step_index":4,"description":"SPEC: docs/design/multi-forge-support.md §6 (the six questions). Fill the §4 capability table with OBSERVED evidence, following the warren-bc4c precedent — each answer carries what was actually seen, because GitHub's equivalent spike found four things no doc stated.\n\nRun against the operator's OWN Forgejo (they can test Forgejo and GitLab):\nQ0. Do the §4b probes hold on a private instance, and do they still answer when the instance requires sign-in for all views? Public instances cannot test this and it decides whether the unauthenticated probe is contract or convenience.\nQ1. Does a Forgejo PAT reach PR create, PR list-by-head-and-base, PR patch-body, branch delete? Which scopes?\nQ2. What does Forgejo report for CI — commit statuses, an Actions API, or both? Is there a per-job log endpoint? Sets capabilities.checkRuns and jobLogs.\nQ3. Is PR creation idempotent-resolvable? contract.ts:201 REQUIRES a duplicate resolve to the existing PR rather than surface a conflict. What does Forgejo return?\nQ4. Can the token owner be read for botIdentity, and does Forgejo accept insteadOf-style https credential injection (this validates the warren-1154 fix)?\nQ5. What is the PR web URL shape exactly, and does it round-trip through parseRepoRef? Per mx-9cf91f this is a hard contract obligation and the most likely thing a new provider gets wrong. Capture a REAL URL, do not assume /pulls/<n>.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T13:28:38.252Z","labels":["multi-forge","spike"],"plan_id":"pl-f0e3","blocks":["warren-99a6","warren-9b6b"]}
{"id":"warren-99a6","title":"src/forge/forgejo/: transport core, error classifier, retry policy, provider + registry arm","status":"open","type":"task","priority":2,"plan_step_index":5,"description":"SPEC: docs/design/multi-forge-support.md §4a (implementation constraints recorded in mulch) + §4 (capability mapping — its Forgejo CI rows are UNVERIFIED; spike warren-09ea answers them, do not treat that table as ground truth) + §1 (the ten methods).\n\nMirror the src/forge/github/ decomposition — transport core, error classifier, retry policy, provider as separate modules — because the naive union exceeds the 500-line check:size budget (github/provider.ts sits at 486).\n\nBINDING CONSTRAINTS FROM MULCH (§4a), none of which appear in the design doc:\n- mx-9cf91f: parseRepoRef MUST round-trip the forge's own PR web URLs or the merge gate breaks. GitHubForge handles /pull/<n>, FakeForge strips /pulls/<n>.\n- mx-90f27c / mx-3aab77: transport retry direction is settled — transient is network/5xx/429, every other 4xx is FATAL, because retrying a 401/403 hides the expired-credential signal forge-contract §4 exists to surface. Copy this, do not invent one.\n- mx-0aebaa: capability flags gate BEFORE any forge call (poller stays idle), rate-limited through the ProjectHealTracker notice-gate.\n- mx-37f192 / mx-230461: request helper takes a userAgent + context label, exposes a retry? passthrough so tests inject sleep:async()=>{}; recordingFetch/jsonResponse test helpers, and jsonResponse takes (status, body) — opposite of legacy copies.\n- mx-0aebaa / mx-195e69: tests use FakeForge + Object.defineProperty to flip readonly capability flags; reap tests use fakeForge()/stubForge() from src/runs/reap/test-helpers.ts. NEVER hand-rolled fetch mocks.\n\nTests land in the SAME PR: Article II (nothing grandfathered at birth) and the coverage ratchet does not fund an untested tree.\n\nSCOPE NOTE (2026-08-19): the operator runs GITEA in their homelab and GITLAB at work, so 'Forgejo first' may be the wrong framing. Gitea and Forgejo share the /api/v1/ surface — the §4b probe showed they differ only in that Forgejo answers /api/forgejo/v1/version (200) where Gitea 404s. So this is probably ONE gitea-family provider serving both kinds, with the identity probe distinguishing them and capability flags absorbing divergence, rather than two providers. Settle this before writing the transport core; it changes the directory name and the registry arms.","createdAt":"2026-08-19T13:27:26.644Z","updatedAt":"2026-08-19T19:10:17.027Z","labels":["multi-forge","forgejo"],"plan_id":"pl-f0e3","blockedBy":["warren-1154","warren-834e","warren-09ea"],"blocks":["warren-9449","warren-9b6b"]}
Expand Down
17 changes: 16 additions & 1 deletion src/forge/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import {
type PullRequestLifecycle,
} from "../core/wire.ts";
import type { Forge, PullRequestState } from "./contract.ts";
import { FakeForge } from "./fake/fake-forge.ts";
import { FAKE_CLONE_URL_SCHEME, FakeForge } from "./fake/fake-forge.ts";
import { GITHUB_API_BASE } from "./github/headers.ts";
import { GitHubForge } from "./github/provider.ts";
import { stubGitHubServer } from "./github/stub-server.ts";
import { GitHubAppForge } from "./github-app/provider.ts";
Expand Down Expand Up @@ -56,6 +57,11 @@ export interface ForgeConformanceOptions {
readonly botIdentity: boolean;
/** true when minted credentials carry a real expiry (GitHub App mode, §4). */
readonly shortLivedCredential?: boolean;
/**
* Base URL probeIdentity should succeed on (warren-56bb §4b).
* FakeForge: "fake://"; GitHubForge: GITHUB_API_BASE (stub handles /meta).
*/
readonly probeBaseUrl: string;
}

/** Conformance: every Forge implementation must satisfy these behaviours. */
Expand Down Expand Up @@ -201,6 +207,12 @@ export function forgeConformanceSuite(makeForge: () => Forge, opts: ForgeConform
expect(identity.error.kind).toBe("unsupported");
}
});

test("probeIdentity confirms its own kind at the configured base URL (§4b — warren-56bb)", async () => {
const { forge } = setup();
const result = await forge.probeIdentity(opts.probeBaseUrl);
expect(result.ok).toBe(true);
});
}

describe("FakeForge conforms to the Forge contract", () => {
Expand All @@ -209,6 +221,7 @@ describe("FakeForge conforms to the Forge contract", () => {
forgeKind: "fake",
foreignUrls: ["https://github.com/o/r.git", "git@github.com:o/r.git"],
botIdentity: true,
probeBaseUrl: FAKE_CLONE_URL_SCHEME,
});
});

Expand All @@ -220,6 +233,7 @@ describe("GitHubForge conforms to the Forge contract", () => {
forgeKind: "github",
foreignUrls: ["fake://projects/widget", "https://gitlab.com/o/r.git"],
botIdentity: false,
probeBaseUrl: GITHUB_API_BASE,
},
);
});
Expand All @@ -239,6 +253,7 @@ describe("GitHubAppForge conforms to the Forge contract", () => {
foreignUrls: ["fake://projects/widget", "https://gitlab.com/o/r.git"],
botIdentity: true,
shortLivedCredential: true,
probeBaseUrl: GITHUB_API_BASE,
},
);
});
17 changes: 17 additions & 0 deletions src/forge/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,4 +249,21 @@ export interface Forge {
* authorship are separate concerns on every forge (§6.8).
*/
botIdentity(): Promise<ForgeResult<GitIdentity>>;

/**
* Validate that the software at `baseUrl` is the forge kind this provider
* implements (multi-forge-support.md §4b — warren-56bb). Called once at
* forge-instance registration; never at project creation (§4b splits those
* two checks). Returns ok(undefined) when the probe confirms the kind;
* ForgeError when the host does not match (http_error / identity_mismatch),
* is unreachable (network), or returns an unexpected response.
*
* FakeForge satisfies this by owning the `fake://` scheme and checking the
* URL prefix without a network call. GitHubForge probes GET
* `${baseUrl}/meta` unauthenticated and asserts GitHub-specific headers.
* The probe is strictly a validation of what KIND of software answers —
* not an authorization check (§4b: "a version string is not an
* authorization check").
*/
probeIdentity(baseUrl: string): Promise<ForgeResult<void>>;
}
Loading
Loading