68aeefa296
* 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>
346 lines
17 KiB
Markdown
346 lines
17 KiB
Markdown
# 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).
|