* 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>
* feat: add mobile parity skill
* fix: align mobile parity skill with repo e2e
* fix: clarify mobile fixture import guidance
* docs: align mobile parity example quote style