* 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(slack): add cookie-mode integration that triages messages via utility agent
Adds an install-wide Slack integration that polls the connected user's
own messages for `!kandev <instruction>`, hands the thread context to a
configured utility agent with the Kandev MCP server attached, and
creates the resulting task. Auth uses the user's xoxc- token + d cookie
so it works on locked-down Enterprise Slack tenants where bot installs
aren't possible.
Shared infra:
- ACP host-utility executor accepts McpServers on session/new
- New MCP tool list_repositories_kandev so the triage agent can pick the
right repo
- Template engine exposes Slack-specific variables for prompt injection
- secretadapter + healthpoll reused from jira/linear
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(slack): address PR review feedback round 9
- Drop dead `var _ = time.Second` no-op and its misleading comment in
trigger_test.go.
- `RecordAuthHealth` now invalidates the cached client when the probe
fails, so the poller self-recovers from credential rotation without
requiring a re-save.
- Stable secondary sort key (`workspace_id DESC`) on the legacy →
singleton migration so promoted-row selection is deterministic on
`updated_at` ties.
- Extract `categorySlack` constant in `engine.go` and update the
`VariableInfo.Category` doc-comment to list the full set of values.
- Realign `slack-integration/spec.md` with what shipped: cookie-mode +
`!kandev` triage as v1, with bot install / User OAuth / reaction &
slash & shortcut triggers moved under Future scope.
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>
* refactor: move integrations to top-level settings with install-wide configs
Jira and Linear configurations are now install-wide singletons rather than
per-workspace duplicates, and the GitHub/Jira/Linear settings pages live
under /settings/integrations instead of being nested inside each workspace.
Issue and PR watchers stay per-workspace via a workspace switcher inside
each integration page. Backend ships one-shot migrations that promote the
most-recently-updated row and copy its secret onto the new global key.
* fix: address review feedback on integrations refactor
Fix migration crash on installs whose jira_configs predate the auth-health
columns by selecting only guaranteed-present legacy columns and falling back
to defaults for the rest. Cover the empty-legacy-table and pre-health-columns
upgrade paths with new store tests.
On the frontend, refactor useIntegrationEnabled to take string parameters
instead of an inline options object so getSnapshot stays referentially stable
across renders, and stop dispatching a misleading no-op sync event when
localStorage.setItem fails in private mode.
* fix: hydrate workspace list on integration settings pages
The integration pages wrapped their content in a fresh StateProvider, which
created a nested store scope with an empty initial state and shadowed the
settings layout's hydrated workspace list. WorkspaceScopedSection then saw
zero workspaces and rendered the "Create a workspace to configure this
integration" empty state even when the user had workspaces.
Drop the redundant StateProvider from the jira and linear pages so they share
the layout's store. The github page switches to StateHydrator so it can still
seed the GitHub OAuth status without resetting the rest of the store.
* refactor(integrations): flatten watcher tables, pick workspace in create dialog
Hiding watchers behind a workspace switcher made the list invisible at a
glance — a user with multiple workspaces had to flip between each one to
see what was configured. Replace the switcher with a flat table that lists
every watch across every workspace, with a Workspace column for context, and
move workspace selection into the create dialog so the user picks once when
the watcher is born.
Backend: jira list-issue-watches and github list-review-watches /
list-issue-watches accept the workspace_id query param as optional; when
omitted, the corresponding store ListAll* method returns rows across every
workspace. Per-row mutations still require workspace_id as the IDOR guard.
Frontend: useReviewWatches / useIssueWatches / useJiraIssueWatches gain a
three-state contract — string for one workspace, undefined for all
workspaces, null to skip fetching. Watch tables grow an optional
showWorkspace column. Watch dialogs accept an optional workspaceId prop;
when absent, the dialog renders a workspace dropdown for the create flow
(disabled when editing existing rows so refs aren't orphaned). Github
review-watch-dialog crossed the 600-line lint cap so the prompt editor
moved to review-watch-prompt-field.tsx.
* fix(integrations): make watcher step dropdown reactive across workspace switches
Switching to a workspace whose workflows had to be fetched left the workflow
step dropdown stuck — the user could pick a workflow but the step Select
either showed the previous workflow's steps or appeared inert. The cause was
the per-dialog useWorkflowSteps hook deferring its empty-state reset via
Promise.resolve().then(() => setSteps([])) to dodge a setState-in-effect
lint rule, which let stale steps survive into the next render and gave Radix
nothing to anchor the popover to during the in-flight fetch.
Hoist the hook into a shared useWorkflowSteps that resets via React's
official setState-during-render pattern, exposes a loading flag, and returns
a stepPlaceholder helper so the Select can render "Loading steps…" or "No
steps in this workflow" instead of an empty popover. The Step Select is now
also disabled while loading or when there are no steps, making the state of
the dropdown unambiguous.
* fix: address PR review feedback on integrations refactor
Backend:
- linear migration: add column-presence guard mirroring the Jira fix so
installs that upgraded from the original Linear schema (without org_slug
/ last_checked_at / last_ok / last_error) don't crash startup with
"no such column"; add TestStore_MigrateLegacyPerWorkspaceTable_PreHealthColumns
- jira/handlers.go: drop the now-unused errMsgWorkspaceIDRequired constant
that was failing golangci-lint
- jira/mock_controller_test.go (new): cover setAuthHealth happy-path and
invalid-JSON-rejection so the singleton-shaped payload is locked in
Frontend:
- useIntegrationAvailable: gate the auth-health probe on loaded && enabled
so disabled integrations don't poll /config every 90s
- use-workflow-steps: initialize loading to !!workflowId so edit-mode opens
show "Loading steps…" instead of "No steps in this workflow" before the
first fetch lands
- use-workflow-steps.test.ts (new): cover the empty/non-empty workflowId,
workflow switch, fetch error, and stepPlaceholder branches
- use-linear-enabled.test: tighten the legacy-key migration assertion to
pin the migrated value rather than just verifying key cleanup
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>