Skip to content

feat(forge): add GitLab as a second forge behind the seam - #65

Merged
LeTuR merged 2 commits into
mainfrom
feat/gitlab-forge
Sep 10, 2026
Merged

feat(forge): add GitLab as a second forge behind the seam#65
LeTuR merged 2 commits into
mainfrom
feat/gitlab-forge

Conversation

@LeTuR

@LeTuR LeTuR commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Intent

Add GitLab as fleet's SECOND forge behind the forge seam that landed in #62, so that fleet stops assuming GitHub. The operator asked: 'We would like also to at least support gitlab as a new forge (if any changes are required).'

Scope and deliberate decisions, so a reviewer reading only the diff does not flag them as mistakes:

  1. HARD CONSTRAINT FROM THE TASK BRIEF: do NOT refactor the seam, and do not add a third forge. If the interface is wrong for GitLab, report the mismatch rather than bend the interface. That is why GitLabForge implements forge.Forge exactly as written and changes no signature.

  2. glab is the CLI, mirroring how the GitHub adapter uses gh — same shutil.which guard, same '(answer, why-not) where a non-empty why is never a verdict' contract. Hosts are gitlab.com plus GITLAB_HOST (glab's own variable, as GH_HOST is gh's). Every call names its repository by FULL URL (-R https://host/group/project) rather than by slug, deliberately: the URL is the only form carrying the host, and self-hosted GitLab is the normal case. Consequence, accepted and documented: an instance reachable only over plain http cannot be addressed.

  3. Field names and shapes were VERIFIED against a real instance, not assumed. gitlab.com answers glab unauthenticated for public projects, so sha/source_branch/target_branch/state/draft/description/has_conflicts/detailed_merge_status/source_project_id/target_project_id/web_url/author.username/head_pipeline.* and the commits endpoint were all checked live with glab 1.117.0. Two answers are behind auth and could NOT be verified — GET /projects/:id squash_option (returns null unauthenticated) and GET /projects/:id/members/all access_level (401) — so those fixtures are CONSTRUCTED from GitLab's REST docs and are labelled as constructed both in scripts/fixtures/glab/README.md and at the point of use in the selftest. That labelling is deliberate and required by the brief.

  4. mergeable deliberately reads only 'mergeable' and 'conflict' out of detailed_merge_status and returns '' for everything else. The real recorded merge request answered 'title_regex', a value absent from the documented set — so any unknown word must mean 'the forge has not said' (classify reads that as ask-again-shortly), never 'mergeable'. This looks conservative on purpose.

  5. Squash is NOT a merge method on GitLab: it is a flag on the merge, and the project setting that forbids it is squash_option: never (merge_method is a separate merge/rebase_merge/ff setting). So merge_methods stays ('squash','merge','rebase') at the forge level and the per-PROJECT refusal is returned from merge() as (False, note) — asked BEFORE anything is merged, never raised, and never silently merged by another method. If the setting cannot be READ the answer is None and does not block, because 'I could not read it' is not 'it forbids it'. This is the mismatch refactor(queue): put a forge seam under fleet, with GitHub as one implementation #62's own result.md predicted; I report it rather than changing MERGE_METHOD into a per-repo setting.

  6. open_change_requests costs one extra glab call per merge request, because GitLab does not put a pipeline in its list endpoint. That N+1 is a deliberate trade: returning checks=[] would be read by every caller as 'no check has reported yet', which would make fleet refuse to merge anything on GitLab.

  7. Two safety properties added because GitLab made them cheap: a head_pipeline whose sha is not the merge request's head yields NO checks rather than a pass (GitLab keeps the previous commit's pipeline there until the new one is created); and merge passes glab's --sha flag so a push landing between fleet's check and the merge cannot slip in.

  8. author_is_bot has no field on GitLab — the MR author object carries no bot flag — so the adapter matches only GitLab's reserved project_bot / group_bot username shape. review_decision can only answer 'changes-requested' or ''; 'approved' is never returned, and classify does not read it.

  9. queue.py's FORGE_PROBE was hard-coded to 'ssh -T git@github.com'. The brief said to fix it here because a remote GitLab task cannot work without it. It now reads the repository's origin ON THE HOST, derives host and port (scp, https:// and ssh://host:port forms), probes that host, and accepts GitHub's 'successfully authenticated' AND GitLab's 'Welcome to GitLab' — ssh -T exits non-zero on a SUCCESSFUL GitHub auth, so exit status cannot be the test. I also REORDERED the probes to reachable/repo/forge: which forge to prove a credential against is a fact about that checkout's origin, so it cannot be asked before the checkout is known to exist. Each probe's failure test is independent, so no existing test depended on the old order; the skills documenting the order are updated.

  10. forge.open_change_requests_in_checkout now joins EVERY forge's reason instead of keeping the last. With two forges configured, keeping the last names one missing CLI and hides the other — it would read as 'install glab' on a machine that talks to GitHub.

  11. Proof with no network was required and is in queue-selftest.sh section 14: a fake glab replays recorded fixtures, gh is a tripwire, and the section drives parsing (14a), the whole queue end to end against a self-hosted subgroup project (14b), the squash refusal (14c) and the credential probe under stubbed git/ssh (14d). The GitHub path is untouched in behaviour and every pre-existing selftest passes unchanged.

  12. DOCS ARE IN SCOPE — the operator widened the task mid-run: 'This sentence in doc must be changed: control plane for your work across GitHub... That is README.md:10. FLEET.md:4 has the twin. Both state GitHub as the PREMISE of what fleet is.' The rule applied is theirs: GitHub as the CONFIGURED FORGE stays wherever it is factually about the GitHub adapter, the gh dependency, or this repo's own contribution process; GitHub as the ASSUMPTION goes. 'Change request' is the seam's own neutral noun and no second word was invented. The operator also said explicitly: do not rewrite prose to prune rationale, just cut; and CONTRIBUTING.md really does document this repo's own GitHub process, so most of its mentions are correct as they stand — I changed NONE of them, deliberately. registry/owners.txt is still described as GitHub owners everywhere, deliberately, because sync-registry.sh enumerates repositories with gh api and that is a third seam refactor(queue): put a forge seam under fleet, with GitHub as one implementation #62 also left alone; the README and onboarding skill now say out loud that gh is required even on a GitLab-only fleet for exactly that reason.

  13. interface/fleet_queue.lua rendered an artifact as #N only for /pull/, so a GitLab artifact drew as a bare URL — refactor(queue): put a forge seam under fleet, with GitHub as one implementation #62's result.md flagged it. It now renders !N for /-/merge_requests/, keeping each forge's own notation rather than flattening both to #, and pane_harness.lua's fixture carries one of each so the pane is rendered against both.

./scripts/check.sh — the repo's whole gate — is green in full before this run.

What Changed

  • Add GitLabForge to scripts/lib/forge.py, implementing the existing Forge interface unchanged and driving glab (mirroring the gh adapter's shutil.which guard and (answer, why-not) contract), addressing repos by full URL against gitlab.com/GITLAB_HOST, with conservative mergeable/author_is_bot/review_decision handling and a pre-merge squash-setting refusal returned from merge() rather than raised.
  • Update scripts/lib/queue.py: FORGE_PROBE now derives the host/port from the checkout's own git origin and accepts both GitHub's and GitLab's SSH auth success strings instead of hard-coding git@github.com; task-startup probe order changes to reachable/repo/forge; open_change_requests_in_checkout now joins every configured forge's failure reason instead of keeping only the last. interface/fleet_queue.lua renders GitLab merge requests as !N (vs GitHub's #N), and pane_harness.lua's fixtures cover both.
  • Add offline GitLab test fixtures under scripts/fixtures/glab/ (recorded against a live instance where possible, constructed-and-labelled where auth-gated) and a new queue-selftest.sh section 14 exercising the GitLab adapter end-to-end with no network.
  • Update AGENTS.md, FLEET.md, README.md, extension.toml.in, and the fleet skills/queue docs to stop assuming GitHub as the only forge, while leaving GitHub-specific configuration and CONTRIBUTING.md's own process docs as-is.

Risk Assessment

✅ Low: The GitLab adapter mirrors the existing GitHub adapter's structure exactly, every field mapping and error-handling path is exercised by fixture-backed selftests recorded against a real glab instance, the seam's interface was left untouched as required, and all cross-referenced documentation (AGENTS.md, README, FLEET.md, skills, POLICY.md) was updated consistently with no stale references to the old single-forge assumptions.

Testing

Baseline ./scripts/check.sh already passed; on top of that I ran the queue's own selftest scripts directly (queue-selftest.sh, pane-selftest.sh) to get an explicit, readable CLI transcript of every GitLab-forge claim in the intent passing, captured a real rendered-pane artifact showing GitLab's !N notation next to GitHub's #N, and manually exercised the forge module's public interface to confirm the joined-reasons behavior — all green, no regressions, no missing evidence.

Evidence: Rendered TUI pane showing GitHub #47 and GitLab !52 notations side by side
 ⛽ fuel reserve 15%                                      2m
 claude ██████┃██████████████████████░░░░░░░░░░░░░░░░░░  62%
────────────────────────────────────────────────────────────
 3 running  1 ready  1 waiting  3 done
────────────────────────────────────────────────────────────
  7 archived
 ◐ RUNNING  3 topics
 ── pane-declutter ─────────────────────────────────────────
 ◐ 01 Cut the pane back to what the operator acts on      6m

 ── publish-agnostic ───────────────────────────────────────
 ◐ 02 Record the publish state the shepherd already se…  15m
 ◆ 03 Draw the publish row in the TUI queue pane         15m
   ↳ 02-shepherd-records-publish  consumes
   1 landed

 ── retire-webui ───────────────────────────────────────────
 ● 01 Remove the web monitor and everything that start…  48m
   ⇡ no-mistakes · #47 · green — yours to merge           9m
 ▶ 02 Point every document at the pane                   48m
   no brief
 ◐ 03 Drop the monitor's selftest and its CI job          3m
   uncollected
   ⇡ no-mistakes · !52 · open — review                    3m

 ● DONE  1 topic
   remote-dispatch  1 landed
Evidence: queue-selftest.sh GitLab adapter section (14a-14d) pass transcript
ok a /-/merge_requests/ URL on gitlab.com is a change request
ok and glab answers for it
ok a merge request from a FORK is not ours
ok an undocumented detailed_merge_status is not read as mergeable
ok a pipeline for a commit that is no longer the head is no check at all
ok GITLAB_HOST configures a self-hosted instance, scheme and all
ok a project configured against squash says so in its own words
ok and nothing is merged while it forbids it
ok the probe proves a GitLab host against GitLab's own welcome
ok and it asked THAT host, not github.com
ok GitHub's own banner still passes, unchanged
queue-selftest: every claim holds

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • ./scripts/check.sh
  • ./scripts/queue-selftest.sh (full run, focus on section 14 GitLab adapter: 14a URL/shape parsing, 14b whole-queue collect/land/shepherd through GitLab, 14c squash_option:never refusal, 14d remote-host credential probe reorder)
  • ./scripts/pane-selftest.sh (includes 'and a GitLab merge request is named in GitLab's own notation')
  • lua scripts/lib/pane_harness.lua 60 — direct rendered-pane transcript showing '!52' (GitLab MR) beside '#47' (GitHub PR)
  • manual: python3 -c 'forge.open_change_requests_in_checkout(path)' with gh present/misconfigured and glab absent from PATH — confirmed the joined reason string contains both forges' explanations, not just the last one
  • grep-verified README.md:10 and FLEET.md:4 now read 'GitHub and GitLab' rather than GitHub-only
⚠️ **Document** - 1 info
  • ℹ️ scripts/fleet-status.sh:8 - scripts/fleet-status.sh's own header still says 'one gh pr list per repo in flight' and lists only gh as the forge dependency, even though its implementation (scripts/lib/fleet_status.py) already reads the forge-agnostic seam. This predates the GitLab change under review (already present at base commit bd8aca6, from refactor(queue): put a forge seam under fleet, with GitHub as one implementation #62's seam introduction), so it wasn't made stale by this diff. Worth a follow-up to generalize it, but out of scope here since this diff didn't alter that fact.
🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix: No fix needed: lint gate (check.sh) already passes cleanly
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

`forge.GitLabForge`, through `glab`, answering every question the seam's
header lists — registered in `BUILTIN` beside GitHub, with hosts from
`GITLAB_HOST` and every call naming its host by full URL so a self-hosted
instance is the ordinary case.

The remote-host credential probe now reads the repository's own `origin` and
proves a credential against THAT host, accepting GitLab's welcome banner
beside GitHub's; the probes are reordered so the repo is known before its
forge is asked about. The pane renders a merge request as `!N`.

Proved with no network: `queue-selftest.sh` §14 drives the adapter over
`glab` output recorded from gitlab.com, with `gh` as a tripwire.

Claude-Session: https://claude.ai/code/session_017DeqTLUSS84GCwowZtcLbU
@LeTuR
LeTuR merged commit 392aef2 into main Sep 10, 2026
10 checks passed
@LeTuR
LeTuR deleted the feat/gitlab-forge branch September 10, 2026 07:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant