19 KiB
GitLab Integration — Implementation Plan
Spec: ./spec.md
Issue: #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 togithub/models.goso frontend stores can share component families.client.go—Clientinterface (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. UsesPRIVATE-TOKENheader 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).
- Projects:
pat_client_test.go— table-driven tests withhttptest.Serverper endpoint group, mirroringgithub/pat_client_test.go.glab_client.go— shells out toglabCLI for the sameClientinterface. Mirrorsinternal/github/gh_client.go:GLabAvailable()detects the binary,IsAuthenticated()runsglab auth status, and each method composes aglab api …orglab mr …invocation, parsing JSON stdout. Whereglablacks a direct command (e.g. specific search filters), fall through toglab apiagainst the REST endpoint.glab_client_test.go— usesexec.LookPathshimming and a fakeglabbinary onPATH(same pattern asgh_client_test.go).mock_client.go+mock_client_test.go+mock_controller.go— E2E mock surface, gated byKANDEV_MOCK_GITLAB=true. Uses theinternal/integrationsshared shapes? No — copy the GitHub pattern (the mock currently lives outsideinternal/integrations). Match whichever pattern the team has standardized on (Linear/Jira useinternal/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 →glabCLI (if installed and authenticated for the configured host) → PAT (env varGITLAB_TOKEN, then secrets store keyGITLAB_TOKEN) → noop.service.go— coordinator parallel togithub.Service. Accepts the per-workspace host. Methods: status, MR list/search, MR feedback (incl. discussions), watches CRUD, action presets CRUD, sync. Useinternal/integrations/healthpollfor 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. Reusehealthpoll.Pollerfor 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— creategitlab_mr_watches,gitlab_review_watches,gitlab_issue_watches,gitlab_action_presets. Usegitlab_*prefix on every column to avoid collisions when the unifying refactor lands.apps/backend/internal/workspace/...— addgitlab_hostcolumn to the workspace settings table (or, if workspace settings are JSON, add the field to the JSON shape). Defaulthttps://gitlab.com.
Backend — wiring
cmd/kandev/services.go— newinitGitLabService(...)helper that resolves the per-workspace host (or a global default for unscoped callers) and wires the service.cmd/kandev/helpers.go— register routes viagitlab.RegisterRoutesandgitlab.RegisterMockRoutes.internal/repoclone/protocol.go— replace thegitlab.comhardcode with a host parameter.CloneURLsignature gainshost string. Update call sites ininternal/agent/lifecycle/executor_*.goandinternal/orchestrator/executor/executor_execute.goto pass the workspace'sgitlab_hostwhenprovider == "gitlab".internal/agent/credentials/env_provider.go— already listsGITLAB_TOKENinknownAPIKeyPatterns. No change required.internal/agent/lifecycle/default_scripts.go— extend the default prepare script so HTTPS clones for GitLab usehttps://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 ofapp/github/. SSR fetches/api/v1/gitlab/status, hydrates store.apps/web/app/settings/integrations/gitlab/page.tsx— mirror ofsettings/integrations/github/page.tsx. Hosts theGitLabIntegrationPagepanel: 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 bothgithubandgitlabto 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).
- (A) Extract shared components. Move generic pieces (search bar,
pagination, save-preset dialog, list toolbar, presets sidebar
skeleton) into
apps/web/lib/state/slices/gitlab/— new slice mirroringslices/github/. Same hydration patterns.apps/web/hooks/domains/gitlab/—use-gitlab-status, watches, search, action presets. Symmetric tohooks/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— addGitLabTopbarButtonnext toGitHubTopbarButton. Render only when GitLab is configured for the active workspace (gate onuseGitLabAvailable).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 originmatches:gitlab|/gitlab) and the GitLab MR-creation flow. PreferglabCLI if available in the agent container; fall back to REST API viacurlwithGITLAB_TOKEN. Keepghas the GitHub path..claude/skills/pr/SKILL.mdsymlink — already symlinked, no change.
WebSocket actions
pkg/websocket/actions.go(or wherever GitHub actions are declared) — addActionGitLabStatus,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}.goplus tests.- Mock client + controller. Wired under
KANDEV_MOCK_GITLAB=true. - Provider +
cmd/kandev/services.goinitGitLabService. - Status WS handler +
/api/v1/gitlab/statusHTTP 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.gohost parameter. Call-site updates.lifecycle/default_scripts.goHTTPS-with-token rewrite for GitLab.- Workspace
gitlab_hostsetting (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>frominternal/integrationsif 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.
gitlabslice + hooks.- Poller for review-watches and issue-watches (uses
healthpoll.Pollerfor auth-health; MR/issue polling ingitlab/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+ResolveDiscussioninpat_client.go.MRFeedbackpayload +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,resolvedflag). - 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
prskill 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 implementedfromrepoclone/protocol.go. - Update
CLAUDE.md"Repo Layout" and the integrations section to listinternal/gitlab/. - Add the new spec/plan to
docs/specs/INDEX.md(already done) and flip status toshippedonce merged.
Phase 8 — MR auto-link (post-ship follow-on)
Goal: close the gap where an MR opened outside Kandev's own MR-creation action
(glab mr create, or opening the MR directly in the GitLab web UI) never got
linked — only manual URL linking and the Create-PR callback did. Mirrors
GitHub's push-detection auto-link.
internal/gitlab/service_task_mr_autolink.go—Service.AutoLinkMRForBranch(find-MR-by-branch → validate repository identity → upsert association → ensure refresh watch → publishgitlab.task_mr.updated).internal/gitlab/service_watches.go—EnsureMRWatch(idempotent get-or-create) andCheckMRWatchnow refreshgitlab_task_mrson every poll, not just watch timestamps.gitlab_mr_watchesunique key widened to(session_id, repository_id, branch)via an idempotent table-rebuild migration, so multi-branch tasks don't collide on the second branch's watch insert.internal/orchestrator/event_handlers_gitlab_mr.go—detectPushAndAssociateMR(GitLab twin of GitHub'sdetectPushAndAssociatePR, same[0, 30s, 60s]retry shape) andCheckSessionMR(on-demand check, mirrorsCheckSessionPR).event_handlers_git.go's push-tracking dispatches by repository provider so the two providers' code paths never call into each other's client.Status: shipped.
Out of scope for this phase (unchanged from the root spec): webhook-driven linking, and backfilling associations for MRs opened before this shipped.
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.gouses the defaulthttp.Client. A workspace settinggitlab_insecure_skip_verifyis the lazy answer; the right answer is readingSSL_CERT_FILE. v1 readsSSL_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.
glabCLI parity.glablacks direct commands for some search filters that REST exposes; the CLI client falls back toglab apifor those, which means theglabbinary version matters less than the underlying REST endpoint version. Pin a minimumglabversion inGLabAvailable()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).