feat(forge): add GitLab as a second forge behind the seam - #65
Merged
Conversation
`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
…ab: probe order, change-request wording
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
GitLabForgetoscripts/lib/forge.py, implementing the existingForgeinterface unchanged and drivingglab(mirroring theghadapter'sshutil.whichguard and(answer, why-not)contract), addressing repos by full URL againstgitlab.com/GITLAB_HOST, with conservativemergeable/author_is_bot/review_decisionhandling and a pre-merge squash-setting refusal returned frommerge()rather than raised.scripts/lib/queue.py:FORGE_PROBEnow 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-codinggit@github.com; task-startup probe order changes to reachable/repo/forge;open_change_requests_in_checkoutnow joins every configured forge's failure reason instead of keeping only the last.interface/fleet_queue.luarenders GitLab merge requests as!N(vs GitHub's#N), andpane_harness.lua's fixtures cover both.scripts/fixtures/glab/(recorded against a live instance where possible, constructed-and-labelled where auth-gated) and a newqueue-selftest.shsection 14 exercising the GitLab adapter end-to-end with no network.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 andCONTRIBUTING.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
!Nnotation 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
Evidence: queue-selftest.sh GitLab adapter section (14a-14d) pass transcript
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 onegrep-verified README.md:10 and FLEET.md:4 now read 'GitHub and GitLab' rather than GitHub-onlyscripts/fleet-status.sh:8- scripts/fleet-status.sh's own header still says 'onegh pr listper repo in flight' and lists onlyghas 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 ✅
🔧 Fix: No fix needed: lint gate (check.sh) already passes cleanly
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.