Skip to content

feat: GitLab merge requests as pull requests - #690

Merged
Tryanks merged 15 commits into
mainfrom
feat/gitlab-host
Oct 11, 2026
Merged

Tryanks merged 15 commits into
mainfrom
feat/gitlab-host

Conversation

@Tryanks

@Tryanks Tryanks commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

Step 3 of #643 (A7 of #535): GitLab (gitlab.com and self-managed) behind the host boundary, following the Forgejo host of #683.

core::pull_request  HostKind::Gitlab: gitlab.com by name or a "gitlab" DNS label; a configured kind beats the name;
                    authority = host[:port], no path (HostRefusal::Path). GITLAB terms: "GitLab", clis "glab",
                    merge_commands ("glab","mr") moved out of GITHUB's, no stacks, conflicts_page None (GitLab's
                    /conflicts answers 404 for a mergeable MR), checks_page "/pipelines", mark icons/gitlab.svg.
                    is_pull_request_url also reads /-/merge_requests/N (subgroups, with or without /-/).

services::forge     Hosts dispatcher gains gitlab; shared by both non-GitHub hosts (moved out of forgejo/):
  verdicts.rs         the 30-second conflict rule (a conflict holds at one head before it is one)
  viewed.rs           Tcode-kept viewed marks; ONE store for Forgejo and GitLab (same viewed-marks.json)
  http.rs             answered / in_order (Partial) / same_origin / media read / host CLI runner
  offered()           UpdateBranch{rebase:false} needs the new capability update_merge

services::gitlab    REST v4 per server, Bearer token; GraphQL where REST refuses anonymous reads
  api.rs        token: saved → GITLAB_TOKEN (only for the server GITLAB_HOST, else GITLAB_URI, else GL_HOST
                names — glab's own order — else gitlab.com) →
                `glab auth git-credential get` asked per host (glab's env tokens stripped);
                hosts from `glab auth status --all --output json`; 429 → Retry-After / RateLimit-Reset
  reads.rs      MR (REST, include_diverged_commits_count) → state, sha, pipeline check, mergeability
                (has_conflicts / detailed_merge_status through Verdicts), merge state, project merge methods;
                discussions + awards + permissions + reviewer states + labels (GraphQL, 50 per page, 10 pages);
                line counts from GraphQL diffStatsSummary; relative /uploads/ links in bodies named in full
  actions.rs    comment, reply, resolve, award/unaward, edit note, edit title/description, labels (by name),
                reviewers (GraphQL mergeRequestSetReviewers APPEND/REMOVE by username, checked against the
                answered set), Draft: prefix, close/reopen, rebase (answers RebaseStarted), merge (squash, sha guard,
                merge_when_pipeline_succeeds, cleaned merge/squash message), cancel auto-merge,
                review = line discussions → summary note → approve (Partial when cut short)
  repository.rs https/ssh remotes, ports, subgroups, project id URL-encoded
  mod.rs        Forge impl: discover (one page of opened MRs for the source branch, forks by project id),
                files (/diffs pages, cursor = page), file text, candidates, action state, media, watch, activity

ui                  GitLab in Add host (kind menu, placeholder, note, path refusal); rows as before;
                    rebase-only branch update when !update_merge, with a rebase tooltip and "Rebase started" toast;
                    a partial review gets its own toast; no line stat where the host counts none

Evidence

Before: GitLab URLs were read as GitHub's (unknown host → GitHub) and failed; Add host had no GitLab kind.
After:

Desktop: Source Control Desktop: Add host, GitLab (path refused) Desktop: Add host, GitLab
Desktop: GitLab MR, conversation Desktop: GitLab MR, files Phone: Source Control Phone: GitLab MR

Fresh profile, English, light; a synthetic self-managed host gitlab.acme.test:8443; no CLI logins (a throwaway HOME, so none of the machine's own gh/tea/glab logins appear), so gitlab.com is not listed: nothing names it. Add host types code.acme.test:8443, which is not listed. The desktop shots and the phone Source Control were re-shot after the first review; the phone conversation is kept from round 1, since nothing in the corrections changes that page. The MR page is gitlab-org/gitlab-test!26, GitLab's public test-fixture project, linked anonymously through the link dialog: the conversation shows awards on the description, avatars and a coloured label; Files shows the hunks with per-file counts, GitLab's own "+3 −0" for the MR (it read "+0 −0" before) and "Viewed marks kept by Tcode". In the first round, toggling a mark wrote viewed-marks.json keyed by the file's GitLab blob id 5c5eba4…, and a note GitLab marks edited showed "edited". That MR has no line discussion, so the shots show no review thread; one could not be created without a token. A quick dark-theme look at Source Control showed the marks and notices in theme colours.

Live reads on gitlab.com through Tcode's host (forge::connect driven by a throwaway probe, not committed, anonymous; counts are gitlab host=… path=… request log lines):

  • gitlab-org/cli!4049 (merged, resolved line discussions, head pipeline success): summary 1 (Merged, checks Passing, Clean, 12 files); conversation 1 (7 comments, 3 threads all resolved, 4 labels with colours, reviewers GitLabDuo ChangesRequested and jay_mccure Approved, complete); files 2 (12 files with hunks, complete); file text 1 (go.mod, 157 lines); viewed files 3 (12 blob revisions); label candidates 2 (104, 4 applied); reviewer candidates 2 (102, 2 requested); action state 2 (behind 0); watch detail 1 (Merged, head sha, one Pipeline check Success, Clean); activity 1 (13 remarks, 6 on lines). 16 lines in total.
  • gitlab-org/cli!4044 (open, unresolved threads): summary 1 (Open, Passing, Clean), action state 2 (Blocked from discussions_not_resolved, behind 26), watch detail 1, activity 1. A first run hit the 60 s deadline on three reads while gitlab.com was slow from here; the rerun answered all of them.
  • Found and fixed by these reads: gitlab.com answers REST notes, discussions and projects/:id/labels with 401 to an anonymous reader, so discussions, awards, permissions and label candidates are read over GraphQL (Source control: other hosts, Forgejo/Gitea, GitLab, Bitbucket, Azure DevOps (A7) #643: "direct REST (or GraphQL where the host offers it)"); GraphQL's MergeRequest has no lastEditedAt, so the description carries no edited-at; a merged MR keeps merge_when_pipeline_succeeds: true, so auto-merge reads as armed only while open; GraphQL requests logged no path.

Writes: unverified. This machine has no glab and no GITLAB_TOKEN, so no scratch project tcode-a7-gitlab-check was created and nothing needed deleting. Unverified against a live server: comment, reply, resolve/unresolve, award and unaward, edit note, edit title/description, labels, reviewers, draft/ready (title prefix), close, reopen, rebase, merge (merge/squash, cleaned message, merge_when_pipeline_succeeds and its cancel), review (line discussions with position, summary note, approve). Also unverified: glab's credential helper and auth status --output json (read from glab's source, internal/commands/auth/login/helper.go and auth/status/status.go, not run), REST merge_method/squash_option for an authenticated reader (anonymous reads omit them; lib/api/entities/project.rb exposes them on the full entity), and media reads with a token, including whether GitLab's web upload route (https://host/<project>/uploads/<secret>/<file>) accepts a Bearer personal access token for a private project; if it does not, private images answer 302 or 404 and are not drawn, and the API route /api/v4/projects/:id/uploads/:secret/:filename is the alternative.

Upstream gaps fixed (#643): head SHA on every watch read (sha); checks state on the snapshot from the head pipeline; mergeability through the Forgejo Verdicts rule, so a conflict reported while GitLab rechecks is held 30 s at one head; edited-at on notes from lastEditedBy (GitLab bumps lastEditedAt when a thread is resolved; only an edit names an editor); merges send merge_when_pipeline_succeeds: false unless asked; update branch is a rebase; review replay reports Partial.

Left out because the API cannot do it (no substitute built):

  • Request-changes verdict: REST approves or does not; capability request_changes false, so the review sheet offers Comment and Approve.
  • Merge-style branch update: GitLab only rebases (PUT /rebase); capability update_merge false, so the merge item is absent and the primary update asks for the rebase.
  • Unapprove: GitLab has POST /unapprove, but no Tcode action takes back a verdict (no such action exists for any host), so it is not wired.
  • Pipeline jobs as checks: one extra request per watch pass; the head pipeline is the one check, as upstream.
  • Approvals as watch remarks: GitLab writes them as system notes only; activity carries notes.
  • Reviewer write race: closed, not left — mergeRequestSetReviewers exists on gitlab.com's schema (introspected: projectPath, iid, reviewerUsernames, operationMode: REPLACE|APPEND|REMOVE), so additions APPEND and removals REMOVE against the set GitLab holds when it applies them; each change is checked against the reviewer set GitLab answers with, and what did not take (for example a second reviewer on a tier that takes one) is reported Partial or Rejected instead of Applied.

Review fixes (round 2)

  • H1: GITLAB_TOKEN follows glab's host variables in glab's order, GITLAB_HOST, GITLAB_URI, GL_HOST (glab internal/config/schema.go:154); with GL_HOST=https://gitlab.corp.example the token goes there and never to gitlab.com. GITLAB_URI was added beside the review's GL_HOST because it carries the same hazard. The test failed without the fix (the token went to gitlab.com).
  • M2: gitlab.com is listed only when settings, a glab login or GITLAB_TOKEN name it, like codeberg.org; screenshots re-shot.
  • M3: line counts from GraphQL diffStatsSummary { additions deletions }; PullRequestSnapshot.additions/deletions became Option<u64> (a kept number still reads as a count; absent hides the stat in the row and the Files header instead of showing zeros).
  • M4: relative upload links in the description and notes (](/uploads/, ]: /uploads/, src=/href= attributes) are named https://host/<project>/uploads/…, so the client can draw them and the media read finds them named; the separate below path match became redundant and was removed. An edit of such a body sends the absolute links back, which GitLab accepts. Fenced code blocks (``` and ~~~) and inline code spans stay as written (second review), so a comment showing Markdown syntax keeps its code text. Left relative, so fail-closed (the link stays as GitLab wrote it and its media is not read): [r]:/uploads/… without a space, angle-bracket destinations `](</uploads/…>)`, unquoted `src=/uploads/…`, and indented (four-space) code blocks, which are rewritten like text.
  • M5: reviewers through mergeRequestSetReviewers APPEND/REMOVE (above); the REST read-modify-write is gone.
  • M6: GitLab's rebase answers RebaseStarted (its doc now covers one pull request's update the host finishes on its own); the lifecycle toast says "Rebase started for %{head}" / "%{host_name} rebases it shortly. Pull before pushing from this thread." (design §5.3), and the rebase-only primary and the behind chip's menu label use a rebase tooltip instead of "Merges the latest …".
  • L2: dispatcher test for the update_merge gate.
  • L4: manual and scheduled pipelines read as Pending (a pipeline blocked on a manual job must not read as Passing); the watch keeps reading such a merge request as in flight.
  • Removed the auto_merge_enabled reads: GitLab's REST merge request entity exposes only merge_when_pipeline_succeeds (lib/api/entities/merge_request_basic.rb:65).
  • Left as they are, noted: L3 — a project whose merge_method is rebase_merge creates a merge commit, yet it is offered as Rebase, so agent-credit cleaning is skipped for it; L5 — auto_merge_allowed is unconditional (GitLab has no project switch Tcode reads for it).
  • Second review: GitLab's line counts no longer gate a summary: a failed diffStatsSummary read logs at debug and the snapshot carries no stat.
  • Accepted for now: the review-partial toast deviates from A7 UI §5.3 — it is a warning, not an error, and it has no "Posted: … Not posted: …" list.

Tests

  • Second review: uploads_are_named_on_their_server gains a fenced block (``` and ~~~), an inline code span followed by a link that is still named, and a non-ASCII code span (the first draft of the span scan sliced at byte offsets and would have panicked on it).
  • New in round 2: gitlab_com_is_listed_once_something_names_it; a_kept_snapshot_reads_its_line_counts (a literal kept snapshot with numbers reads them; without the fields, no count); uploads_are_named_on_their_server; a_reviewer_change_is_what_gitlab_answers (a second reviewer GitLab keeps out without an error is not Applied; an answer without the merge request is no removal); a_merge_update_is_refused_where_the_host_only_rebases (dispatcher: UpdateBranch{rebase:false} → Rejected(Unsupported) before any request; rebase:true passes the gate and meets the host, here turned off, as HostDisabled). Extended: a_token_goes_only_to_the_server_it_is_for (GL_HOST alone, and GITLAB_HOST beating GL_HOST; the fake glab also refuses to run with any host variable); a_pipeline_reads_as_one_check (manual and scheduled are pending; that expectation changed with the contract, per the review).
  • New: urls_and_remotes_find_the_project_by_port_and_subgroups (MR URL with port and subgroups, with and without /-/; https, ssh:// and scp remotes; userinfo host refused; a one-segment project refused; URL-encoded project id); a_token_goes_only_to_the_server_it_is_for (gitlab: GITLAB_TOKEN only for GITLAB_HOST's server, else gitlab.com; a fake glab that refuses to run with GITLAB_TOKEN in its environment answers only its own host:port); merge_status_reads_as_mergeability_and_merge_state; a_pipeline_reads_as_one_check; a_draft_is_its_titles_prefix; a_comment_names_the_lines_gitlab_places_it_by (it failed first: the hunk counter started one line late, fixed in the code).
  • Extended: each_host_goes_to_its_kinds_implementation (gitlab.com and a gitlab label by name; gitlab.acme.test configured as Forgejo stays Forgejo; a configured code.acme.test:8443 routes its MR URL to GitLab); the_model_reads_the_hosts_settings_configure (the GitHub-only linking text is still the literal origin/main text; GitLab configured adds glab and "GitHub or GitLab"; glab mr close still detected); each_kind_names_its_hosts_by_its_own_rule (GitLab port allowed, path refused); the URL-hint test (GitLab MR URL, one-segment project refused).
  • Moved to its owner, unchanged in contract: forgejo::reads::a_conflict_is_a_false_that_holds_at_one_head → forge::verdicts::a_conflict_is_one_that_holds_at_one_head (the rule is shared now; it reads a Mergeability instead of Forgejo JSON, whose mergeable mapping stays covered by statuses_and_mergeability_read_as_the_watch_needs); forgejo::api::a_redirect_off_the_servers_origin_drops_the_token → forge::http with the media read it guards.

Checks at the PR head

  • cargo fmt --all --check: clean
  • cargo clippy --workspace --all-targets --locked -- -D warnings: clean
  • cargo nextest run --workspace --locked --no-fail-fast: 1122 tests run: 1122 passed, 13 skipped
  • cargo machete: no unused dependencies
  • iOS, Android and Web run in CI only. Narrow desktop was not inspected; the phone geometry was.

Follow-ups (not in this PR)

  • L1: a Partial review keeps the comments it did post as drafts (the draft is marked uncertain), so submitting again duplicates them; together with part names in the partial toast (A7 UI §5.3 "Posted: … Not posted: …").
  • L6: fold GitHub's private in_order/Step into forge::http.
  • Unapprove; pipeline jobs as checks; approvals as watch remarks.
  • Kind glyphs on badges and rows, Add host from the link dialog (as feat: Forgejo and Gitea pull request hosts #683).

Merge Danger

Door: two-way
Blast Radius: source-control
Wire changes, noted above PROTOCOL_VERSION (no bump): HostKind gains gitlab; PullRequestCapabilities.update_merge (GitHub and Forgejo true); PullRequestSnapshot.additions/deletions may be null (kept thread metadata with numbers still loads; an older build reading a null fails on that snapshot); RebaseStarted also answers one pull request's branch update. GitLab tokens are saved under @gitlab. Hosts named gitlab.* or with a gitlab label that were read as GitHub before now route to GitLab, unless settings say otherwise. The model-visible text is unchanged for GitHub-only settings and grows only when a GitLab host is configured. Side effects on other hosts: is_pull_request_url (a menu hint) now accepts GitLab MR URLs; merges_or_closes still matches glab mr merge|close, now through GitLab's terms. Forgejo's verdicts, viewed marks, write answers and media read moved to forge unchanged, and the two hosts now share one viewed-marks store instead of each holding the file (two stores on one file would overwrite each other's marks). The GitLab mark (assets/icons/gitlab.svg) is Simple Icons' (CC0).

Part of #643

Tryanks added 13 commits October 9, 2026 21:42
HostKind::Gitlab (gitlab.com by name or a "gitlab" DNS label; self-managed
servers by configuration, with a port and no path) and GitLab's terms, which
take glab's merge commands from GitHub's. services::gitlab reaches each server
over REST v4 with its own token (saved, GITLAB_TOKEN for GITLAB_HOST's server
or gitlab.com, glab's stored login per host) and reads discussions, awards,
labels, permissions and blobs over GraphQL, which answers a public project
anonymously where REST's notes and labels refuse. Writes cover comments,
replies, resolution, awards, edits, labels by name, reviewers by id, the
Draft: title prefix, close and reopen, rebase, merge with squash, a cleaned
message and merge_when_pipeline_succeeds, and a review replayed as line
discussions, a note and an approval.

The conflict rule that holds a false for 30 seconds at one head, Tcode's
viewed marks, write answers, media reads and the CLI runner move to forge so
Forgejo and GitLab share them; one viewed-marks store serves both. Capabilities
gain update_merge, which GitLab lacks, so its branch update is a rebase.
The GitLab mark joins the icon assets. On a host without update_merge the
behind chip and the menu offer only the rebase, and the primary update asks
for it; a review a host stopped part way through says so in its own toast.
@Tryanks
Tryanks marked this pull request as ready for review October 11, 2026 00:22
@Tryanks
Tryanks enabled auto-merge (squash) October 11, 2026 00:22
# Conflicts:
#	crates/ui/src/pull_requests/lifecycle.rs
#	crates/ui/src/pull_requests/mod.rs
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