Files
kandev/docs/specs/gitlab-integration/plan.md
T
João Salavisa 68aeefa296 feat(gitlab): add GitLab integration with MR / discussions support (#861)
* feat(gitlab): add GitLab integration with MR / discussions support

Adds a GitLab integration parallel to the GitHub one: PAT and glab CLI auth,
self-managed host configuration, MR feedback (approvals + discussions +
pipelines), and discussion reply / resolve so agents can address review
comments. The pr skill detects the host from git remote and creates a real
GitLab merge request via glab or REST.

Closes #820

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(gitlab): address review feedback and tighten lint surface

- Add service_test.go covering ConfigureToken / ClearToken / ConfigureHost.
- Drop speculative MRWatch / TaskMR / ReviewWatch / IssueWatch / preset
  scaffolding that had no migrations or handlers behind it.
- Consolidate /approvals into one round-trip via fetchApprovals.
- Use http.DefaultClient in CheckHost (context already carries the deadline).
- Rename misleading id → configured in ConfigureToken.
- Introduce mrStateOpen constant so the lint goconst rule passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): address PR review feedback (CodeQL SSRF, races, atomicity)

- connectivity: parse host with url.Parse, validate http/https scheme,
  build probe URL via &url.URL{} so user-controlled host can't smuggle
  into the request — clears the CodeQL go/request-forgery alert.
- pat_client: guard cached username with sync.RWMutex; IsAuthenticated
  now propagates non-401/403 errors instead of masking transport
  failures; GetMRStatus skips pipelines query when HeadSHA / HeadBranch
  are both empty; parseNextLink drops cross-host links instead of
  returning bare absolute URLs that get() would double-prefix.
- service: findTokenSecret returns (bool, string, error) and all
  callers propagate the secrets.List error so a backend outage isn't
  mistaken for "token absent"; ConfigureHost persists to hostStore
  before mutating in-memory host + client + authMethod under one lock.
- repoclone: SSH clone URL falls back to ssh:// form when the host has
  a port (scp-style git@host:path can't carry one).
- gitlab/client_helpers: namespace fallback uses LastIndex("/") so
  subgroup paths (acme/team/squad/repo) are preserved.
- pr skill / plan doc: drop hardcoded --target-branch main, derive host
  from git remote instead of $CI_SERVER_URL, build curl payload with
  jq -n; fix /pipelines/:id/jobs → :pipeline_id typo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): preserve host base path in CheckHost; broaden validation; test

- connectivity: extract validateHost so the parse / scheme / hostname /
  userinfo / opaque-URL checks each have a single home. Preserve the
  configured base path (TrimRight slash) so a host like
  https://gitlab.acme.corp/gitlab probes /gitlab/api/v4/version instead
  of dropping the prefix.
- connectivity_test: cover validateHost (default, https, http, trailing
  slash, subpath, missing scheme, ftp, empty host, userinfo) and
  CheckHost end-to-end (version endpoint, base-path preservation,
  scheme rejection, unreachable host).
- pr skill: drop the broken `glab repo view --json defaultBranch`
  branch — glab doesn't accept --json, so the lookup silently fell
  back to "main" every time. Fetch default_branch via the GitLab
  REST API directly using the PAT we already have.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): override filter defaults, refresh auth uncached, mock pipelines

- appendFilter: Del before re-Add so user-supplied filter keys actually
  override caller defaults (e.g. state=closed swaps the seeded state=opened
  instead of appending a second value GitLab silently ignored).
- IsAuthenticated: probe /user directly instead of routing through the
  username cache; a token revoked upstream now surfaces as not-connected
  immediately. Cache is opportunistically refreshed on success.
- MockClient.GetMRFeedback: include pipeline state in HasIssues, matching
  the real PATClient behavior so test flows can't miss a broken MR.
- pr skill: document `glab mr note create --reply <id>` for replies (bare
  `glab mr note` opens a new thread); fix host-extraction block to handle
  ssh://host:2222/team/repo.git without leaking the port into PROJECT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): guard mock pipelines on empty MR head; preserve HTTPS port in pr skill

- MockClient.GetMRFeedback now mirrors PATClient: skip the pipelines
  fetch when HeadSHA and HeadBranch are both empty. The mock'\''s
  ListPipelines ignores its branch argument and returns every pipeline
  seeded under the project, so without this guard a brand-new MR would
  inherit a sibling MR'\''s failing pipeline and flip HasIssues to true.
  Tests cover both the empty-head and head-present cases.
- pr skill: derive HOST scheme-aware so https://host:8443 keeps the port
  (HTTPS API endpoint), ssh://host:2222 strips it (different transport),
  and scp-style git@host stays portless. Avoids silently re-routing
  self-managed API calls to the default 443.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): export SeedPipelines; mock GetMRStatus parity; drain bodies; test cache hit

- MockClient: add exported SeedPipelines(project, iid, []) alongside the
  other Seed* methods; mirror PATClient.GetMRStatus by populating
  PipelineState / jobs counts via summarizePipelines, guarded on a
  non-empty head ref. Brings mock parity with the real client so any
  future test seeding pipelines sees the same MRStatus shape.
- PATClient HTTP plumbing: drain resp.Body via io.Copy(io.Discard, ...)
  when result == nil in both getWithTotal and doWrite, so HTTP/1.1 can
  reuse the connection instead of opening a new TCP socket per call.
- TestPATClient_GetAuthenticatedUser: assert calls == 1 after the second
  GetAuthenticatedUser hit so the cache contract is actually verified
  (a non-caching implementation would have passed the prior version).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): surface connection errors, align MR pipeline guards, fix mock seed key

- service.GetStatus now captures the IsAuthenticated error into a new
  Status.ConnectionError field (empty on healthy / 401-403, populated on
  network / 5xx / parse failures). Frontend GitLabStatus + settings page
  render a distinct "Unreachable" badge + alert so users don't delete a
  still-valid token during a transient GitLab outage.
- PATClient.GetMRFeedback now guards on HeadSHA || HeadBranch, matching
  GetMRStatus and MockClient.GetMRFeedback (which had been updated in an
  earlier round). The three call sites were inconsistent — a mock test
  with HeadBranch-but-no-SHA would pass while the real client silently
  dropped pipelines.
- MockClient.pipelines is now keyed by project alone (was
  map[mockMRKey][]Pipeline). SeedPipelines takes (project, []) so
  successive seeds for the same project deterministically overwrite,
  instead of leaving multiple entries that ListPipelines would resolve
  in Go's unspecified map-iteration order.
- Tests: TestService_GetStatus_{PopulatesConnectionErrorOn5xx,
  ClearsConnectionErrorOn401} cover the new error surface;
  TestMockClient_SeedPipelines_OverwritesByProject locks the key change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): tighten validateHost; init Status scopes; close token-host race

- validateHost: drop the parsed.Opaque != "" branch — Go only sets
  Opaque for opaque URIs, which have an empty Host the prior check
  already rejects. Narrow the userinfo error to mention the actual
  user:password@host shape so the message matches what users see.
- service.GetStatus: initialize RequiredScopes: []string{} on the
  nil-client defensive branch so the JSON payload keeps the
  always-an-array contract declared on the TypeScript side. The branch
  is effectively unreachable (every Provide path returns at least a
  NoopClient) but the empty-slice fallback costs nothing.
- service.ConfigureToken: build the installed client inside the write
  lock using the up-to-date s.host instead of the pre-probe snapshot.
  A concurrent ConfigureHost between RUnlock and Lock used to leave
  s.client pointing at the prior host while s.host advertised the new
  one — every subsequent API call would silently target the wrong
  instance until the next reconfigure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): drop MR-commit stats fields; rename AuthorName; log post-lock host

- MRCommitInfo: drop Additions / Deletions / FilesChanged. GitLab's
  /merge_requests/:iid/commits endpoint doesn't return a stats object,
  so those fields were always 0 — better to omit them than feed callers
  silently-wrong numbers. Rename AuthorUsername to AuthorName since the
  API returns a display name, not a login handle. Doc-comment records
  the per-commit endpoint to use if a future revision wants real stats.
- ListMRCommits: drop the Stats anonymous struct from the JSON decode
  target now that the destination fields are gone.
- service.ConfigureToken: capture installedHost inside the write lock
  and log that value. The previous log used the pre-validation host
  snapshot, so a concurrent ConfigureHost between snapshot and lock
  would leave the log entry referring to a stale value while the
  client itself was correctly built against the new host.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): encode subgroup paths; sentinel-route errors; mock approval parity

- pat_client.SearchGroupProjects now encodes the group path through a new
  groupRef helper that %2F-substitutes slashes (mirrors projectRef). Subgroup
  paths like "acme/team" used to land at /groups/acme/team/projects, which
  GitLab routed to the parent group only and returned 404 for. Subgroups now
  work end-to-end.
- service.ErrInvalidToken sentinel + controller errors.Is routing: token
  validation failures now wrap a package-level sentinel and the HTTP layer
  switches on it via errors.Is, replacing the fragile strings.Contains
  prefix check that would silently fall through to 500 if the message ever
  changed. Coverage: TestService_ConfigureToken_WrapsErrInvalidToken.
- MockClient gains seedable approvals: SeedApprovals(project, iid, list,
  required) backs a dedicated approvals + requiredApprovals map.
  ListMRApprovals, GetMRFeedback, and GetMRStatus all consume them so
  ApprovalState / ApprovalCount / RequiredApprovals match what the real
  PATClient.GetMRStatus computes. Coverage:
  TestMockClient_GetMRStatus_UsesSeededApprovals.
- ClearToken: same host-snapshot race ConfigureToken was fixed for last
  round — NewClient now runs under the write lock against s.host rather
  than a pre-acquired snapshot.
- CheckHost: drain resp.Body before close so http.DefaultClient's keep-alive
  pool can reuse the connection for the next probe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): full-path projectRef round-trip; unlock during glab probe

- splitFullReference now returns (namespace, fullPath) where fullPath is
  the namespace-qualified project path ("group/sub/project") instead of
  the last segment alone. Callers can pass MR.ProjectPath straight to
  projectRef for round-trip API calls (GetMR, GetMRFeedback, etc.) —
  matches GitLab's own path_with_namespace convention. Existing test
  cases updated.
- ClearToken: build the fallback client OUTSIDE the write lock so
  NewClient's glab subprocess (bounded by glabAuthTimeout = 10s)
  doesn't freeze every concurrent GetStatus / GetMRFeedback /
  Client() RLock reader for the duration. Brief WLock now only wraps
  the field swap. Tradeoff with the concurrent-ConfigureHost race is
  documented in-code: narrow stale-host window vs. 10s of blocked
  readers, and the next reconfigure resolves it.
- New regression test TestPATClient_SearchGroupProjects_EncodesSubgroupSlash
  asserts the request URL is /groups/acme%2Fteam/projects so a future
  refactor that swapped groupRef back to a bare url.PathEscape would
  fail visibly instead of silently 404ing subgroup lookups.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): equality-guard ClearToken's client swap against ConfigureHost race

ClearToken builds the fallback client outside the write lock to avoid
blocking RLock readers for up to 10s while glab auth-probes. The
swap-only critical section now compares s.host against the pre-probe
snapshot before assigning — if a concurrent ConfigureHost moved the
host underneath us, we leave its (newer, correctly-targeted) client
alone instead of overwriting with our stale-host build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): add GitLab to the settings integrations sidebar

The /settings/integrations/gitlab page existed and the index card listed
GitLab alongside the other integrations, but the settings sidebar nav
(IntegrationsNavGroup) only listed GitHub / Jira / Linear / Slack. The
page was unreachable through the standard sidebar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(gitlab): task-MR topbar surface + DB-backed task↔MR store

Phase 1 of the GitLab UI build-out: brings the MR side of the task topbar
to parity with the existing GitHub PR button.

Backend
- gitlab_task_mrs table + Store wiring (UpsertTaskMR,
  ListTaskMRsByTask, ListTaskMRsByWorkspaceID, DeleteTaskMR), uniqued
  on (task_id, repository_id, project_path, mr_iid) for multi-repo.
- service.SyncTaskMR folds a live GetMRStatus probe into an upsert;
  ListTaskMRsByTask / ListTaskMRsByWorkspace expose the rows.
- Three HTTP endpoints: GET /workspaces/:id/task-mrs, GET
  /tasks/:taskID/mrs, POST /tasks/:taskID/mrs/sync.
- initGitLabService now takes a *db.Pool and constructs gitlab.Store.
  Failures non-fatal: integration keeps working without the table.

Frontend
- TaskMR type + listWorkspaceTaskMRs / listTaskMRs / syncTaskMR API.
- lib/state/slices/gitlab slice (taskMRs.byTaskId) wired through
  default-state, store.ts AppState, and slices/index exports.
- hooks/domains/gitlab/use-task-mr.ts: useWorkspaceMRs (fetch-on-
  workspace-switch) and useTaskMRs (per-task selector).
- components/gitlab/mr-topbar-button.tsx — single/multi MR variants,
  status icons mirroring pr-task-icon priority order.
- task-top-bar.tsx renders PRTopbarButton and MRTopbarButton side-by-
  side; each returns null when empty, so the union handles GitHub-only,
  GitLab-only, and multi-repo tasks without an explicit switch.
- kanban-board.tsx hydrates useWorkspaceMRs alongside useWorkspacePRs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(gitlab): add /gitlab page for browsing user MRs and issues

Adds the GitLab counterpart to /github so users can browse their own
merge requests and issues from inside kandev, parallel to the existing
GitHub "My GitHub" page.

Backend
- /api/v1/gitlab/user/mrs and /user/issues — paginated wrappers around
  the existing SearchMRsPaged / ListIssuesPaged client methods.
  paginationFromQuery clamps page/per_page the same way as the client.

Frontend
- /gitlab SSR page + client with MR/Issue tabs, scope filters
  (assigned / created / review-requested), and a free-text search box
  that falls back to raw GitLab query-string for power users.
- searchUserMRs / searchUserIssues API helpers + MR / Issue / *SearchPage
  types in lib/types/gitlab.
- useGitLabAvailable hook (probes /status + refreshes on window focus)
  feeds the integrations menu so the GitLab link only shows when a
  token is configured or auth is green.

Tests
- task-top-bar.test mocks the new MRTopbarButton so renders no longer
  pick up useAppStore without a StateProvider.
- integrations-menu.test wires the new useGitLabAvailable mock and
  gitlabReady prop into mockAvailability / getAvailableIntegrationLinks
  fixtures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): stable empty array in useTaskMRs to avoid infinite re-render

useTaskMRs returned `state.taskMRs.byTaskId[taskId] ?? []` — that fresh
literal made the zustand selector produce a new reference on every
render when the task had no MRs, triggering an infinite re-render loop
in MRTopbarButton that crashed the task page with a Next.js client-side
exception.

The page never mounted session-chat, so Docker E2E tests that drive
`/t/:id` (slow-bootstrap progress, external-stop resume, environment
reset) all timed out waiting for the chat panel.

Mirror the GitHub side's `EMPTY_PRS` pattern: hoist a module-level
`EMPTY_MRS = []` and return it for the empty case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): address PR review — cache header, in-flight cancel, tests

- Top-level `cache: "no-store"` in 5 sites (use-task-mr.ts, gitlab-api.ts,
  gitlab-page-client.tsx). The earlier `init: { cache: "no-store" }`
  placement was silently overwritten by fetchJson with undefined, so probes
  hit the browser cache instead.
- useWorkspaceMRs now invalidates in-flight responses and calls
  resetTaskMRs when the workspace becomes null, so the previous workspace's
  MRs don't linger after sign-out / workspace switch.
- Drop unpopulated UnresolvedThreads/DiscussionCount from TaskMR (model,
  schema, TS type). SyncTaskMR's source MRStatus never populated them and
  no surface reads them; the dropped columns are skipped via an explicit
  projection in store.go so existing dev DBs keep binding.
- Extract MRList/IssueList inline fetches in /gitlab into useGitLabUserMRs
  / useGitLabUserIssues — matches CLAUDE.md "never fetch in components".
- Tests for store (UpsertTaskMR, composite key, workspace-JOIN isolation,
  ordering, Delete), service (SyncTaskMR mock round-trip + field mapping +
  store-unset error, ListTaskMRsByWorkspace JOIN isolation, ListTaskMRsByTask),
  pat_client (ListIssuesPaged X-Total), gitlab-api (5 new fns), gitlab-slice
  (setTaskMR composite key), and the use-task-mr / use-user-search hooks.
- pr SKILL.md: skip note for steps 5–6 on GitLab remotes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(gitlab): only wrap 401/403 probe errors as ErrInvalidToken

Network failures, 5xx, and DNS errors during `ConfigureToken` previously
surfaced to the user as HTTP 400 "invalid token: …" — during a GitLab
outage a user with a valid token might delete it assuming it was rejected.
Now only `*APIError` with 401/403 is mapped to `ErrInvalidToken`; other
errors propagate unwrapped so the controller routes them to "GitLab
unreachable" instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:17:16 +01:00

346 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# GitLab Integration — Implementation Plan
**Spec:** `./spec.md`
**Issue:** [#820](https://github.com/kdlbs/kandev/issues/820)
## 1. Architecture decisions
**One package, mirror don't merge.** A new `internal/gitlab/` package mirrors
the layout of `internal/github/`. Do **not** introduce a unifying
`codehost.Client` interface in v1. The GitHub package is ~14k LOC and encodes
deep GitHub-specific knowledge (search query DSL, review states, mergeable
states, action presets). Premature abstraction would hurt both packages.
Defer the unifying interface to v2 — once the GitLab package is real, the
shared shape is concrete instead of guessed.
**Two clients, both shipped in v1.** Implement `pat_client.go` against
GitLab REST API v4 (Go HTTP client, similar shape to
`internal/github/pat_client.go`) **and** `glab_client.go` that shells out
to the `glab` CLI (similar shape to `internal/github/gh_client.go`).
`factory.go` selects: mock env var → `glab` CLI if installed and
authenticated → PAT (env var, then secrets store) → noop. Same selection
order as the GitHub package so users with `glab` already configured pick
up zero-config, and PAT remains the path for headless / corp-managed
environments.
**Auth: token-based, per-workspace host.** Token in `secrets.SecretStore`
under name `GITLAB_TOKEN` (env var `GITLAB_TOKEN` also accepted). Self-managed
host URL stored as a per-workspace setting (`gitlab_host`, default
`https://gitlab.com`). Per-workspace because different workspaces commonly
target different GitLab instances (work vs personal).
**Data model: parallel tables, shared field semantics.** New tables
`gitlab_mr_watches`, `gitlab_review_watches`, `gitlab_issue_watches`,
`gitlab_action_presets` parallel to their `github_*` counterparts. The
existing `Repository.Provider` field already supports `"gitlab"`; no schema
change there. PR/MR data is **not** unified — they're sibling concepts with
different fields (e.g. GitLab pipelines vs GitHub checks, GitLab approvals
vs GitHub reviews).
**`pr` skill: provider-aware, single skill.** Update `.agents/skills/pr/`
to detect the repo provider from `git remote` and branch on host. For GitLab
repos, use REST API directly via `curl`+`GITLAB_TOKEN` — no `glab`
dependency in the agent container.
## 2. File map
### Backend — new package `internal/gitlab/`
Mirrors `internal/github/` (~40 files) but deliberately smaller in v1. Files
to create:
- `models.go` — `MR`, `MRReview` (approvals), `MRComment`, `Pipeline`,
`MRFeedback`, `MRStatus`, `MRSearchPage`, `Issue`, `IssueSearchPage`,
`Project`, `RepoBranch`, `MRWatch`, `ReviewWatch`, `IssueWatch`. Shape
parallel to `github/models.go` so frontend stores can share component
families.
- `client.go` — `Client` interface (parallel to GitHub's, but with
GitLab nouns: `GetMR`, `FindMRByBranch`, `ListAuthoredMRs`,
`ListReviewRequestedMRs`, `SubmitMRApproval`, `ListPipelines`,
`GetMRDiscussions`, `ResolveDiscussion`, `CreateMRDiscussionNote`).
- `pat_client.go` — REST v4 implementation. Per-instance host URL.
Uses `PRIVATE-TOKEN` header for auth. Methods needed:
- Projects: `/projects?membership=true`, `/projects/:id`, `/projects/:id/repository/branches`.
- MRs: `/projects/:id/merge_requests`, `/merge_requests` (search),
`/projects/:id/merge_requests/:iid`,
`/projects/:id/merge_requests/:iid/discussions` (read + post + resolve),
`/projects/:id/merge_requests/:iid/approvals`,
`/projects/:id/merge_requests/:iid/changes`,
`/projects/:id/merge_requests/:iid/commits`.
- Pipelines: `/projects/:id/pipelines`, `/projects/:id/pipelines/:pipeline_id/jobs`.
- Issues: `/issues` (search), `/projects/:id/issues/:iid`.
- User: `/user` (auth check + login).
- `pat_client_test.go` — table-driven tests with `httptest.Server` per
endpoint group, mirroring `github/pat_client_test.go`.
- `glab_client.go` — shells out to `glab` CLI for the same `Client`
interface. Mirrors `internal/github/gh_client.go`: `GLabAvailable()`
detects the binary, `IsAuthenticated()` runs `glab auth status`, and
each method composes a `glab api …` or `glab mr …` invocation,
parsing JSON stdout. Where `glab` lacks a direct command (e.g. specific
search filters), fall through to `glab api` against the REST endpoint.
- `glab_client_test.go` — uses `exec.LookPath` shimming and a
fake `glab` binary on `PATH` (same pattern as `gh_client_test.go`).
- `mock_client.go` + `mock_client_test.go` + `mock_controller.go` —
E2E mock surface, gated by `KANDEV_MOCK_GITLAB=true`. Uses the
`internal/integrations` shared shapes? **No** — copy the GitHub pattern
(the mock currently lives outside `internal/integrations`). Match
whichever pattern the team has standardized on (Linear/Jira use
`internal/integrations`; GitHub does not). For consistency with
GitHub-the-code-host, follow the GitHub pattern.
- `noop_client.go` — null-object fallback when unauthenticated.
- `factory.go` — `NewClient(ctx, secrets, host, log)`. Order: mock env
var → `glab` CLI (if installed and authenticated for the configured
host) → PAT (env var `GITLAB_TOKEN`, then secrets store key
`GITLAB_TOKEN`) → noop.
- `service.go` — coordinator parallel to `github.Service`. Accepts the
per-workspace host. Methods: status, MR list/search, MR feedback (incl.
discussions), watches CRUD, action presets CRUD, sync. **Use
`internal/integrations/healthpoll`** for the auth-health loop — that's
the shared utility Jira/Linear already use, and the GitHub package
predates it. New code should pick the better pattern.
- `service_*_test.go` — tests for issue, status, sync, watches, search
cache (parallel to GitHub's split).
- `service_task_events.go` — subscribes to task events and resyncs when
MRs are linked. Mirror GitHub.
- `store.go` — SQLx persistence for the four new tables + watches/presets
read/write. Migrations next.
- `store_test.go` — multi-project / multi-workspace round-trip.
- `controller.go` — HTTP routes under `/api/v1/gitlab/*`:
`GET /status`, `GET/POST/PUT/DELETE /mr-watches`,
`GET/POST/PUT/DELETE /review-watches`,
`GET/POST/PUT/DELETE /issue-watches`,
`GET /mrs/:projectId/:iid/feedback`, etc.
- `handlers.go` — WS dispatcher registrations
(`ActionGitLabStatus`, `ActionGitLabMRList`, …).
- `poller.go` — review-watch + issue-watch background loop. Reuse
`healthpoll.Poller` for auth health; keep MR/issue polling in this
package (different cadence, payload-shaped).
- `connectivity.go`, `constants.go`, `ttl_cache.go` — verbatim parallels.
- `provider.go` — `Provide(writer, reader *sqlx.DB, secrets SecretStore,
eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error)`.
### Backend — schema migrations
- `apps/backend/internal/task/repository/migrations/NNNN_gitlab_tables.sql` —
create `gitlab_mr_watches`, `gitlab_review_watches`,
`gitlab_issue_watches`, `gitlab_action_presets`. Use `gitlab_*` prefix
on every column to avoid collisions when the unifying refactor lands.
- `apps/backend/internal/workspace/...` — add `gitlab_host` column to the
workspace settings table (or, if workspace settings are JSON, add the
field to the JSON shape). Default `https://gitlab.com`.
### Backend — wiring
- `cmd/kandev/services.go` — new `initGitLabService(...)` helper that
resolves the per-workspace host (or a global default for unscoped
callers) and wires the service.
- `cmd/kandev/helpers.go` — register routes via `gitlab.RegisterRoutes`
and `gitlab.RegisterMockRoutes`.
- `internal/repoclone/protocol.go` — replace the `gitlab.com` hardcode
with a host parameter. `CloneURL` signature gains `host string`. Update
call sites in `internal/agent/lifecycle/executor_*.go` and
`internal/orchestrator/executor/executor_execute.go` to pass the
workspace's `gitlab_host` when `provider == "gitlab"`.
- `internal/agent/credentials/env_provider.go` — already lists
`GITLAB_TOKEN` in `knownAPIKeyPatterns`. No change required.
- `internal/agent/lifecycle/default_scripts.go` — extend the default
prepare script so HTTPS clones for GitLab use
`https://oauth2:${GITLAB_TOKEN}@<host>/<owner>/<name>.git`. Existing
GitHub equivalent is the model.
### Frontend — new pages and components
- `apps/web/app/gitlab/page.tsx` + `gitlab-page-client.tsx` — mirror of
`app/github/`. SSR fetches `/api/v1/gitlab/status`, hydrates store.
- `apps/web/app/settings/integrations/gitlab/page.tsx` — mirror of
`settings/integrations/github/page.tsx`. Hosts the
`GitLabIntegrationPage` panel: connection status banner, host URL
field, PAT field, reconnect CTA.
- `apps/web/components/gitlab/` — domain components. The MyGitHub
component family (`my-github/pr-list`, `my-github/issue-list`,
presets sidebar, search bar, action presets, save-preset dialog,
pagination, list toolbar, quick task launcher) is large and tightly
coupled to the GitHub data shape. Two viable approaches:
- **(A) Extract shared components.** Move generic pieces (search bar,
pagination, save-preset dialog, list toolbar, presets sidebar
skeleton) into `components/code-host/` parameterised on item shape.
Wire both `github` and `gitlab` to them. Higher upfront cost, no
duplication.
- **(B) Copy-and-adapt.** Duplicate the components folder, swap PR
types for MR types. Lower upfront cost, two parallel codebases to
maintain.
- **Recommendation: (A) for shells, (B) for body.** Extract the
container components (sidebar, toolbar, pagination, save dialog) —
they're already provider-agnostic in shape. Copy the row renderers
(`pr-row.tsx` → `mr-row.tsx`) where the visible fields differ
(approvals vs reviews, pipelines vs checks).
- `apps/web/lib/state/slices/gitlab/` — new slice mirroring
`slices/github/`. Same hydration patterns.
- `apps/web/hooks/domains/gitlab/` — `use-gitlab-status`, watches,
search, action presets. Symmetric to `hooks/domains/github/`.
- `apps/web/lib/api/domains/gitlab-api.ts` — REST client.
- `apps/web/lib/types/gitlab.ts` — `GitLabMR`, `GitLabIssue`, etc.
### Frontend — kanban top-bar
- `apps/web/components/kanban/kanban-header.tsx` — add
`GitLabTopbarButton` next to `GitHubTopbarButton`. Render only when
GitLab is configured for the active workspace (gate on
`useGitLabAvailable`).
- `apps/web/components/kanban/kanban-header-mobile.tsx` — same.
### Agent skill — `pr`
- `.agents/skills/pr/SKILL.md` — add a "GitLab repos" section
explaining the detection (`git remote get-url origin` matches
`:gitlab|/gitlab`) and the GitLab MR-creation flow. Prefer `glab` CLI
if available in the agent container; fall back to REST API via `curl`
with `GITLAB_TOKEN`. Keep `gh` as the GitHub path.
- `.claude/skills/pr/SKILL.md` symlink — already symlinked, no change.
### WebSocket actions
- `pkg/websocket/actions.go` (or wherever GitHub actions are declared) —
add `ActionGitLabStatus`, `ActionGitLabMRList`, `ActionGitLabMRGet`,
`ActionGitLabMRFeedbackGet`, watches (review/issue/MR), pipeline
status, action presets, stats.
## 3. Phases
Each phase ends with `make fmt` then `make typecheck test lint` (backend)
and `pnpm typecheck test lint` (web).
### Phase 1 — Backend client + auth (no UI)
Goal: the backend can list one user's MRs over REST.
- `internal/gitlab/{models,client,pat_client,glab_client,noop_client,
factory,connectivity,constants}.go` plus tests.
- Mock client + controller. Wired under `KANDEV_MOCK_GITLAB=true`.
- Provider + `cmd/kandev/services.go` `initGitLabService`.
- Status WS handler + `/api/v1/gitlab/status` HTTP route.
- Migration for the new tables (kept empty-but-present so future phases
can write).
- Tests: PAT client per-endpoint, noop client, factory selection,
controller status route.
### Phase 2 — Repo cloning + agent env
Goal: a GitLab repo can be cloned and the agent has the token.
- `repoclone/protocol.go` host parameter. Call-site updates.
- `lifecycle/default_scripts.go` HTTPS-with-token rewrite for GitLab.
- Workspace `gitlab_host` setting (read/write API + UI field deferred
to Phase 3).
- Tests: protocol unit test for SSH/HTTPS variants on custom host.
Lifecycle prepare-script test for token injection.
### Phase 3 — Settings page + connection UX
Goal: a user can connect/reconnect a GitLab account from the UI.
- `app/settings/integrations/gitlab/page.tsx` + panel.
- API endpoints: get/set host, set/clear token.
- Reuse `<IntegrationAuthStatusBanner>` and
`<IntegrationAuthErrorMessage>` from `internal/integrations` if those
shells fit (they were built for Linear/Jira; check shape compat first).
- Tests: settings panel render + token-save flow (vitest).
### Phase 4 — MR list page + watches
Goal: full feature parity with the GitHub page for browse + watch.
- Extract shared shells from GitHub MyGitHub components into
`components/code-host/`.
- New `components/gitlab/my-gitlab/` — row renderers, search-bar
preset definitions, action-presets resolution.
- `app/gitlab/page.tsx` + client.
- Watch CRUD WS handlers + UI.
- `gitlab` slice + hooks.
- Poller for review-watches and issue-watches (uses
`healthpoll.Poller` for auth-health; MR/issue polling in
`gitlab/poller.go`).
- Tests: store slice, hooks, save-preset, search.
### Phase 5 — MR review surface (#820 core)
Goal: the agent UI shows MR feedback (discussions, approvals, pipeline)
and the agent can post/resolve discussions.
- `GetMRDiscussions` + `CreateMRDiscussionNote` + `ResolveDiscussion`
in `pat_client.go`.
- `MRFeedback` payload + `WS ActionGitLabMRFeedbackGet`.
- Frontend: in the existing PR-feedback panel that lives next to a
task, branch on `provider`. Reuse the comments/threads UI; map
GitLab discussions onto the same shape (discussion = thread, notes =
comments, `resolved` flag).
- Tests: pat_client discussions round-trip, frontend feedback panel
with a GitLab fixture.
### Phase 6 — `pr` skill update + #820 wiring
Goal: clicking "Create PR" on a GitLab task opens a real MR.
- `.agents/skills/pr/SKILL.md` — GitLab branch.
- The kanban "Create PR" button already runs the `pr` skill on the
task; no new wiring needed. Verify the surfaced MR URL flows back
through the existing PR-watch path (Phase 4 watches consume it).
- Manual smoke: open a kandev workspace pointing at a GitLab repo,
edit, click Create PR, watch the MR appear, comment from gitlab.com,
watch the agent see and resolve the comment.
### Phase 7 — Cleanup + docs
- Remove `// TODO: GitLab support is not yet implemented` from
`repoclone/protocol.go`.
- Update `CLAUDE.md` "Repo Layout" and the integrations section to
list `internal/gitlab/`.
- Add the new spec/plan to `docs/specs/INDEX.md` (already done) and
flip status to `shipped` once merged.
## 4. Risks / open points
- **API shape mismatches.** GitLab's MR discussions are nested
(discussion → notes → resolved); GitHub's review comments are flat
with `in_reply_to`. Mapping into the existing comments/threads store
needs care — discussions are the natural unit on GitLab, not
individual comments. Mitigation: GitLab discussions become first-class
in the store, the GitHub side is mapped as "discussions of one note".
Defer if it explodes scope; v1 can render two distinct components.
- **Action-preset query syntax.** GitHub presets use the GitHub search
DSL (`is:pr is:open author:@me`). GitLab MR search uses query
parameters (`?author_username=…&state=opened`). The presets feature is
GitHub-shaped; v1 ships GitLab presets as a constrained set
(assignee/author/reviewer × open/merged) without full DSL parity.
- **Self-managed TLS.** Self-hosted GitLabs sometimes use private CAs.
`pat_client.go` uses the default `http.Client`. A workspace setting
`gitlab_insecure_skip_verify` is the lazy answer; the right answer is
reading `SSL_CERT_FILE`. v1 reads `SSL_CERT_FILE`; flag the
self-signed escape hatch as a follow-up.
- **Per-workspace vs global host.** Per-workspace adds per-call
resolution overhead and complicates non-workspace-scoped callers
(review/issue pollers). Mitigation: the poller iterates workspaces
and uses each workspace's host; the MyGitLab page is workspace-scoped
by construction.
- **`glab` CLI parity.** `glab` lacks direct commands for some search
filters that REST exposes; the CLI client falls back to `glab api`
for those, which means the `glab` binary version matters less than
the underlying REST endpoint version. Pin a minimum `glab` version
in `GLabAvailable()` and surface the version mismatch in the
status WS payload so the settings page can warn.
- **Frontend component extraction (Phase 4).** Risk of refactor churn
on GitHub during the extraction. Mitigation: extract
one-component-at-a-time behind matching tests; revert the extraction
if scope creeps and just copy-and-adapt instead.
- **Mock harness scope.** GitHub's mock has 14k LOC of behavior to
emulate; GitLab's mock can ship with a thin slice (status, MR list,
MR feedback, watch round-trips). The Playwright e2e suite is the
forcing function — match what tests need, not full parity.
## 5. Out of scope (per spec)
Webhook ingestion, GitLab CI editing, GitLab-Issues-as-tracker,
group-wide dashboards, OAuth login, Bitbucket. Two natural follow-ups
once shipped: webhook ingestion (kills the pollers) and the unifying
`codehost.Client` interface (now that two real clients exist).