Skip to content

feat(gitlab): GitLab task layer — full parity with GitHub automation#176

Merged
pawelkrystkiewicz merged 23 commits into
mainfrom
feat/gitlab-task-layer
Jun 24, 2026
Merged

feat(gitlab): GitLab task layer — full parity with GitHub automation#176
pawelkrystkiewicz merged 23 commits into
mainfrom
feat/gitlab-task-layer

Conversation

@pawelkrystkiewicz

Copy link
Copy Markdown
Collaborator

Summary

  • Adds GitLab as a first-class task source, mirroring the GitHub integration end-to-end
  • All GitLab operations use the glab CLI (same pattern as gh for GitHub)
  • Uses scoped labels (vanguard::running, vanguard::review, etc.) for automatic mutual-exclusivity on label transitions

New commands

Command Description
vanguard run --gitlab group/project#42 Run a GitLab issue → draft MR
vanguard watch --source gitlab --label vanguard Autonomous watch loop over GitLab issues
vanguard watch --source gitlab --spec-label "ready for spec" --agent-label "ready for agent" Loop v1 (spec pass + agent pass)
vanguard review-mr --mr 42 --gitlab-project g/p Single-MR AI review
vanguard watch-mrs --gitlab-project g/p --label "ready for review" MR review watch loop
vanguard doctor-mrs --gitlab-project g/p Preflight check for MR watch setup

New files

  • src/gitlab-labels.ts — 5 scoped label constants
  • src/tasks/gitlab.tsGitLabTaskFetcher implements TaskFetcher, encodeProject, issueIID, editGitlabLabels, commentGitlabIssue, linkMergeRequest
  • src/runners/gitlab.tsrunGitlabIssue, gitlabDepsFromEnv
  • src/runners/mr-review.tsparseMergeRequestRef, fetchMergeRequestForReview, reviewMergeRequest, SHA-based dedup marker
  • src/runners/mr-watch.tsgitlabMergeRequestWatchPrimitives, watchMergeRequests
  • src/cli/review-mr.ts, watch-mrs.ts, doctor-mrs.ts — thin CLI handlers

Modified files

  • src/pipeline/pipeline.tsPublishOptions.cli?: 'gh' | 'glab'; backward-compatible
  • src/runners/watch.tsgitlabWatchPrimitives, gitlabSpecPrimitives, watchGitlab, watchGitlabLoopV1
  • src/cli/args.tsWatchSource += 'gitlab'; new command kinds + flags
  • src/cli/preflight.tsgitlabAuthOk, gitlabLabelsOk, GitLab preflight wiring
  • src/cli/run.ts, watch.ts, index.ts — dispatch wiring
  • src/index.ts — all new symbols exported

Key design decisions

  • glab CLI only — no direct REST; consistent with gh pattern
  • Scoped labelsvanguard::running / vanguard::review / vanguard::speccing are mutually exclusive within their :: scope; GitLab auto-removes the old one on review, explicit remove only on claim
  • No separate --source gitlab-board — GitLab boards are label-based; use --source gitlab --label <column-label>
  • publishForReview extended with cli?: 'gh' | 'glab'glab mr create uses --source-branch/--target-branch/--description instead of --head/--base/--body
  • watch-mrs/doctor-mrs — separate from watch-prs to avoid naming contradiction
  • Loop v1 — same routing label defaults as GitHub (ready for spec, ready for agent, needs info)

Workflow notes (for reviewer)

.github/workflows/ was not modified. No workflow changes are needed — the GitLab commands run locally / in CI against the GitLab API via glab. If the repo ever adds a GitLab CI job that invokes vanguard watch --source gitlab, the GITLAB_TOKEN env var is all that's needed (glab picks it up automatically).

Test plan

  • pnpm typecheck — clean
  • pnpm test — 647/647 passing (63 test files)
  • vanguard doctor-mrs --gitlab-project <your-project> — run against a real GitLab project to confirm preflight
  • vanguard run --gitlab <project>#<iid> — smoke test against a real issue
  • vanguard watch-mrs --gitlab-project <p> --label "ready for review" --once — confirm MR polling + dedup

🤖 Generated with Claude Code

@pawelkrystkiewicz pawelkrystkiewicz removed the ready for vanguard review Trigger a Vanguard PR review label Jun 24, 2026
pawelkrystkiewicz and others added 6 commits June 24, 2026 14:46
Add `project` field to the run command type and capture `--gitlab-project`
flag when source is gitlab. Pass cmd.project to gitlabDepsFromEnv instead
of cmd.repoSlug so --gitlab-project overrides git remote auto-detect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ure comment, watch-mrs preflight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pawelkrystkiewicz pawelkrystkiewicz added the ready for vanguard review Trigger a Vanguard PR review label Jun 24, 2026
@github-actions github-actions Bot added vanguard:reviewing Vanguard PR review in progress and removed ready for vanguard review Trigger a Vanguard PR review labels Jun 24, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vanguard Review

GitHub's issue onFailure also only comments (no label restore), so the gitlab watch matches — not a finding. Now let me examine the loop-v1 validation branch in args.ts, which the diff comment suggests may reject gitlab.

@github-actions github-actions Bot added vanguard:reviewed Vanguard PR review posted and removed vanguard:reviewing Vanguard PR review in progress labels Jun 24, 2026
@pawelkrystkiewicz pawelkrystkiewicz added ready for vanguard review Trigger a Vanguard PR review and removed vanguard:reviewed Vanguard PR review posted labels Jun 24, 2026
@github-actions github-actions Bot removed the ready for vanguard review Trigger a Vanguard PR review label Jun 24, 2026
@github-actions github-actions Bot removed the ready for vanguard review Trigger a Vanguard PR review label Jun 24, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vanguard Review

Review: GitLab task layer (PR #176)

I focused on correctness and behavioral regressions in the new GitLab code paths. Three actionable findings, plus minor notes.

1. parseMrList re-filters on labels client-side — can silently drop every MR (medium confidence, high impact)

src/runners/mr-watch.ts:

const candidates = parseMrList(await glab(listArgs), opts.project, opts.label, opts.author);
// ...
if (!labels.includes(triggerLabel)) return [];

The list call already constrains server-side with --label opts.label, so this client re-filter is redundant. Worse, it's a single point of total failure: if glab mr list --output json emits labels in any shape other than a flat string[] whose entries exactly equal the trigger label (e.g. label objects {name}, or the field is absent/named differently), labels falls back to [], labels.includes(triggerLabel) is always false, and listReady() returns nothing on every poll — the watch loop appears healthy but never reviews anything.

The issue path (GitLabTaskFetcher.list) does not re-filter and relies solely on the server --label, so the two paths are inconsistent. Recommend dropping the labels.includes(triggerLabel) guard (server already filtered) or hardening it against non-string label shapes. Note the unit test only feeds hand-built JSON with labels: ['ready for review'], so it can't catch a real glab output-shape mismatch.

2. preflight gitlabLabelsFor reads cmd.specClaimedLabel, but the field is specClaimedState (verify)

src/cli/preflight.ts:

cmd.specClaimedLabel ?? GITLAB_DEFAULT_SPEC_CLAIMED_LABEL,

The watch command kind in args.ts defines the loop-v1 field as specClaimedState, not specClaimedLabel (see the existing union: specClaimedState?: string;). If this is genuinely a different/undefined property, the branch always falls through to GITLAB_DEFAULT_SPEC_CLAIMED_LABEL, so a user-supplied --spec-claimed-state is never validated by doctor. (The plan version used (cmd as any).specClaimedLabel, which would have masked this; the implemented version accesses it directly.) Please confirm the property name — if it's specClaimedState, the preflight check validates the wrong label.

3. runGitlabIssue refreshes retrospective memory unconditionally and without a task id (low/medium)

src/runners/gitlab.ts:

} finally {
  await refreshRetrospectiveMemory(deps.repoPath).catch(...);
  await disposeContext(ctx);
}

This diverges from runGithubIssue, which calls refreshRetrospectiveMemory(deps.repoPath, ctx.taskId) only on the successful-MR path. Here it runs in finally, so it also fires on the no-changes/early-return path and on failures, and omits the ctx.taskId argument the GitHub runner passes. If the intent was parity, this records memory for runs that produced nothing and may key it differently. Worth confirming this behavioral difference is deliberate.

Minor / non-blocking

  • MR dedup degrades silently when sha is empty. hasExistingReviewForHead returns false whenever item.sha === '', so an MR whose head SHA can't be resolved (even after the mr view enrichment) is re-reviewed on every poll. It's logged, so acceptable — flagging for awareness.
  • fetchMergeRequestForReview webUrl fallback is '' (the plan used a constructed gitlab.com/... URL). With an empty web_url, the review prompt's URL: line is blank. Cosmetic.
  • isAutomationAuthor uses GitHub's [bot] convention, which doesn't match GitLab bot usernames (project_N_bot / group_N_bot). The vanguard prefix + --author filter cover the main case, but GitLab service accounts won't be excluded.

No injection or secret-exposure issues found: all glab invocations pass arguments as arrays through execa (no shell), and untrusted issue/MR/error text is only ever passed as -m <value> positional args.

@github-actions github-actions Bot added vanguard:reviewed Vanguard PR review posted and removed vanguard:reviewing Vanguard PR review in progress labels Jun 24, 2026
The server-side `--label` flag already constrains glab mr list output.
The client-side `labels.includes(triggerLabel)` guard was redundant and
fragile: if glab returns labels in a non-string shape the filter would
silently drop every MR and listReady() would return nothing on every poll.

Drop the guard and the now-unused triggerLabel parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pawelkrystkiewicz pawelkrystkiewicz added ready for vanguard review Trigger a Vanguard PR review and removed vanguard:reviewed Vanguard PR review posted labels Jun 24, 2026
@github-actions github-actions Bot added vanguard:reviewing Vanguard PR review in progress and removed ready for vanguard review Trigger a Vanguard PR review labels Jun 24, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vanguard Review

I reviewed the diff focusing on correctness, security, and regressions. One blocking finding, plus a couple of lower-confidence issues worth verifying.

Blocking

vanguard run --gitlab group/project#42 ignores the project in the ref

The PR's headline command and the example file both document the ref form group/project#42 ("full project path + IID"). But the project segment of that ref is never parsed out — only the IID is used.

Trace:

  • src/cli/args.ts: for run, cmd.id = 'group/project#42' and cmd.project is set only when --gitlab-project is passed (...(typeof values['gitlab-project'] === 'string' && picked[0] === 'gitlab' ? { project: ... } : {})).
  • src/cli/run.ts runGitlab: gitlabDepsFromEnv(cmd.repoPath, cmd.project, ...). With cmd.project undefined, gitlabDepsFromEnv falls back to auto-detecting the project from the git origin remote.
  • src/runners/gitlab.ts runGitlabIssue: new GitLabTaskFetcher(deps.project).fetch(issueRef). The fetcher uses deps.project for the --repo flag and issueIID(issueRef)42. The group/project prefix in the ref is silently discarded.

Consequences:

  • vanguard run --gitlab group/project#42 (no --gitlab-project) fetches issue 42 from whatever origin resolves to, not from group/project.
  • parseGitlabProjectFromRemote matches GitHub SSH/HTTPS remotes too (git@github.com:owner/repo.gitowner/repo), so in a GitHub-origin checkout this runs glab against the GitHub slug instead of failing clearly.

No test covers this — parseCli(['run','--gitlab','owner/project#42']) only asserts cmd.id, and no test exercises the fetch path.

Fix: derive the project from the ref prefix when --gitlab-project is absent — e.g. in runGitlab/gitlabDepsFromEnv, if cmd.project is undefined and cmd.id contains a /…# prefix, use that prefix as the project (falling back to remote auto-detect only for a bare-number ref).

Worth verifying (lower confidence)

preflight gitlabLabelsFor may reference a non-existent field

src/cli/preflight.ts reads cmd.specClaimedLabel ?? GITLAB_DEFAULT_SPEC_CLAIMED_LABEL, but the watch Command type appears to expose this as specClaimedState (consistent with the GitHub path and the specClaimedState?: string field in args.ts). If the field is actually specClaimedState, then either this wouldn't typecheck, or specClaimedLabel is always undefined and a user-supplied spec-claimed state is ignored by the loop-v1 GitLab label preflight (it would silently check the default vanguard::speccing). Please confirm the field name matches args.ts.

glab flag compatibility is unverified by tests

All tests inject fake glab runners, so the real CLI surface is never exercised. A few flags are worth confirming against the installed glab version before relying on them in CI/E2E:

  • glab issue view … --output json, glab issue list … --output json, glab mr view … --output json, glab label list … --output json (older glab used -F/--output-format / -F json).
  • glab issue update … --label/--unlabel and glab mr update … --label/--unlabel.

These are out of scope for a diff-only check but are the most likely real-world break points given the "no direct REST" design.

Minor / non-blocking

  • src/runners/mr-watch.ts onFailure posts a plain failure note (no dedup marker) and restores the trigger label, so a persistently-failing MR accumulates one failure note per poll. Consider deduping or rate-limiting failure notes.
  • When glab mr view yields no sha, dedup is disabled and the MR is re-reviewed every poll (correctly logged). Acceptable, but worth surfacing in docs since it produces repeated review notes.

@github-actions github-actions Bot added vanguard:reviewed Vanguard PR review posted and removed vanguard:reviewing Vanguard PR review in progress labels Jun 24, 2026
…sent

`vanguard run --gitlab group/project#42` without --gitlab-project was
silently ignoring the group/project prefix and falling back to the git
origin remote for project detection, fetching issue 42 from the wrong
project in mixed-remote checkouts.

Parse the project from the ref prefix (`text before #`) and pass it to
gitlabDepsFromEnv, falling back to origin remote detection only for
bare-IID refs (no `#` present).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pawelkrystkiewicz pawelkrystkiewicz added ready for vanguard review Trigger a Vanguard PR review and removed vanguard:reviewed Vanguard PR review posted labels Jun 24, 2026
@github-actions github-actions Bot added vanguard:reviewing Vanguard PR review in progress and removed ready for vanguard review Trigger a Vanguard PR review labels Jun 24, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vanguard Review

I reviewed the implemented code in the diff (treating the 2,494‑line plan doc as non‑shipping context). Findings below, strongest first.

Findings

1. MR review dedup silently caps at 100 notes (duplicate reviews / wasted spend) — Medium

src/runners/mr-watch.ts, hasExistingReviewForHead:

const out = await glab(['api', `projects/${encodeProject(item.project)}/merge_requests/${item.iid}/notes?per_page=100`]);

Only the first page of notes is fetched, with no --paginate and no explicit sort. The SHA‑based review marker is what prevents re‑reviewing the same head commit. On an MR with more than 100 non‑system notes (busy MRs are exactly the ones that get re‑polled often), if the page returned does not contain the marker, listReady will re‑emit the MR every poll, posting a fresh ## Vanguard Review note each tick and burning LLM budget. Please paginate, or explicitly request newest‑first (?sort=desc&per_page=100) and document the bound. The same 100‑note cap exists for issue notes in src/tasks/gitlab.ts (/issues/${iid}/notes?per_page=100), though that path is less marker‑sensitive.

2. Preflight requires vanguard::* state labels that GitLab auto‑creates on first use — Medium

src/cli/preflight.ts, gitlabLabelsFor + gitlabLabelsOk. For watch-mrs/doctor-mrs the required set is [label, reviewingLabel, reviewedLabel], and for gitlab issue watch it includes claimedState/reviewState/specClaimedLabel. But nothing in the flow pre‑creates these — GitLab materializes a scoped label (vanguard::running, vanguard::reviewing, …) the first time it is applied via glab ... --label. So on any fresh project doctor-mrs will report gitlab labels: missing vanguard::reviewing, vanguard::reviewed and watch-mrs will hard‑fail its own preflight (watchMrsCommand throws on !report.ok) even though the labels would be created automatically on the first claim. Only the user‑authored trigger label should be required; the Vanguard‑owned state labels should not be preflight‑gated (or should be created up front). This contradicts the PR's stated "GitLab auto‑removes the old one" design premise.

3. Unverified glab flag/output assumptions will fail silently at runtime — Medium (verify)

Several calls assume glab supports --output json on subcommands and that list output is uncapped:

  • gitlabLabelsOk: glab label list --repo <p> --output json (no limit). gh side uses --limit 200; if glab label list paginates/caps or doesn't accept --output json, the parse yields a short/empty set and preflight reports valid labels as "missing".
  • GitLabTaskFetcher.fetch: glab issue view <iid> --repo <p> --output json — assumes view supports JSON and that labels come back as string[] (GitLab can return label objects depending on version; toGitLabTask would then map non‑strings into task.labels).
  • fetchMergeRequestForReview: relies on glab mr view --output json exposing sha, source_branch, target_branch.

None of these are exercised by the unit tests (all use fakes), so a flag mismatch ships green. Please confirm against the pinned glab version, ideally with one integration smoke test, before merge.

4. Raw error text posted to GitLab issues/MRs — Low (info exposure)

src/runners/watch.ts (onFailure: ... \Vanguard run failed: ${String(error)}`), src/runners/gitlab.ts/mr-watch.tssimilarly.execarejections stringify the full command line and stderr; agent/provider failures can include file paths or upstream API response bodies. This goes into a project‑visible note. Consider truncating/sanitizing (e.g.error.shortMessageor a capped message) rather than dumpingString(error)`. Mirrors existing GitHub behavior, so not a regression, but worth tightening for the new public‑comment paths.

5. parseGitlabProjectFromRemote accepts any git host → wrong‑host run — Low

src/runners/gitlab.ts. The regex parses GitHub/Bitbucket/etc. remotes just as readily as GitLab. With run --gitlab 42 (bare IID, no --gitlab-project) in a repo whose origin is GitHub, gitlabDepsFromEnv resolves a project from the GitHub remote and proceeds to call glab against it. A bare‑IID gitlab run with a non‑GitLab origin should error rather than guess. Minor, but cheap to guard.

6. Minor: draft/automation filtering depends on glab mr list JSON shape — Low (verify)

parseMrList drops drafts via item.draft === true. If glab mr list --output json represents draft state differently (e.g. work_in_progress, or only the Draft: title prefix) in the target version, draft MRs would pass the filter and get auto‑reviewed. Confirm the field name; consider also checking a Draft: title prefix as a fallback.

Non‑blocking notes

  • editGitlabLabels guard if (args.length > 5) correctly no‑ops when both add/remove are empty (base args length is 5) and matches the test. Good.
  • All glab invocations use array args via execa (no shell) — no command injection. Good.
  • listReady enrichment in mr-watch.ts does up to 2 extra glab calls per candidate MR (view for sha + notes for dedup); bounded by ready count, acceptable, but note it's an N+1 against the list.

Items 1–3 are the ones I'd want resolved (or explicitly verified against a real glab) before merge.

@github-actions github-actions Bot added vanguard:reviewed Vanguard PR review posted and removed vanguard:reviewing Vanguard PR review in progress labels Jun 24, 2026
…gination

- preflight: only check user-owned trigger/routing labels; Vanguard state
  labels (vanguard::running, vanguard::review, vanguard::reviewing, etc.)
  are scoped :: labels that GitLab auto-creates on first apply, so requiring
  them to pre-exist causes doctor/watch-mrs to fail on fresh projects
- preflight: add --per-page 100 to glab label list (default is 30)
- mr-watch: add sort=desc&order_by=created_at to notes query so the
  most-recent notes (where the SHA dedup marker lives) are in page 1
- gitlabDepsFromEnv: reject github.com/bitbucket.org/dev.azure.com remotes
  when falling back to origin auto-detection to avoid silently running glab
  against a non-GitLab host

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pawelkrystkiewicz pawelkrystkiewicz added ready for vanguard review Trigger a Vanguard PR review and removed vanguard:reviewed Vanguard PR review posted labels Jun 24, 2026
@github-actions github-actions Bot added vanguard:reviewing Vanguard PR review in progress and removed ready for vanguard review Trigger a Vanguard PR review labels Jun 24, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vanguard Review

Review: GitLab task layer (PR #176)

I reviewed the implementation files (not the plan doc) for correctness, security, and regressions. The shell-out surface is safe — every glab/git/gh call uses execa with an argument array (no shell interpolation), so label names, comment bodies, and agent output can't inject commands. Label state-machine logic in the watch/spec/MR primitives is internally consistent, and the preflight refactor correctly gates the GitHub/Linear blocks behind isLoopCommand so watch-mrs/doctor-mrs don't accidentally run GitHub checks.

A few non-blocking findings worth addressing:

1. glab argument vectors are never exercised by tests (test-coverage gap)

Every unit test injects a fake GlabRunner, so the actual glab flags are unverified — including glab issue view … --output json, glab issue list --output json, glab label list … --output json (src/cli/preflight.ts gitlabLabelsOk), glab mr diff, and glab mr view … --output json. The PR's own test plan leaves all three real-glab smoke tests unchecked. If any of those flag spellings are wrong (glab historically uses -F/--output, and --output json support varies by subcommand/version), the feature fails at runtime with green CI. Recommend running at least one real vanguard run --gitlab and doctor-mrs against a live project before merge, as the plan intended.

2. isAutomationAuthor over-broad prefix match

src/runners/mr-watch.ts:

function isAutomationAuthor(username: string): boolean {
  const lower = username.toLowerCase();
  return lower.startsWith('vanguard') || ...
}

Any human whose username merely starts with vanguard (e.g. vanguard-dev) is silently excluded from watch-mrs. Consider an exact match against the configured bot account, or make it configurable. (Note: this also drifted from the plan's includes('vanguard') — fine, but worth a deliberate choice.)

3. Note pagination caps dedup/comment retrieval at 100

GitLabTaskFetcher.fetch (projects/.../issues/{iid}/notes?per_page=100) and hasExistingReviewForHead (merge_requests/{iid}/notes?per_page=100) fetch only the first page. For the dedup check the sort=desc ordering makes a recent marker likely to land on page 1, so it's usually fine — but on a long-lived MR an older marker could fall off, causing a redundant re-review. For fetch, issues with >100 comments silently drop context handed to the agent. Low impact; flag if long threads are expected.

4. refreshRetrospectiveMemory call differs from the GitHub runner

src/runners/gitlab.ts calls refreshRetrospectiveMemory(deps.repoPath) (single arg) inside finally, so it runs even when no commit was produced — whereas the GitHub runner (per the plan) calls it with ctx.taskId only after a successful commit. Worth confirming this is intentional and that the one-arg signature is correct (and that refreshing memory on a no-op run is desired).

5. watch-mrs claim can't detect a concurrent claim (cross-process)

gitlabMergeRequestWatchPrimitives.claim just edits labels, which never throws for an already-claimed MR, so watchMergeRequestsOnce's catch → skipped branch can't fire across processes — two watchers could both review the same MR. The SHA-marker dedup in listReady mitigates within a poll, and this mirrors the existing PR-watch pattern, so it's not a regression. Mentioning for awareness.

None of these prevent merge.

No blocking findings.

@github-actions github-actions Bot added vanguard:reviewed Vanguard PR review posted and removed vanguard:reviewing Vanguard PR review in progress labels Jun 24, 2026
@pawelkrystkiewicz pawelkrystkiewicz merged commit a8c9061 into main Jun 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

vanguard:reviewed Vanguard PR review posted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants