feat(gitlab): add GitLab integration with MR / discussions support (#861)

* feat(gitlab): add GitLab integration with MR / discussions support

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

Closes #820

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-05-23 23:17:16 +01:00
committed by GitHub
parent 5a55b06d4d
commit 68aeefa296
58 changed files with 8048 additions and 12 deletions
+89 -1
View File
@@ -5,7 +5,13 @@ description: Commit, push, and create a PR. Default is ready-for-review with aut
# PR
> **GitHub tool selection:** This skill uses `gh` CLI commands by default. If `gh` is unavailable or fails, use any available GitHub tools in the environment (e.g. MCP GitHub tools) to create, edit, and view the PR. The goal is the same — the tool may differ.
> **Host detection:** This skill works on both GitHub and GitLab repositories. Detect the host before step 4 by inspecting `git remote get-url origin`:
> - URL contains `github.com` (or any host you have configured for GitHub) → use the **GitHub flow** below.
> - URL contains `gitlab` (e.g. `gitlab.com`, `gitlab.acme.corp`) → use the **GitLab flow** at the bottom of this file.
> - For self-managed hosts, the user's repository configuration determines the host.
>
> **GitHub tool selection:** The GitHub flow uses `gh` CLI by default. If `gh` is unavailable or fails, use any available GitHub tools in the environment (e.g. MCP GitHub tools).
> **GitLab tool selection:** The GitLab flow prefers `glab` CLI when available; otherwise it shells `curl` against the REST v4 API using `$GITLAB_TOKEN` (which the agent runtime injects from the user's secrets store).
## Available skills
@@ -65,3 +71,85 @@ description: Commit, push, and create a PR. Default is ready-for-review with aut
- Tell the user to drag and drop the image files from `.pr-assets/` into the PR description on GitHub for the images to render
7. **Return the PR URL** when done.
## GitLab flow (Merge Requests)
When `git remote get-url origin` points at a GitLab host, the steps are the same up through **Push** (1–3). For step 4, create a Merge Request instead of a PR. **Skip steps 5 and 6** — `/pr-fixup` is wired to GitHub CI / CodeRabbit and `gh pr edit` only works against GitHub. The GitLab equivalent is to manage the MR directly via `glab` or the REST API (see "review comments" note at the bottom). After creating the MR, return the MR URL and stop.
**MR title** still follows Conventional Commits — the squash-merge commit message is built from it the same way.
**MR description** uses the same template as the PR body above (Summary, Validation, etc.).
Prefer the `glab` CLI when it is on the agent's `PATH`:
Don't hardcode `--target-branch`: many projects ship from `master`, `develop`, or a custom default. Omit the flag so `glab` resolves the project's default branch via the API, or pass an explicit value only if the user / spec already specified one.
```bash
glab mr create [--draft] \
--title "type: description" \
--description "$(cat <<'EOF'
<filled template>
EOF
)" \
--remove-source-branch \
--yes
```
If `glab` is unavailable but `$GITLAB_TOKEN` is set, fall back to the REST API. Derive the host from the git remote — `$CI_SERVER_URL` is only set inside GitLab runners and silently falling back to `gitlab.com` from a developer's machine would target the wrong instance. Construct the JSON body with `jq` so multi-line descriptions and embedded quotes can't break the payload.
```bash
REMOTE_URL="$(git remote get-url origin)" # any of: git@host:path.git | ssh://git@host[:port]/path.git | https://host[:port]/path.git
# Classify by scheme so we can keep an https:// port (real API endpoint)
# while dropping any ssh:// port (irrelevant to the HTTPS API).
case "$REMOTE_URL" in
ssh://*) URL="${REMOTE_URL#ssh://}"; FORM=ssh ;;
http://*|https://*) URL="${REMOTE_URL#*://}"; FORM=http ;;
*) URL="$REMOTE_URL"; FORM=scp ;;
esac
URL="${URL#*@}" # strip optional user@
case "$FORM" in
scp)
# scp-style "git@host:path" — no port possible.
HOST_ONLY="${URL%%:*}"
HOST="https://${HOST_ONLY}"
PROJECT_PATH="${URL#*:}"
;;
ssh)
# ssh:// — port (if any) is the SSH port, not the HTTPS API port.
HOST_PORT="${URL%%/*}"
HOST="https://${HOST_PORT%%:*}"
PROJECT_PATH="${URL#*/}"
;;
http)
# https://host[:port]/path — preserve the port; it IS the API endpoint.
HOST_PORT="${URL%%/*}"
HOST="https://${HOST_PORT}"
PROJECT_PATH="${URL#*/}"
;;
esac
PROJECT="${PROJECT_PATH%.git}" # team/repo
SOURCE_BRANCH="$(git branch --show-current)"
PROJECT_ENC="$(printf '%s' "$PROJECT" | jq -sRr @uri)"
# Default branch via the GitLab API itself, not glab (avoids version drift
# on glab's flag surface). Fall back to "main" only if the lookup fails.
TARGET_BRANCH="$(curl --fail -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"$HOST/api/v4/projects/$PROJECT_ENC" | jq -r '.default_branch // "main"')"
PAYLOAD="$(jq -n \
--arg source "$SOURCE_BRANCH" \
--arg target "$TARGET_BRANCH" \
--arg title "type: description" \
--arg description "$(cat <<'EOF'
<filled template>
EOF
)" \
'{source_branch: $source, target_branch: $target, title: $title, description: $description, remove_source_branch: true}')"
curl --fail -X POST \
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
-H "Content-Type: application/json" \
--data "$PAYLOAD" \
"$HOST/api/v4/projects/$PROJECT_ENC/merge_requests"
```
To address review comments on a GitLab MR, use the **discussions** API rather than individual review comments — discussions are GitLab's threading primitive. List with `GET /projects/:id/merge_requests/:iid/discussions`, reply with `POST /projects/:id/merge_requests/:iid/discussions/:discussion_id/notes`, and resolve a thread with `PUT /projects/:id/merge_requests/:iid/discussions/:discussion_id?resolved=true`. The `glab` equivalent for replies is `glab mr note create --reply <discussion_id>` — bare `glab mr note` opens a new thread instead of replying to an existing one.
+1
View File
@@ -109,6 +109,7 @@ apps/backend/
│ ├── events/ # Event bus for internal pub/sub
│ ├── gateway/ # WebSocket gateway
│ ├── github/ # GitHub API integration (PRs, reviews, webhooks)
│ ├── gitlab/ # GitLab API integration (MRs, discussions, pipelines)
│ ├── common/ # Shared utilities, config, logger
│ ├── integration/ # External integrations
│ ├── integrations/ # Shared shapes for third-party integrations
+6
View File
@@ -41,6 +41,7 @@ import (
"github.com/kandev/kandev/internal/events/bus"
gateways "github.com/kandev/kandev/internal/gateway/websocket"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
"github.com/kandev/kandev/internal/health"
"github.com/kandev/kandev/internal/improvekandev"
"github.com/kandev/kandev/internal/jira"
@@ -715,6 +716,11 @@ func registerSecondaryRoutes(
p.log.Debug("Registered GitHub handlers (HTTP + WebSocket)")
}
if p.services.GitLab != nil {
gitlab.RegisterRoutes(p.router, p.services.GitLab, p.log)
p.log.Debug("Registered GitLab handlers (HTTP)")
}
if p.services.Jira != nil {
jira.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Jira, p.log)
jira.RegisterMockRoutes(p.router, p.services.Jira, p.log)
+73
View File
@@ -18,6 +18,7 @@ import (
editorservice "github.com/kandev/kandev/internal/editors/service"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
"github.com/kandev/kandev/internal/integrations/secretadapter"
"github.com/kandev/kandev/internal/jira"
"github.com/kandev/kandev/internal/linear"
@@ -91,6 +92,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
)
githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log)
gitlabSvc := initGitLabService(dbPool, repos.Secrets, log)
jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log)
linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log)
slackSvc := initSlackService(dbPool, repos.Secrets, log)
@@ -118,6 +120,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
Utility: utilitySvc,
Workflow: workflowSvc,
GitHub: githubSvc,
GitLab: gitlabSvc,
Jira: jiraSvc,
Linear: linearSvc,
Slack: slackSvc,
@@ -256,6 +259,76 @@ func initGitHubService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secr
return svc
}
// gitlabSecretAdapter adapts secrets.SecretStore to the GitLab integration's
// SecretProvider and SecretManager interfaces. Mirrors githubSecretAdapter
// — kept separate so the two packages can evolve independently.
type gitlabSecretAdapter struct {
store secrets.SecretStore
}
func (a *gitlabSecretAdapter) List(ctx context.Context) ([]*gitlab.SecretListItem, error) {
items, err := a.store.List(ctx)
if err != nil {
return nil, err
}
result := make([]*gitlab.SecretListItem, len(items))
for i, item := range items {
result[i] = &gitlab.SecretListItem{
ID: item.ID,
Name: item.Name,
HasValue: item.HasValue,
}
}
return result, nil
}
func (a *gitlabSecretAdapter) Reveal(ctx context.Context, id string) (string, error) {
return a.store.Reveal(ctx, id)
}
func (a *gitlabSecretAdapter) Create(ctx context.Context, name, value string) (string, error) {
secret := &secrets.SecretWithValue{
Secret: secrets.Secret{Name: name},
Value: value,
}
if err := a.store.Create(ctx, secret); err != nil {
return "", err
}
return secret.ID, nil
}
func (a *gitlabSecretAdapter) Update(ctx context.Context, id, value string) error {
return a.store.Update(ctx, id, &secrets.UpdateSecretRequest{Value: &value})
}
func (a *gitlabSecretAdapter) Delete(ctx context.Context, id string) error {
return a.store.Delete(ctx, id)
}
// initGitLabService wires up the GitLab integration. Failures are non-fatal:
// the rest of the backend still boots without GitLab configured.
func initGitLabService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *gitlab.Service {
adapter := &gitlabSecretAdapter{store: secretsStore}
// Host persistence (per-workspace gitlab_host) is deferred to a
// follow-up; v1 reads from DefaultHost on every boot.
svc, _, err := gitlab.Provide(context.Background(), adapter, nil, log)
if err != nil {
log.Warn("GitLab service initialization failed (non-fatal)", zap.Error(err))
}
if svc != nil {
svc.SetSecretManager(adapter)
// Task↔MR association store backs the topbar review surface.
// Non-fatal: if the table fails to create the rest of the
// integration (status, configure, MR feedback) still works.
if store, storeErr := gitlab.NewStore(dbPool.Writer(), dbPool.Reader()); storeErr == nil {
svc.SetStore(store)
} else {
log.Warn("GitLab task-mr store unavailable (non-fatal)", zap.Error(storeErr))
}
}
return svc
}
// initJiraService wires up the Jira integration. Failures are non-fatal.
func initJiraService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *jira.Service {
svc, _, err := jira.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log)
+2
View File
@@ -7,6 +7,7 @@ import (
editorservice "github.com/kandev/kandev/internal/editors/service"
editorstore "github.com/kandev/kandev/internal/editors/store"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
"github.com/kandev/kandev/internal/jira"
"github.com/kandev/kandev/internal/linear"
notificationservice "github.com/kandev/kandev/internal/notifications/service"
@@ -53,6 +54,7 @@ type Services struct {
Utility *utilityservice.Service
Workflow *workflowservice.Service
GitHub *github.Service
GitLab *gitlab.Service
Jira *jira.Service
Linear *linear.Service
Slack *slack.Service
+111
View File
@@ -0,0 +1,111 @@
package gitlab
import (
"context"
"time"
)
// Client defines the interface for interacting with the GitLab API.
//
// Implementations: pat_client.go (REST v4 over HTTP), glab_client.go
// (shells out to the glab CLI), mock_client.go (in-memory, gated by
// KANDEV_MOCK_GITLAB=true), and noop_client.go (null-object fallback).
//
// projectPath everywhere is the namespace/path slug, e.g. "group/project"
// or "group/subgroup/project". The MR IID (per-project sequential ID) is
// the user-visible number.
type Client interface {
// IsAuthenticated reports whether the client can talk to GitLab.
IsAuthenticated(ctx context.Context) (bool, error)
// GetAuthenticatedUser returns the username of the authenticated user.
GetAuthenticatedUser(ctx context.Context) (string, error)
// Host returns the GitLab host this client is configured against.
Host() string
// GetMR retrieves a single merge request by its per-project IID.
GetMR(ctx context.Context, projectPath string, iid int) (*MR, error)
// FindMRByBranch finds an open MR for the given source branch.
FindMRByBranch(ctx context.Context, projectPath, branch string) (*MR, error)
// ListAuthoredMRs lists open MRs authored by the authenticated user
// for a project.
ListAuthoredMRs(ctx context.Context, projectPath string) ([]*MR, error)
// ListReviewRequestedMRs lists open MRs where the user is a reviewer.
// filter is an optional additional GitLab API filter (e.g.
// "project_id=123" or "milestone=v1"); customQuery, when non-empty,
// replaces the entire generated query.
ListReviewRequestedMRs(ctx context.Context, filter, customQuery string) ([]*MR, error)
// ListUserGroups returns the GitLab groups the authenticated user
// belongs to (analogous to GitHubOrg).
ListUserGroups(ctx context.Context) ([]Group, error)
// SearchGroupProjects searches projects in a group, optionally
// filtered by a query string.
SearchGroupProjects(ctx context.Context, group, query string, limit int) ([]Project, error)
// ListMRApprovals lists approvals on a merge request.
ListMRApprovals(ctx context.Context, projectPath string, iid int) ([]MRApproval, error)
// ListMRDiscussions lists discussions (review threads) on an MR.
// If since is non-nil, only discussions updated after that time are
// returned.
ListMRDiscussions(ctx context.Context, projectPath string, iid int, since *time.Time) ([]MRDiscussion, error)
// CreateMRDiscussionNote posts a reply note in an existing discussion.
CreateMRDiscussionNote(ctx context.Context, projectPath string, iid int, discussionID, body string) (*MRNote, error)
// ResolveMRDiscussion marks a discussion as resolved.
ResolveMRDiscussion(ctx context.Context, projectPath string, iid int, discussionID string) error
// ListPipelines lists pipelines for a given git ref (branch or SHA).
ListPipelines(ctx context.Context, projectPath, ref string) ([]Pipeline, error)
// GetMRFeedback fetches aggregated feedback (approvals, discussions,
// pipelines) for an MR.
GetMRFeedback(ctx context.Context, projectPath string, iid int) (*MRFeedback, error)
// GetMRStatus fetches lightweight MR state (used by the poller).
GetMRStatus(ctx context.Context, projectPath string, iid int) (*MRStatus, error)
// ListMRFiles lists files changed in a merge request.
ListMRFiles(ctx context.Context, projectPath string, iid int) ([]MRFile, error)
// ListMRCommits lists commits in a merge request.
ListMRCommits(ctx context.Context, projectPath string, iid int) ([]MRCommitInfo, error)
// SubmitMRApproval approves an MR. To revoke an approval, call
// SubmitMRUnapproval.
SubmitMRApproval(ctx context.Context, projectPath string, iid int) error
// SubmitMRUnapproval revokes the authenticated user's approval of an MR.
SubmitMRUnapproval(ctx context.Context, projectPath string, iid int) error
// CreateMR opens a new merge request. Used by the agent `pr` skill.
CreateMR(ctx context.Context, projectPath, sourceBranch, targetBranch, title, description string, draft bool) (*MR, error)
// ListProjectBranches lists branches for a project.
ListProjectBranches(ctx context.Context, projectPath string) ([]RepoBranch, error)
// ListIssues searches for open issues. filter is an optional
// additional API filter; customQuery, when non-empty, replaces the
// entire generated query.
ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error)
// SearchMRs searches for MRs matching the given query.
SearchMRs(ctx context.Context, filter, customQuery string) ([]*MR, error)
// SearchMRsPaged is the paginated variant of SearchMRs. page is
// 1-indexed; perPage is clamped to GitLab's 1..100 range.
SearchMRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*MRSearchPage, error)
// ListIssuesPaged is the paginated variant of ListIssues.
ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error)
// GetIssueState returns the state of a single issue ("opened" or "closed").
GetIssueState(ctx context.Context, projectPath string, iid int) (string, error)
}
@@ -0,0 +1,454 @@
package gitlab
import (
"net/url"
"strings"
"time"
)
// rawMR is the JSON shape of a GitLab merge request as returned by the
// REST v4 API.
type rawMR struct {
ID int64 `json:"id"`
IID int `json:"iid"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"` // opened, closed, merged, locked
WebURL string `json:"web_url"`
Draft bool `json:"draft"`
WorkInProgress bool `json:"work_in_progress"`
MergeStatus string `json:"merge_status"`
HasConflicts bool `json:"has_conflicts"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
SHA string `json:"sha"`
References struct {
Full string `json:"full"`
} `json:"references"`
Author rawUser `json:"author"`
Reviewers []rawUser `json:"reviewers"`
Assignees []rawUser `json:"assignees"`
ChangesCount string `json:"changes_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MergedAt *time.Time `json:"merged_at"`
ClosedAt *time.Time `json:"closed_at"`
}
type rawUser struct {
Username string `json:"username"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
Bot bool `json:"bot"`
}
type rawProject struct {
ID int64 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
PathWithNamespace string `json:"path_with_namespace"`
Visibility string `json:"visibility"`
Namespace struct {
FullPath string `json:"full_path"`
} `json:"namespace"`
}
type rawIssue struct {
ID int64 `json:"id"`
IID int `json:"iid"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"`
WebURL string `json:"web_url"`
Author rawUser `json:"author"`
Labels []string `json:"labels"`
Assignees []rawUser `json:"assignees"`
References struct {
Full string `json:"full"`
} `json:"references"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at"`
}
type rawDiscussion struct {
ID string `json:"id"`
IndividualNote bool `json:"individual_note"`
Notes []rawNote `json:"notes"`
}
type rawNote struct {
ID int64 `json:"id"`
Body string `json:"body"`
Type string `json:"type"`
System bool `json:"system"`
Resolvable bool `json:"resolvable"`
Resolved bool `json:"resolved"`
Author rawUser `json:"author"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Position *struct {
NewPath string `json:"new_path"`
OldPath string `json:"old_path"`
NewLine int `json:"new_line"`
OldLine int `json:"old_line"`
} `json:"position"`
}
type rawPipeline struct {
ID int64 `json:"id"`
IID int `json:"iid"`
Status string `json:"status"`
Source string `json:"source"`
Ref string `json:"ref"`
SHA string `json:"sha"`
WebURL string `json:"web_url"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
func convertRawMR(raw *rawMR) *MR {
state := normalizeMRState(raw.State)
namespace, projectPath := splitFullReference(raw.References.Full)
mr := &MR{
ID: raw.ID,
IID: raw.IID,
ProjectID: raw.ProjectID,
Title: raw.Title,
URL: raw.WebURL,
WebURL: raw.WebURL,
State: state,
HeadBranch: raw.SourceBranch,
HeadSHA: raw.SHA,
BaseBranch: raw.TargetBranch,
AuthorUsername: raw.Author.Username,
ProjectNamespace: namespace,
ProjectPath: projectPath,
Body: raw.Description,
Draft: raw.Draft || raw.WorkInProgress,
MergeStatus: raw.MergeStatus,
HasConflicts: raw.HasConflicts,
Reviewers: convertReviewers(raw.Reviewers),
Assignees: convertReviewers(raw.Assignees),
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
MergedAt: raw.MergedAt,
ClosedAt: raw.ClosedAt,
}
return mr
}
func convertRawMRSlice(raw []rawMR) []*MR {
out := make([]*MR, len(raw))
for i := range raw {
out[i] = convertRawMR(&raw[i])
}
return out
}
func convertReviewers(raw []rawUser) []MRReviewer {
out := make([]MRReviewer, 0, len(raw))
for _, r := range raw {
if r.Username == "" {
continue
}
out = append(out, MRReviewer{
Username: r.Username,
Name: r.Name,
Type: "user",
})
}
return out
}
func convertRawIssue(raw *rawIssue) *Issue {
namespace, projectPath := splitFullReference(raw.References.Full)
assignees := make([]string, 0, len(raw.Assignees))
for _, a := range raw.Assignees {
if a.Username != "" {
assignees = append(assignees, a.Username)
}
}
return &Issue{
ID: raw.ID,
IID: raw.IID,
ProjectID: raw.ProjectID,
Title: raw.Title,
Body: raw.Description,
URL: raw.WebURL,
WebURL: raw.WebURL,
State: raw.State,
AuthorUsername: raw.Author.Username,
ProjectNamespace: namespace,
ProjectPath: projectPath,
Labels: append([]string(nil), raw.Labels...),
Assignees: assignees,
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
ClosedAt: raw.ClosedAt,
}
}
func convertRawProject(raw *rawProject) Project {
namespace := raw.Namespace.FullPath
if namespace == "" {
// GitLab subgroups can be arbitrarily nested (acme/team/squad/repo),
// so the namespace is everything up to the final "/" — not just the
// first segment.
if idx := strings.LastIndex(raw.PathWithNamespace, "/"); idx > 0 {
namespace = raw.PathWithNamespace[:idx]
}
}
return Project{
ID: raw.ID,
PathWithNamespace: raw.PathWithNamespace,
Namespace: namespace,
Path: raw.Path,
Name: raw.Name,
Visibility: raw.Visibility,
}
}
func convertRawDiscussion(raw *rawDiscussion) MRDiscussion {
d := MRDiscussion{
ID: raw.ID,
Notes: make([]MRNote, 0, len(raw.Notes)),
}
for i := range raw.Notes {
note := convertRawNote(&raw.Notes[i])
d.Notes = append(d.Notes, note)
if i == 0 {
d.Resolvable = raw.Notes[i].Resolvable
d.Resolved = raw.Notes[i].Resolved
d.CreatedAt = raw.Notes[i].CreatedAt
d.UpdatedAt = raw.Notes[i].UpdatedAt
if raw.Notes[i].Position != nil {
d.Path = raw.Notes[i].Position.NewPath
d.Line = raw.Notes[i].Position.NewLine
d.OldLine = raw.Notes[i].Position.OldLine
}
} else if note.UpdatedAt.After(d.UpdatedAt) {
d.UpdatedAt = note.UpdatedAt
}
}
return d
}
func convertRawNote(raw *rawNote) MRNote {
return MRNote{
ID: raw.ID,
Author: raw.Author.Username,
AuthorAvatar: raw.Author.AvatarURL,
AuthorIsBot: raw.Author.Bot,
Body: raw.Body,
Type: raw.Type,
System: raw.System,
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
}
}
func convertRawPipeline(raw *rawPipeline) Pipeline {
return Pipeline{
ID: raw.ID,
IID: raw.IID,
Status: raw.Status,
Source: raw.Source,
Ref: raw.Ref,
SHA: raw.SHA,
WebURL: raw.WebURL,
StartedAt: raw.StartedAt,
FinishedAt: raw.FinishedAt,
}
}
// mrStateOpen is the normalized "open" state value shared with the GitHub
// integration vocabulary. GitLab's API returns "opened"; we expose "open".
const mrStateOpen = "open"
// normalizeMRState converts GitLab's "opened" to "open" and leaves the rest
// alone so the UI shares the GitHub vocabulary.
func normalizeMRState(state string) string {
switch state {
case "opened":
return mrStateOpen
case "merged", "closed", "locked":
return state
default:
return state
}
}
// splitFullReference parses GitLab's "namespace/path!iid" or
// "namespace/path#iid" form into (namespace, projectPath). It is best-effort:
// when the reference does not match it returns ("", "").
// splitFullReference parses GitLab's full-reference strings (e.g.
// "group/sub/project!42" for an MR or "group/project#10" for an issue) into
// (namespace, projectPath). projectPath is the *full* path-with-namespace
// — "group/sub/project" — so callers can round-trip it back into API URLs
// via projectRef without having to recombine namespace + name themselves.
// namespace is everything before the final "/".
func splitFullReference(full string) (namespace, projectPath string) {
for _, sep := range []string{"!", "#"} {
if idx := strings.Index(full, sep); idx > 0 {
full = full[:idx]
break
}
}
last := strings.LastIndex(full, "/")
if last <= 0 {
return "", ""
}
return full[:last], full
}
func hasOpenDiscussions(discussions []MRDiscussion) bool {
for _, d := range discussions {
if d.Resolvable && !d.Resolved {
return true
}
}
return false
}
func pipelineFailing(pipelines []Pipeline) bool {
state, _, _ := summarizePipelines(pipelines)
return state == "failure"
}
// Computed status strings shared by pipeline + approval summarizers.
const statusPending = "pending"
// summarizePipelines reduces a list of pipeline runs (most-recent-first per
// the GitLab API) to a single state plus job counts. Only the most recent
// pipeline matters for the rolled-up status.
func summarizePipelines(pipelines []Pipeline) (state string, jobsTotal, jobsPassing int) {
if len(pipelines) == 0 {
return "", 0, 0
}
latest := pipelines[0]
jobsTotal = latest.JobsTotal
jobsPassing = latest.JobsPassing
switch latest.Status {
case "success":
state = "success"
case "failed", "canceled":
state = "failure"
case "skipped":
state = ""
default:
state = statusPending
}
return state, jobsTotal, jobsPassing
}
func summarizeApprovals(have, required int) string {
if required == 0 {
if have > 0 {
return "approved"
}
return ""
}
if have >= required {
return "approved"
}
return statusPending
}
// --- Search query builders ---
// buildReviewMRQuery builds a query string for "MRs needing my review".
// GitLab's /merge_requests endpoint scopes to the authenticated user when
// `scope=assigned_to_me` or `reviewer_username=<me>`; we pass
// `reviewer_username=` resolution to the caller via filter (e.g.
// "reviewer_username=octocat"). state defaults to "opened".
func buildReviewMRQuery(filter, customQuery string) string {
if customQuery != "" {
return customQuery
}
values := url.Values{}
values.Set("state", "opened")
values.Set("scope", "all")
values.Set("per_page", "50")
if filter != "" {
appendFilter(values, filter)
}
return values.Encode()
}
func buildMRSearchQuery(filter, customQuery string) string {
if customQuery != "" {
return customQuery
}
values := url.Values{}
values.Set("state", "opened")
values.Set("scope", "all")
if filter != "" {
appendFilter(values, filter)
}
return values.Encode()
}
func buildIssueSearchQuery(filter, customQuery string) string {
if customQuery != "" {
return customQuery
}
values := url.Values{}
values.Set("state", "opened")
values.Set("scope", "all")
if filter != "" {
appendFilter(values, filter)
}
return values.Encode()
}
// appendFilter parses a `key=value&key2=value2` filter and merges it into
// values. User-supplied keys override defaults set by the caller (so passing
// "state=closed" actually swaps the default "opened" rather than appending
// a second value). Unparseable filters are ignored — callers that need
// stricter validation should use customQuery instead.
func appendFilter(values url.Values, filter string) {
parsed, err := url.ParseQuery(filter)
if err != nil {
return
}
for k, vs := range parsed {
values.Del(k)
for _, v := range vs {
values.Add(k, v)
}
}
}
// countDiffLines returns (additions, deletions) by counting lines starting
// with "+" or "-" (excluding the diff header lines that start with
// "+++"/"---"). Best-effort; matches the GitLab UI's own counting.
func countDiffLines(diff string) (int, int) {
additions, deletions := 0, 0
for _, line := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
continue
case strings.HasPrefix(line, "+"):
additions++
case strings.HasPrefix(line, "-"):
deletions++
}
}
return additions, deletions
}
func diffStatus(newFile, deletedFile, renamedFile bool) string {
switch {
case newFile:
return "added"
case deletedFile:
return "deleted"
case renamedFile:
return "renamed"
default:
return "modified"
}
}
@@ -0,0 +1,48 @@
package gitlab
import (
"net/url"
"testing"
)
// User-supplied filter keys must override defaults seeded by the caller.
// The previous implementation used values.Add(), which kept the default
// alongside the user value (e.g. state=opened&state=closed) and GitLab
// silently honoured the first one.
func TestAppendFilter_UserKeysOverrideDefaults(t *testing.T) {
values := url.Values{}
values.Set("state", "opened")
values.Set("scope", "all")
appendFilter(values, "state=closed&labels=bug")
if got := values.Get("state"); got != "closed" {
t.Errorf("state = %q, want closed (user filter must override default)", got)
}
if got := values["state"]; len(got) != 1 {
t.Errorf("state values = %v, want length 1 (defaults must be cleared before re-adding)", got)
}
if got := values.Get("labels"); got != "bug" {
t.Errorf("labels = %q, want bug", got)
}
if got := values.Get("scope"); got != "all" {
t.Errorf("scope = %q, want all (untouched keys keep their default)", got)
}
}
func TestAppendFilter_MultiValueOverride(t *testing.T) {
values := url.Values{}
values.Set("labels", "default")
appendFilter(values, "labels=a&labels=b")
got := values["labels"]
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Errorf("labels = %v, want [a b] (default cleared then both user values appended)", got)
}
}
func TestAppendFilter_UnparseableIgnored(t *testing.T) {
values := url.Values{}
values.Set("state", "opened")
appendFilter(values, "%bad%")
if got := values.Get("state"); got != "opened" {
t.Errorf("state = %q, want opened (unparseable filter must not nuke defaults)", got)
}
}
@@ -0,0 +1,79 @@
package gitlab
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// connectivityTimeout bounds the host-reachability probe.
const connectivityTimeout = 5 * time.Second
// validateHost parses and validates a GitLab base URL, returning the parsed
// URL with only the fields we'll use copied across. Splitting validation
// from request construction makes both the unit tests and the SSRF surface
// tractable: anything that escapes this function has a known-good scheme
// and a non-empty host.
func validateHost(host string) (*url.URL, error) {
if host == "" {
host = DefaultHost
}
parsed, err := url.Parse(host)
if err != nil {
return nil, err
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, errors.New("host must use http or https scheme")
}
if parsed.Host == "" {
return nil, errors.New("host is missing a hostname")
}
if parsed.User != nil {
return nil, errors.New("host must not contain userinfo (user:password@host is not allowed)")
}
return &url.URL{
Scheme: parsed.Scheme,
Host: parsed.Host,
Path: strings.TrimRight(parsed.Path, "/"),
}, nil
}
// CheckHost reports whether the given GitLab host is reachable. It issues
// an unauthenticated GET to /api/v4/version and treats any HTTP response
// (including 401) as "reachable" — only network-level errors count as a
// failure. Used by the settings page when the user enters a self-managed
// host URL, before they configure a token.
//
// The host is parsed and its scheme verified to be http/https before the
// request is built. The probe URL is composed via net/url rather than string
// concatenation so user input cannot smuggle in a different host or path —
// this is what makes the call safe against SSRF.
func CheckHost(ctx context.Context, host string) error {
base, err := validateHost(host)
if err != nil {
return err
}
probe := *base
probe.Path = base.Path + apiPathPrefix + "/version"
cctx, cancel := context.WithTimeout(ctx, connectivityTimeout)
defer cancel()
req, err := http.NewRequestWithContext(cctx, http.MethodGet, probe.String(), nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
// Drain so http.DefaultClient's transport can reuse the connection on
// the next probe — same pattern as PATClient.getWithTotal / doWrite.
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
return nil
}
@@ -0,0 +1,104 @@
package gitlab
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestValidateHost(t *testing.T) {
cases := []struct {
name string
in string
wantErr string // substring; "" = success
wantPath string // expected base.Path after validation
}{
{"default-when-empty", "", "", ""},
{"https-bare", "https://gitlab.example.com", "", ""},
{"http-allowed", "http://gitlab.local", "", ""},
{"trailing-slash-trimmed", "https://gitlab.example.com/", "", ""},
{"subpath-preserved", "https://gitlab.acme.corp/gitlab", "", "/gitlab"},
{"subpath-trailing-slash-trimmed", "https://gitlab.acme.corp/gitlab/", "", "/gitlab"},
{"missing-scheme", "gitlab.example.com", "scheme", ""},
{"ftp-scheme-rejected", "ftp://gitlab.example.com", "scheme", ""},
{"empty-host", "https://", "hostname", ""},
{"userinfo-rejected", "https://user:pw@gitlab.example.com", "userinfo", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := validateHost(tc.in)
if tc.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("err = %v, want substring %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if got.Path != tc.wantPath {
t.Errorf("Path = %q, want %q", got.Path, tc.wantPath)
}
if got.Scheme != "http" && got.Scheme != "https" {
t.Errorf("scheme escaped validation: %q", got.Scheme)
}
})
}
}
func TestCheckHost_ProbesVersionEndpoint(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.URL.Path != "/api/v4/version" {
t.Errorf("path = %q, want /api/v4/version", r.URL.Path)
}
w.WriteHeader(http.StatusUnauthorized) // 401 still counts as reachable
}))
defer srv.Close()
if err := CheckHost(context.Background(), srv.URL); err != nil {
t.Fatalf("err = %v, want nil (401 is a reachable signal)", err)
}
if !called {
t.Fatal("server was not called")
}
}
func TestCheckHost_PreservesBasePath(t *testing.T) {
called := false
mux := http.NewServeMux()
mux.HandleFunc("/gitlab/api/v4/version", func(w http.ResponseWriter, _ *http.Request) {
called = true
_, _ = w.Write([]byte(`{"version":"16.0.0"}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
if err := CheckHost(context.Background(), srv.URL+"/gitlab"); err != nil {
t.Fatalf("err = %v, want nil for host with /gitlab base path", err)
}
if !called {
t.Fatal("server was not called at /gitlab/api/v4/version — base path was dropped")
}
}
func TestCheckHost_RejectsBadScheme(t *testing.T) {
if err := CheckHost(context.Background(), "ftp://gitlab.example.com"); err == nil {
t.Fatal("expected scheme rejection")
}
}
func TestCheckHost_UnreachableHostReturnsError(t *testing.T) {
// 192.0.2.0/24 is reserved for documentation (RFC 5737); connecting
// to it deterministically fails.
err := CheckHost(context.Background(), "http://192.0.2.1:1")
if err == nil {
t.Fatal("expected network error")
}
}
+12
View File
@@ -0,0 +1,12 @@
package gitlab
// Auth method constants.
const (
AuthMethodNone = "none"
AuthMethodPAT = "pat"
AuthMethodGLab = "glab_cli"
)
// DefaultHost is the public GitLab.com host. Self-managed instances override
// this via the per-workspace gitlab_host setting.
const DefaultHost = "https://gitlab.com"
+269
View File
@@ -0,0 +1,269 @@
package gitlab
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
)
// Controller handles HTTP endpoints for GitLab integration.
type Controller struct {
service *Service
logger *logger.Logger
}
// NewController creates a new GitLab controller.
func NewController(svc *Service, log *logger.Logger) *Controller {
return &Controller{service: svc, logger: log}
}
// RegisterHTTPRoutes registers the v1 HTTP surface.
func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api := router.Group("/api/v1/gitlab")
api.GET("/status", c.httpGetStatus)
api.POST("/token", c.httpConfigureToken)
api.DELETE("/token", c.httpClearToken)
api.POST("/host", c.httpConfigureHost)
api.GET("/mrs/feedback", c.httpGetMRFeedback)
api.POST("/mrs/discussions/notes", c.httpCreateDiscussionNote)
api.POST("/mrs/discussions/resolve", c.httpResolveDiscussion)
api.GET("/workspaces/:workspaceID/task-mrs", c.httpListWorkspaceTaskMRs)
api.GET("/tasks/:taskID/mrs", c.httpListTaskMRs)
api.POST("/tasks/:taskID/mrs/sync", c.httpSyncTaskMR)
api.GET("/user/mrs", c.httpSearchUserMRs)
api.GET("/user/issues", c.httpSearchUserIssues)
}
// RegisterRoutes is the package-level entrypoint mirroring github.RegisterRoutes.
func RegisterRoutes(router *gin.Engine, svc *Service, log *logger.Logger) {
NewController(svc, log).RegisterHTTPRoutes(router)
}
func (c *Controller) httpGetStatus(ctx *gin.Context) {
status, err := c.service.GetStatus(ctx.Request.Context())
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, status)
}
func (c *Controller) httpConfigureToken(ctx *gin.Context) {
var req ConfigureTokenRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: token is required"})
return
}
if err := c.service.ConfigureToken(ctx.Request.Context(), req.Token); err != nil {
// errors.Is against the package-level sentinel — durable across
// future rewording / wrapping of the ConfigureToken error message.
if errors.Is(err, ErrInvalidToken) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"configured": true})
}
func (c *Controller) httpClearToken(ctx *gin.Context) {
if err := c.service.ClearToken(ctx.Request.Context()); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"cleared": true})
}
func (c *Controller) httpConfigureHost(ctx *gin.Context) {
var req ConfigureHostRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: host is required"})
return
}
if err := c.service.ConfigureHost(ctx.Request.Context(), req.Host); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"configured": true, "host": c.service.Host()})
}
func (c *Controller) httpGetMRFeedback(ctx *gin.Context) {
projectPath, iid, err := parseProjectAndIID(ctx)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
feedback, err := c.service.GetMRFeedback(ctx.Request.Context(), projectPath, iid)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, feedback)
}
func (c *Controller) httpCreateDiscussionNote(ctx *gin.Context) {
var req struct {
Project string `json:"project" binding:"required"`
IID int `json:"iid" binding:"required"`
DiscussionID string `json:"discussion_id" binding:"required"`
Body string `json:"body" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
note, err := c.service.CreateMRDiscussionNote(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID, req.Body)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, note)
}
func (c *Controller) httpResolveDiscussion(ctx *gin.Context) {
var req struct {
Project string `json:"project" binding:"required"`
IID int `json:"iid" binding:"required"`
DiscussionID string `json:"discussion_id" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if err := c.service.ResolveMRDiscussion(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"resolved": true})
}
// parseProjectAndIID reads ?project=<path>&iid=<n> query params and validates
// them. project is the namespace/path slug (URL-decoded by Gin); iid must be
// a positive integer.
func parseProjectAndIID(ctx *gin.Context) (string, int, error) {
project := ctx.Query("project")
if project == "" {
return "", 0, errors.New("project query param required")
}
iidStr := ctx.Query("iid")
if iidStr == "" {
return "", 0, errors.New("iid query param required")
}
iid, err := strconv.Atoi(iidStr)
if err != nil || iid <= 0 {
return "", 0, errors.New("iid must be a positive integer")
}
return project, iid, nil
}
// SyncTaskMRRequest is the JSON body for POST /tasks/:taskID/mrs/sync.
// project_path is "namespace/path"; iid is the MR's per-project sequential id;
// repository_id is the kandev repository UUID (empty for single-repo tasks).
type SyncTaskMRRequest struct {
ProjectPath string `json:"project_path" binding:"required"`
IID int `json:"iid" binding:"required"`
RepositoryID string `json:"repository_id"`
}
func (c *Controller) httpListWorkspaceTaskMRs(ctx *gin.Context) {
wsID := ctx.Param("workspaceID")
if wsID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceID required"})
return
}
taskMRs, err := c.service.ListTaskMRsByWorkspace(ctx.Request.Context(), wsID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, TaskMRsResponse{TaskMRs: taskMRs})
}
func (c *Controller) httpListTaskMRs(ctx *gin.Context) {
taskID := ctx.Param("taskID")
if taskID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "taskID required"})
return
}
mrs, err := c.service.ListTaskMRsByTask(ctx.Request.Context(), taskID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"task_mrs": mrs})
}
func (c *Controller) httpSyncTaskMR(ctx *gin.Context) {
taskID := ctx.Param("taskID")
if taskID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "taskID required"})
return
}
var req SyncTaskMRRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: project_path and iid required"})
return
}
row, err := c.service.SyncTaskMR(ctx.Request.Context(), taskID, req.RepositoryID, req.ProjectPath, req.IID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, row)
}
// httpSearchUserMRs surfaces the configured user's MR queue. filter is one of
// "assigned", "authored", "review_requested" (matching the GitLab "scope"
// query param); custom_query passes through verbatim for power users.
func (c *Controller) httpSearchUserMRs(ctx *gin.Context) {
page, perPage := paginationFromQuery(ctx)
result, err := c.service.Client().SearchMRsPaged(
ctx.Request.Context(),
ctx.Query("filter"),
ctx.Query("custom_query"),
page, perPage,
)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, result)
}
// httpSearchUserIssues surfaces the configured user's issue queue.
func (c *Controller) httpSearchUserIssues(ctx *gin.Context) {
page, perPage := paginationFromQuery(ctx)
result, err := c.service.Client().ListIssuesPaged(
ctx.Request.Context(),
ctx.Query("filter"),
ctx.Query("custom_query"),
page, perPage,
)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, result)
}
// paginationFromQuery reads ?page=&per_page= with the same clamps SearchMRsPaged
// applies internally — surfaced here so 400s on bad input are uniform.
func paginationFromQuery(ctx *gin.Context) (int, int) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
perPage, _ := strconv.Atoi(ctx.DefaultQuery("per_page", "50"))
if perPage <= 0 {
perPage = 50
}
return page, perPage
}
+85
View File
@@ -0,0 +1,85 @@
package gitlab
import (
"context"
"fmt"
"os"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// SecretProvider is the interface the factory uses to look up a GitLab PAT.
type SecretProvider interface {
List(ctx context.Context) ([]*SecretListItem, error)
Reveal(ctx context.Context, id string) (string, error)
}
// SecretListItem mirrors secrets.SecretListItem to avoid a direct import cycle.
type SecretListItem struct {
ID string `json:"id"`
Name string `json:"name"`
HasValue bool `json:"has_value"`
}
// NewClient builds a GitLab client using the best available auth method.
// Selection order: mock env var → glab CLI (if authenticated for host) →
// PAT (env var GITLAB_TOKEN, then secrets store name GITLAB_TOKEN /
// gitlab_token) → noop.
//
// host configures the GitLab base URL; pass "" for the public default.
func NewClient(ctx context.Context, host string, secrets SecretProvider, log *logger.Logger) (Client, string, error) {
if host == "" {
host = DefaultHost
}
if os.Getenv("KANDEV_MOCK_GITLAB") == "true" {
log.Info("using mock client for GitLab integration")
return NewMockClient(host), "mock", nil
}
if GLabAvailable() {
client, err := NewGLabClient(ctx, host)
if err == nil {
log.Info("using glab CLI for GitLab integration", zap.String("host", host))
return client, AuthMethodGLab, nil
}
log.Debug("glab CLI available but not authenticated", zap.Error(err))
}
if token := os.Getenv("GITLAB_TOKEN"); token != "" {
log.Info("using GITLAB_TOKEN from environment for GitLab integration")
return NewPATClient(host, token), AuthMethodPAT, nil
}
if secrets != nil {
token, err := findPAT(ctx, secrets)
if err == nil && token != "" {
log.Info("using PAT from secrets store for GitLab integration")
return NewPATClient(host, token), AuthMethodPAT, nil
}
if err != nil {
log.Debug("failed to find GitLab PAT in secrets", zap.Error(err))
}
}
return NewNoopClient(host), AuthMethodNone, nil
}
// findPAT looks for a secret named "GITLAB_TOKEN" or "gitlab_token".
func findPAT(ctx context.Context, secrets SecretProvider) (string, error) {
items, err := secrets.List(ctx)
if err != nil {
return "", fmt.Errorf("list secrets: %w", err)
}
for _, item := range items {
if !item.HasValue {
continue
}
if item.Name == "GITLAB_TOKEN" || item.Name == "gitlab_token" {
return secrets.Reveal(ctx, item.ID)
}
}
return "", nil
}
@@ -0,0 +1,163 @@
package gitlab
import (
"context"
"testing"
"github.com/kandev/kandev/internal/common/logger"
)
// newTestLogger builds a quiet logger for tests that exercise the factory.
func newTestLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
if err != nil {
t.Fatalf("logger: %v", err)
}
return log
}
type stubSecrets struct {
items []*SecretListItem
tokens map[string]string
listErr error
}
func (s *stubSecrets) List(context.Context) ([]*SecretListItem, error) {
if s.listErr != nil {
return nil, s.listErr
}
return s.items, nil
}
func (s *stubSecrets) Reveal(_ context.Context, id string) (string, error) {
return s.tokens[id], nil
}
func TestNewClient_MockEnvShortcircuits(t *testing.T) {
t.Setenv("KANDEV_MOCK_GITLAB", "true")
log := newTestLogger(t)
c, method, err := NewClient(context.Background(), "", nil, log)
if err != nil {
t.Fatalf("err = %v", err)
}
if _, ok := c.(*MockClient); !ok {
t.Fatalf("got %T, want *MockClient", c)
}
if method != "mock" {
t.Errorf("method = %q, want mock", method)
}
}
func TestNewClient_EnvTokenBeatsSecrets(t *testing.T) {
t.Setenv("KANDEV_MOCK_GITLAB", "")
t.Setenv("GITLAB_TOKEN", "from-env")
log := newTestLogger(t)
secrets := &stubSecrets{
items: []*SecretListItem{{ID: "s1", Name: "GITLAB_TOKEN", HasValue: true}},
tokens: map[string]string{"s1": "from-secret"},
}
c, method, err := NewClient(context.Background(), "", secrets, log)
if err != nil {
t.Fatalf("err = %v", err)
}
pat, ok := c.(*PATClient)
if !ok {
// glab might be installed on the test runner — accept GLab as well
// only when the env-var path is unreachable. The branch under test
// here is env-var, so explicitly assert PATClient.
if _, isGLab := c.(*GLabClient); isGLab {
t.Skip("glab CLI present on test host; env-var branch is shadowed")
}
t.Fatalf("got %T, want *PATClient", c)
}
if pat.token != "from-env" {
t.Errorf("token = %q, want from-env", pat.token)
}
if method != AuthMethodPAT {
t.Errorf("method = %q, want pat", method)
}
}
func TestNewClient_FallsBackToSecrets(t *testing.T) {
t.Setenv("KANDEV_MOCK_GITLAB", "")
t.Setenv("GITLAB_TOKEN", "")
log := newTestLogger(t)
secrets := &stubSecrets{
items: []*SecretListItem{{ID: "s1", Name: "GITLAB_TOKEN", HasValue: true}},
tokens: map[string]string{"s1": "from-secret"},
}
c, _, err := NewClient(context.Background(), "https://gitlab.example.com", secrets, log)
if err != nil {
t.Fatalf("err = %v", err)
}
if _, ok := c.(*PATClient); !ok {
if _, isGLab := c.(*GLabClient); isGLab {
t.Skip("glab CLI present on test host; secret-store branch is shadowed")
}
t.Fatalf("got %T, want *PATClient", c)
}
}
func TestNewClient_NoopWhenUnconfigured(t *testing.T) {
t.Setenv("KANDEV_MOCK_GITLAB", "")
t.Setenv("GITLAB_TOKEN", "")
log := newTestLogger(t)
c, method, err := NewClient(context.Background(), "", nil, log)
if err != nil {
t.Fatalf("err = %v", err)
}
if _, ok := c.(*NoopClient); !ok {
if _, isGLab := c.(*GLabClient); isGLab {
t.Skip("glab CLI present on test host; noop branch is shadowed")
}
t.Fatalf("got %T, want *NoopClient", c)
}
if method != AuthMethodNone {
t.Errorf("method = %q, want none", method)
}
}
func TestNewClient_AcceptsLowercaseSecretName(t *testing.T) {
t.Setenv("KANDEV_MOCK_GITLAB", "")
t.Setenv("GITLAB_TOKEN", "")
log := newTestLogger(t)
secrets := &stubSecrets{
items: []*SecretListItem{{ID: "s2", Name: "gitlab_token", HasValue: true}},
tokens: map[string]string{"s2": "lc"},
}
c, _, err := NewClient(context.Background(), "", secrets, log)
if err != nil {
t.Fatalf("err = %v", err)
}
if _, ok := c.(*PATClient); !ok {
if _, isGLab := c.(*GLabClient); isGLab {
t.Skip("glab CLI present on test host; secret-store branch is shadowed")
}
t.Fatalf("got %T, want *PATClient", c)
}
}
func TestNewClient_IgnoresSecretsWithoutValue(t *testing.T) {
t.Setenv("KANDEV_MOCK_GITLAB", "")
t.Setenv("GITLAB_TOKEN", "")
log := newTestLogger(t)
secrets := &stubSecrets{
items: []*SecretListItem{{ID: "s3", Name: "GITLAB_TOKEN", HasValue: false}},
}
c, _, err := NewClient(context.Background(), "", secrets, log)
if err != nil {
t.Fatalf("err = %v", err)
}
if _, ok := c.(*NoopClient); !ok {
if _, isGLab := c.(*GLabClient); isGLab {
t.Skip("glab CLI present on test host; noop branch is shadowed")
}
t.Fatalf("got %T, want *NoopClient (HasValue=false should be skipped)", c)
}
}
+158
View File
@@ -0,0 +1,158 @@
package gitlab
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
// glabAuthTimeout bounds glab subprocess execution.
const glabAuthTimeout = 10 * time.Second
// GLabClient implements Client by piggy-backing on the user's glab CLI
// configuration. It discovers the host and token via `glab auth status`,
// then delegates every API call to an embedded PATClient.
//
// Versus a pure shell-out client, this approach is simpler (no per-command
// JSON parsing) and stays consistent with the REST surface that
// PATClient exercises in tests.
type GLabClient struct {
*PATClient
version string
}
// GLabAvailable checks if the glab CLI is installed and on PATH.
func GLabAvailable() bool {
_, err := exec.LookPath("glab")
return err == nil
}
// NewGLabClient discovers glab's configured host and token for the given
// targetHost (or the default if empty), and returns a Client that signs
// requests with that token. Returns an error when glab is not available
// or not authenticated for targetHost.
func NewGLabClient(ctx context.Context, targetHost string) (*GLabClient, error) {
if !GLabAvailable() {
return nil, errors.New("glab CLI not installed")
}
host := targetHost
if host == "" {
host = DefaultHost
}
hostname := stripScheme(host)
token, err := glabReadToken(ctx, hostname)
if err != nil {
return nil, err
}
if token == "" {
return nil, fmt.Errorf("glab not authenticated for %s", hostname)
}
pat := NewPATClient(host, token)
return &GLabClient{
PATClient: pat,
version: glabVersion(ctx),
}, nil
}
// Version reports the glab CLI version (best effort, "" if unavailable).
func (c *GLabClient) Version() string { return c.version }
// RunAuthDiagnostics executes `glab auth status` and captures the raw
// output for troubleshooting. Mirrors GitHub's RunAuthDiagnostics.
func (c *GLabClient) RunAuthDiagnostics(ctx context.Context) *AuthDiagnostics {
cctx, cancel := context.WithTimeout(ctx, glabAuthTimeout)
defer cancel()
cmd := exec.CommandContext(cctx, "glab", "auth", "status", "--hostname", stripScheme(c.Host()))
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
exitCode := 0
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
exitCode = -1
}
}
output := stderr.String()
if output == "" {
output = stdout.String()
}
return &AuthDiagnostics{
Command: "glab auth status --hostname " + stripScheme(c.Host()),
Output: output,
ExitCode: exitCode,
}
}
// glabReadToken extracts the token glab uses for the given hostname. glab's
// `auth status -t` prints the token to stderr; this function captures both
// streams and parses the line "Token: <value>".
func glabReadToken(ctx context.Context, hostname string) (string, error) {
cctx, cancel := context.WithTimeout(ctx, glabAuthTimeout)
defer cancel()
cmd := exec.CommandContext(cctx, "glab", "auth", "status", "--hostname", hostname, "-t")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Even when glab exits non-zero we may still have a token printed —
// e.g. a different account on the same host triggers a warning. Try
// to parse before giving up.
token := parseGlabToken(stdout.String() + "\n" + stderr.String())
if token != "" {
return token, nil
}
return "", fmt.Errorf("glab auth status: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
return parseGlabToken(stdout.String() + "\n" + stderr.String()), nil
}
// parseGlabToken finds a "Token: <value>" line in the combined output of
// `glab auth status -t` and returns the token (or "" if none).
func parseGlabToken(output string) string {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
idx := strings.Index(strings.ToLower(line), "token:")
if idx < 0 {
continue
}
token := strings.TrimSpace(line[idx+len("token:"):])
// glab sometimes prefixes lines with ANSI / arrows; strip leading
// non-alphanumerics until we hit token characters.
token = strings.TrimLeft(token, " \t-→>")
if token != "" && token != "<no token>" {
return token
}
}
return ""
}
// glabVersion runs `glab --version` and extracts the semver string.
// Returns "" on any failure.
func glabVersion(ctx context.Context) string {
cctx, cancel := context.WithTimeout(ctx, glabAuthTimeout)
defer cancel()
out, err := exec.CommandContext(cctx, "glab", "--version").Output()
if err != nil {
return ""
}
for _, field := range strings.Fields(string(out)) {
if strings.Count(field, ".") >= 2 {
return strings.TrimSpace(field)
}
}
return ""
}
func stripScheme(host string) string {
host = strings.TrimPrefix(host, "https://")
host = strings.TrimPrefix(host, "http://")
return strings.TrimRight(host, "/")
}
@@ -0,0 +1,48 @@
package gitlab
import "testing"
func TestParseGlabToken_ExtractsValue(t *testing.T) {
output := `Logged in to gitlab.com as alice (oauth_token)
✓ Token: glpat-AAAAA-BBBBB
✓ Token scopes: api`
got := parseGlabToken(output)
if got != "glpat-AAAAA-BBBBB" {
t.Errorf("got %q, want glpat-AAAAA-BBBBB", got)
}
}
func TestParseGlabToken_LowercaseLabel(t *testing.T) {
got := parseGlabToken("token: glpat-xyz")
if got != "glpat-xyz" {
t.Errorf("got %q, want glpat-xyz", got)
}
}
func TestParseGlabToken_NoToken(t *testing.T) {
got := parseGlabToken("Token: <no token>")
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestParseGlabToken_Empty(t *testing.T) {
if got := parseGlabToken(""); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestStripScheme(t *testing.T) {
cases := map[string]string{
"https://gitlab.com": "gitlab.com",
"http://gitlab.acme.corp": "gitlab.acme.corp",
"https://gitlab.com/": "gitlab.com",
"gitlab.example.com": "gitlab.example.com",
"https://gitlab.example.com/": "gitlab.example.com",
}
for in, want := range cases {
if got := stripScheme(in); got != want {
t.Errorf("stripScheme(%q) = %q, want %q", in, got, want)
}
}
}
+417
View File
@@ -0,0 +1,417 @@
package gitlab
import (
"context"
"fmt"
"sync"
"time"
)
// MockClient is an in-memory GitLab client used by E2E tests.
// Activated when KANDEV_MOCK_GITLAB=true. It serves a small fixed set of
// MRs/issues/discussions plus accepts dynamic seeding via mock_controller.
//
// The mock intentionally implements the full Client interface but covers
// only the fields E2E flows exercise — the goal is to drive UI tests, not
// to emulate GitLab faithfully.
type MockClient struct {
host string
mu sync.Mutex
username string
mrs map[mockMRKey]*MR
discussions map[mockMRKey][]MRDiscussion
// pipelines is keyed by project — ListPipelines below returns the
// project's seeded set regardless of branch or iid, matching the
// real PATClient.ListPipelines flow (one MR head ref → all
// pipelines for that project). Keying by mockMRKey here would
// make iteration order matter when multiple MRs share a project.
pipelines map[string][]Pipeline
// approvals tracks who has approved each MR. requiredApprovals tracks
// the project-level required-count GitLab returns alongside the
// approved_by list. Both are seeded separately because the GitLab
// /approvals endpoint conflates them on one payload — the mock keeps
// them split so tests can express e.g. "2 approvals required, 1 given".
approvals map[mockMRKey][]MRApproval
requiredApprovals map[mockMRKey]int
issues map[mockIssueKey]*Issue
branches map[string][]RepoBranch
nextMRIID int
}
type mockMRKey struct {
Project string
IID int
}
type mockIssueKey struct {
Project string
IID int
}
// NewMockClient builds a fresh mock with a small canned dataset.
func NewMockClient(host string) *MockClient {
if host == "" {
host = DefaultHost
}
c := &MockClient{
host: host,
username: "kandev-tester",
mrs: make(map[mockMRKey]*MR),
discussions: make(map[mockMRKey][]MRDiscussion),
pipelines: make(map[string][]Pipeline),
approvals: make(map[mockMRKey][]MRApproval),
requiredApprovals: make(map[mockMRKey]int),
issues: make(map[mockIssueKey]*Issue),
branches: make(map[string][]RepoBranch),
nextMRIID: 100,
}
return c
}
// SetUser overrides the authenticated user reported by the mock.
func (c *MockClient) SetUser(username string) {
c.mu.Lock()
defer c.mu.Unlock()
c.username = username
}
// SeedMR registers an MR for (projectPath, iid). If iid == 0 the mock
// assigns one and returns it. The MR is stored verbatim.
func (c *MockClient) SeedMR(projectPath string, mr *MR) int {
c.mu.Lock()
defer c.mu.Unlock()
iid := mr.IID
if iid == 0 {
iid = c.nextMRIID
c.nextMRIID++
mr.IID = iid
}
mr.ProjectPath = projectPath
c.mrs[mockMRKey{Project: projectPath, IID: iid}] = mr
return iid
}
// SeedDiscussions sets the discussions returned for an MR.
func (c *MockClient) SeedDiscussions(projectPath string, iid int, discussions []MRDiscussion) {
c.mu.Lock()
defer c.mu.Unlock()
c.discussions[mockMRKey{Project: projectPath, IID: iid}] = discussions
}
// SeedIssue registers an issue.
func (c *MockClient) SeedIssue(projectPath string, issue *Issue) {
c.mu.Lock()
defer c.mu.Unlock()
issue.ProjectPath = projectPath
c.issues[mockIssueKey{Project: projectPath, IID: issue.IID}] = issue
}
// SeedBranches sets the branches returned for a project.
func (c *MockClient) SeedBranches(projectPath string, branches []RepoBranch) {
c.mu.Lock()
defer c.mu.Unlock()
c.branches[projectPath] = branches
}
// SeedPipelines registers the pipelines returned for projectPath.
// The mock's ListPipelines returns every pipeline seeded under the project
// regardless of branch, mirroring how the real PATClient surfaces a single
// project-level pipeline list. Keyed by project (not MR iid) so two MRs in
// the same project share one canonical list — calling SeedPipelines twice
// overwrites rather than racing on map iteration order.
func (c *MockClient) SeedPipelines(projectPath string, pipelines []Pipeline) {
c.mu.Lock()
defer c.mu.Unlock()
c.pipelines[projectPath] = pipelines
}
// SeedApprovals registers the approval state for (projectPath, iid):
// the list of who has approved + the required-count for "approved" to
// flip on. Tests can express "1 of 2 approvals" by passing one approver
// and required=2.
func (c *MockClient) SeedApprovals(projectPath string, iid int, approvals []MRApproval, required int) {
c.mu.Lock()
defer c.mu.Unlock()
key := mockMRKey{Project: projectPath, IID: iid}
c.approvals[key] = approvals
c.requiredApprovals[key] = required
}
func (c *MockClient) Host() string { return c.host }
func (c *MockClient) IsAuthenticated(context.Context) (bool, error) {
return true, nil
}
func (c *MockClient) GetAuthenticatedUser(context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.username, nil
}
func (c *MockClient) GetMR(_ context.Context, projectPath string, iid int) (*MR, error) {
c.mu.Lock()
defer c.mu.Unlock()
mr, ok := c.mrs[mockMRKey{Project: projectPath, IID: iid}]
if !ok {
return nil, fmt.Errorf("mock: MR %s!%d not found", projectPath, iid)
}
return mr, nil
}
func (c *MockClient) FindMRByBranch(_ context.Context, projectPath, branch string) (*MR, error) {
c.mu.Lock()
defer c.mu.Unlock()
for _, mr := range c.mrs {
if mr.ProjectPath == projectPath && mr.HeadBranch == branch && mr.State == mrStateOpen {
return mr, nil
}
}
return nil, nil
}
func (c *MockClient) ListAuthoredMRs(_ context.Context, projectPath string) ([]*MR, error) {
c.mu.Lock()
defer c.mu.Unlock()
user := c.username
out := []*MR{}
for _, mr := range c.mrs {
if mr.ProjectPath == projectPath && mr.AuthorUsername == user {
out = append(out, mr)
}
}
return out, nil
}
func (c *MockClient) ListReviewRequestedMRs(context.Context, string, string) ([]*MR, error) {
c.mu.Lock()
defer c.mu.Unlock()
out := []*MR{}
for _, mr := range c.mrs {
for _, r := range mr.Reviewers {
if r.Username == c.username {
out = append(out, mr)
break
}
}
}
return out, nil
}
func (c *MockClient) ListUserGroups(context.Context) ([]Group, error) {
return []Group{{ID: 1, Path: "kandev", Name: "Kandev"}}, nil
}
func (c *MockClient) SearchGroupProjects(context.Context, string, string, int) ([]Project, error) {
return []Project{}, nil
}
func (c *MockClient) ListMRApprovals(_ context.Context, projectPath string, iid int) ([]MRApproval, error) {
c.mu.Lock()
defer c.mu.Unlock()
if a, ok := c.approvals[mockMRKey{Project: projectPath, IID: iid}]; ok {
return a, nil
}
return []MRApproval{}, nil
}
func (c *MockClient) ListMRDiscussions(_ context.Context, projectPath string, iid int, _ *time.Time) ([]MRDiscussion, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.discussions[mockMRKey{Project: projectPath, IID: iid}], nil
}
func (c *MockClient) CreateMRDiscussionNote(_ context.Context, projectPath string, iid int, discussionID, body string) (*MRNote, error) {
c.mu.Lock()
defer c.mu.Unlock()
key := mockMRKey{Project: projectPath, IID: iid}
now := time.Now().UTC()
note := MRNote{
ID: now.UnixNano(),
Author: c.username,
Body: body,
CreatedAt: now,
UpdatedAt: now,
}
for i, d := range c.discussions[key] {
if d.ID == discussionID {
c.discussions[key][i].Notes = append(c.discussions[key][i].Notes, note)
c.discussions[key][i].UpdatedAt = now
return &note, nil
}
}
return nil, fmt.Errorf("mock: discussion %s not found", discussionID)
}
func (c *MockClient) ResolveMRDiscussion(_ context.Context, projectPath string, iid int, discussionID string) error {
c.mu.Lock()
defer c.mu.Unlock()
key := mockMRKey{Project: projectPath, IID: iid}
for i, d := range c.discussions[key] {
if d.ID == discussionID {
c.discussions[key][i].Resolved = true
return nil
}
}
return fmt.Errorf("mock: discussion %s not found", discussionID)
}
func (c *MockClient) ListPipelines(_ context.Context, projectPath, _ string) ([]Pipeline, error) {
c.mu.Lock()
defer c.mu.Unlock()
if p, ok := c.pipelines[projectPath]; ok {
return p, nil
}
return []Pipeline{}, nil
}
func (c *MockClient) GetMRFeedback(ctx context.Context, projectPath string, iid int) (*MRFeedback, error) {
mr, err := c.GetMR(ctx, projectPath, iid)
if err != nil {
return nil, err
}
d, _ := c.ListMRDiscussions(ctx, projectPath, iid, nil)
approvals, _ := c.ListMRApprovals(ctx, projectPath, iid)
// Mirror PATClient.GetMRFeedback: only consider pipelines when the MR
// actually has a head ref. MockClient.ListPipelines ignores its branch
// argument and returns every pipeline seeded under the project, so
// without this guard a fresh MR with no head would still inherit a
// failing pipeline from a sibling MR in the same project.
var pipelines []Pipeline
if mr.HeadSHA != "" || mr.HeadBranch != "" {
pipelines, _ = c.ListPipelines(ctx, projectPath, mr.HeadBranch)
}
return &MRFeedback{
MR: mr,
Approvals: approvals,
Discussions: d,
Pipelines: pipelines,
HasIssues: hasOpenDiscussions(d) || pipelineFailing(pipelines),
}, nil
}
func (c *MockClient) GetMRStatus(ctx context.Context, projectPath string, iid int) (*MRStatus, error) {
mr, err := c.GetMR(ctx, projectPath, iid)
if err != nil {
return nil, err
}
var pipelines []Pipeline
if mr.HeadSHA != "" || mr.HeadBranch != "" {
pipelines, _ = c.ListPipelines(ctx, projectPath, mr.HeadBranch)
}
approvals, _ := c.ListMRApprovals(ctx, projectPath, iid)
c.mu.Lock()
required := c.requiredApprovals[mockMRKey{Project: projectPath, IID: iid}]
c.mu.Unlock()
pipelineState, jobsTotal, jobsPassing := summarizePipelines(pipelines)
approvalState := summarizeApprovals(len(approvals), required)
return &MRStatus{
MR: mr,
ApprovalState: approvalState,
PipelineState: pipelineState,
MergeStatus: mr.MergeStatus,
ApprovalCount: len(approvals),
RequiredApprovals: required,
PipelineJobsTotal: jobsTotal,
PipelineJobsPassing: jobsPassing,
}, nil
}
func (c *MockClient) ListMRFiles(context.Context, string, int) ([]MRFile, error) {
return []MRFile{}, nil
}
func (c *MockClient) ListMRCommits(context.Context, string, int) ([]MRCommitInfo, error) {
return []MRCommitInfo{}, nil
}
func (c *MockClient) SubmitMRApproval(context.Context, string, int) error { return nil }
func (c *MockClient) SubmitMRUnapproval(context.Context, string, int) error { return nil }
func (c *MockClient) CreateMR(_ context.Context, projectPath, sourceBranch, targetBranch, title, description string, draft bool) (*MR, error) {
c.mu.Lock()
defer c.mu.Unlock()
iid := c.nextMRIID
c.nextMRIID++
mr := &MR{
IID: iid,
Title: title,
Body: description,
HeadBranch: sourceBranch,
BaseBranch: targetBranch,
State: mrStateOpen,
Draft: draft,
AuthorUsername: c.username,
ProjectPath: projectPath,
WebURL: fmt.Sprintf("%s/%s/-/merge_requests/%d", c.host, projectPath, iid),
URL: fmt.Sprintf("%s/%s/-/merge_requests/%d", c.host, projectPath, iid),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
c.mrs[mockMRKey{Project: projectPath, IID: iid}] = mr
return mr, nil
}
func (c *MockClient) ListProjectBranches(_ context.Context, projectPath string) ([]RepoBranch, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.branches[projectPath], nil
}
func (c *MockClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
c.mu.Lock()
defer c.mu.Unlock()
out := []*Issue{}
for _, i := range c.issues {
out = append(out, i)
}
return out, nil
}
func (c *MockClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
issues, _ := c.ListIssues(ctx, filter, customQuery)
return &IssueSearchPage{Issues: issues, TotalCount: len(issues), Page: page, PerPage: perPage}, nil
}
func (c *MockClient) SearchMRs(context.Context, string, string) ([]*MR, error) {
c.mu.Lock()
defer c.mu.Unlock()
out := []*MR{}
for _, mr := range c.mrs {
out = append(out, mr)
}
return out, nil
}
func (c *MockClient) SearchMRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*MRSearchPage, error) {
mrs, _ := c.SearchMRs(ctx, filter, customQuery)
return &MRSearchPage{MRs: mrs, TotalCount: len(mrs), Page: page, PerPage: perPage}, nil
}
func (c *MockClient) GetIssueState(_ context.Context, projectPath string, iid int) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if i, ok := c.issues[mockIssueKey{Project: projectPath, IID: iid}]; ok {
return i.State, nil
}
return "", fmt.Errorf("mock: issue %s#%d not found", projectPath, iid)
}
// Stats returns a summary of the seeded data, useful for E2E assertions.
func (c *MockClient) Stats() string {
c.mu.Lock()
defer c.mu.Unlock()
return fmt.Sprintf(
"mrs=%d discussions=%d issues=%d",
len(c.mrs), totalDiscussions(c.discussions), len(c.issues),
)
}
func totalDiscussions(m map[mockMRKey][]MRDiscussion) int {
total := 0
for _, d := range m {
total += len(d)
}
return total
}
@@ -0,0 +1,94 @@
package gitlab
import (
"context"
"testing"
)
// MockClient.ListPipelines is keyed by project only — it returns every
// pipeline seeded under the project regardless of the branch argument. Without
// the head-ref guard in GetMRFeedback that means a brand-new MR with no head
// SHA / branch would still inherit a sibling MR's failing pipeline and flip
// HasIssues to true. The real client guards on HeadSHA before probing
// pipelines; the mock must match.
func TestMockClient_GetMRFeedback_SkipsPipelinesWhenHeadEmpty(t *testing.T) {
mock := NewMockClient("")
const project = "team/repo"
// Seed a failing pipeline for the project — any MR without a head ref
// must NOT inherit it.
mock.SeedPipelines(project, []Pipeline{{Status: "failed"}})
mock.SeedMR(project, &MR{IID: 7, State: "open"}) // no HeadSHA, no HeadBranch
fb, err := mock.GetMRFeedback(context.Background(), project, 7)
if err != nil {
t.Fatalf("err = %v", err)
}
if len(fb.Pipelines) != 0 {
t.Errorf("pipelines = %d, want 0 (head ref empty — should not inherit project pipelines)", len(fb.Pipelines))
}
if fb.HasIssues {
t.Error("HasIssues = true on an MR with empty head ref and no failing discussions, want false")
}
}
// SeedPipelines is keyed by project so successive calls for the same project
// overwrite rather than living side-by-side in the map. Previously the seed
// API took (project, iid), which let two seeds for the same project coexist
// and made ListPipelines's iteration-order pick non-deterministic.
func TestMockClient_SeedPipelines_OverwritesByProject(t *testing.T) {
mock := NewMockClient("")
const project = "team/repo"
mock.SeedPipelines(project, []Pipeline{{Status: "failed"}})
mock.SeedPipelines(project, []Pipeline{{Status: "success"}})
got, err := mock.ListPipelines(context.Background(), project, "feat/x")
if err != nil {
t.Fatalf("err = %v", err)
}
if len(got) != 1 || got[0].Status != "success" {
t.Errorf("pipelines = %#v, want [{Status: success}] (second seed must overwrite)", got)
}
}
// MockClient.GetMRStatus must derive approval state from SeedApprovals so
// the same approval-gating logic the real client uses (summarizeApprovals
// against approved-vs-required) can be exercised end-to-end in tests.
func TestMockClient_GetMRStatus_UsesSeededApprovals(t *testing.T) {
mock := NewMockClient("")
const project = "team/repo"
mock.SeedMR(project, &MR{IID: 7, State: "open", HeadBranch: "feat/x", HeadSHA: "abc"})
mock.SeedApprovals(project, 7, []MRApproval{{Username: "alice"}}, 2)
st, err := mock.GetMRStatus(context.Background(), project, 7)
if err != nil {
t.Fatalf("err = %v", err)
}
if st.ApprovalCount != 1 {
t.Errorf("ApprovalCount = %d, want 1", st.ApprovalCount)
}
if st.RequiredApprovals != 2 {
t.Errorf("RequiredApprovals = %d, want 2", st.RequiredApprovals)
}
if st.ApprovalState != "pending" {
t.Errorf("ApprovalState = %q, want pending (1/2 approved)", st.ApprovalState)
}
}
func TestMockClient_GetMRFeedback_ReportsPipelinesWhenHeadPresent(t *testing.T) {
mock := NewMockClient("")
const project = "team/repo"
mock.SeedPipelines(project, []Pipeline{{Status: "failed"}})
mock.SeedMR(project, &MR{IID: 7, State: "open", HeadBranch: "feat/x", HeadSHA: "abc"})
fb, err := mock.GetMRFeedback(context.Background(), project, 7)
if err != nil {
t.Fatalf("err = %v", err)
}
if len(fb.Pipelines) != 1 {
t.Fatalf("pipelines = %d, want 1", len(fb.Pipelines))
}
if !fb.HasIssues {
t.Error("HasIssues = false despite a failing pipeline; want true")
}
}
+301
View File
@@ -0,0 +1,301 @@
// Package gitlab provides GitLab integration for Kandev: merge request
// monitoring, review queue management, pipeline status tracking, and
// discussion (review comment) interaction. Mirrors the surface of the
// internal/github package, adapted to GitLab nouns and the REST v4 API.
package gitlab
import "time"
// MR represents a GitLab Merge Request.
//
// IID is GitLab's per-project sequential ID (the number shown in the UI).
// ID is the global GitLab ID — required for some endpoints. The frontend
// keys on (ProjectPath, IID).
type MR struct {
ID int64 `json:"id"`
IID int `json:"iid"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
URL string `json:"url"`
WebURL string `json:"web_url"`
State string `json:"state"` // open, closed, merged, locked
HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"`
BaseBranch string `json:"base_branch"`
AuthorUsername string `json:"author_username"`
ProjectNamespace string `json:"project_namespace"`
ProjectPath string `json:"project_path"`
Body string `json:"body"`
Draft bool `json:"draft"`
MergeStatus string `json:"merge_status"` // can_be_merged, cannot_be_merged, unchecked, ...
HasConflicts bool `json:"has_conflicts"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Reviewers []MRReviewer `json:"reviewers"`
Assignees []MRReviewer `json:"assignees"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MergedAt *time.Time `json:"merged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// MRReviewer represents a reviewer or assignee on an MR.
// GitLab does not have team-level review requests, so Type is always "user".
type MRReviewer struct {
Username string `json:"username"`
Name string `json:"name"`
Type string `json:"type"`
}
// MRApproval represents a single approval on a merge request.
// GitLab approvals are simpler than GitHub reviews — there is no
// COMMENTED / DISMISSED state, only approved/not-approved.
type MRApproval struct {
Username string `json:"username"`
Avatar string `json:"avatar"`
CreatedAt time.Time `json:"created_at"`
}
// MRDiscussion represents a discussion thread on a merge request.
// Discussions are GitLab's review-comment unit: a top-level note, optionally
// anchored to a file/line, and zero or more reply notes. The Resolved flag
// is per-discussion (not per-note).
type MRDiscussion struct {
ID string `json:"id"`
Resolvable bool `json:"resolvable"`
Resolved bool `json:"resolved"`
Notes []MRNote `json:"notes"`
Path string `json:"path,omitempty"`
Line int `json:"line,omitempty"`
OldLine int `json:"old_line,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MRNote is a single message inside a discussion thread.
type MRNote struct {
ID int64 `json:"id"`
Author string `json:"author"`
AuthorAvatar string `json:"author_avatar"`
AuthorIsBot bool `json:"author_is_bot"`
Body string `json:"body"`
Type string `json:"type"` // DiffNote, DiscussionNote, or empty for regular notes
System bool `json:"system"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Pipeline represents a CI pipeline run associated with an MR.
// GitLab pipelines are richer than GitHub check runs — they have stages
// and jobs — but the UI surface only needs the rolled-up status.
type Pipeline struct {
ID int64 `json:"id"`
IID int `json:"iid"`
Status string `json:"status"` // running, pending, success, failed, canceled, skipped, manual, ...
Source string `json:"source"` // push, merge_request_event, schedule, ...
Ref string `json:"ref"`
SHA string `json:"sha"`
WebURL string `json:"web_url"`
JobsTotal int `json:"jobs_total"`
JobsPassing int `json:"jobs_passing"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
// MRFeedback aggregates all feedback for an MR (fetched live from GitLab).
type MRFeedback struct {
MR *MR `json:"mr"`
Approvals []MRApproval `json:"approvals"`
Discussions []MRDiscussion `json:"discussions"`
Pipelines []Pipeline `json:"pipelines"`
HasIssues bool `json:"has_issues"`
}
// MRStatus contains lightweight MR state used by the background poller.
// Unlike MRFeedback it skips discussions to reduce API calls.
type MRStatus struct {
MR *MR `json:"mr"`
ApprovalState string `json:"approval_state"` // "approved", "changes_requested", "pending", ""
PipelineState string `json:"pipeline_state"` // "success", "failure", "pending", ""
MergeStatus string `json:"merge_status"` // can_be_merged, cannot_be_merged, unchecked
ApprovalCount int `json:"approval_count"`
RequiredApprovals int `json:"required_approvals"`
PipelineJobsTotal int `json:"pipeline_jobs_total"`
PipelineJobsPassing int `json:"pipeline_jobs_passing"`
}
// MRSearchPage is a paginated slice of MR search results, with the total
// count from the GitLab API X-Total header.
type MRSearchPage struct {
MRs []*MR `json:"mrs"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// IssueSearchPage is a paginated slice of Issue search results.
type IssueSearchPage struct {
Issues []*Issue `json:"issues"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// Project represents a GitLab project (lightweight, for autocomplete).
type Project struct {
ID int64 `json:"id"`
PathWithNamespace string `json:"path_with_namespace"`
Namespace string `json:"namespace"`
Path string `json:"path"`
Name string `json:"name"`
Visibility string `json:"visibility"` // private, internal, public
}
// Group represents a GitLab group the authenticated user belongs to.
// Analogous to GitHubOrg.
type Group struct {
ID int64 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
// RepoBranch represents a branch in a GitLab project.
type RepoBranch struct {
Name string `json:"name"`
}
// Status represents GitLab connection status surfaced to the frontend.
type Status struct {
Authenticated bool `json:"authenticated"`
Username string `json:"username"`
AuthMethod string `json:"auth_method"` // "glab_cli", "pat", "none"
Host string `json:"host"`
TokenConfigured bool `json:"token_configured"`
TokenSecretID string `json:"token_secret_id,omitempty"`
GLabVersion string `json:"glab_version,omitempty"`
GLabOutdated bool `json:"glab_outdated,omitempty"`
RequiredScopes []string `json:"required_scopes"`
Diagnostics *AuthDiagnostics `json:"diagnostics,omitempty"`
// ConnectionError carries a transport-layer failure from the most
// recent IsAuthenticated probe (network down, 5xx, parse failure).
// 401/403 is NOT a connection error — that just means Authenticated
// is false. Empty when the probe succeeded or the host is unconfigured.
// Lets the frontend distinguish "not connected" from "GitLab is
// temporarily unreachable" so users don't delete a valid token during
// a transient outage.
ConnectionError string `json:"connection_error,omitempty"`
}
// ConfigureTokenRequest is the request body for configuring a GitLab token.
type ConfigureTokenRequest struct {
Token string `json:"token" binding:"required"`
}
// ConfigureHostRequest is the request body for setting the GitLab host URL.
type ConfigureHostRequest struct {
Host string `json:"host" binding:"required"`
}
// AuthDiagnostics captures the output of `glab auth status` (or REST probe)
// for troubleshooting.
type AuthDiagnostics struct {
Command string `json:"command"`
Output string `json:"output"`
ExitCode int `json:"exit_code"`
}
// MRFile represents a file changed in a merge request.
type MRFile struct {
Filename string `json:"filename"`
Status string `json:"status"` // added, deleted, modified, renamed
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Patch string `json:"patch,omitempty"`
OldPath string `json:"old_path,omitempty"`
}
// MRCommitInfo represents a commit in a merge request.
//
// AuthorName is the commit's `author_name` (display name like "Jane Smith"),
// not a GitLab login handle — GitLab's MR-commits endpoint doesn't expose a
// login. Per-commit Additions/Deletions/FilesChanged are also not on the
// MR-commits endpoint; getting them would require a per-commit fetch
// against /projects/:id/repository/commits/:sha?stats=true, which is N+1
// and out of scope for v1. The fields were filled with 0 in earlier
// revisions and removed here to stop callers consuming bogus data.
type MRCommitInfo struct {
SHA string `json:"sha"`
Message string `json:"message"`
AuthorName string `json:"author_name"`
AuthorDate string `json:"author_date"`
}
// Issue represents a GitLab Issue.
type Issue struct {
ID int64 `json:"id"`
IID int `json:"iid"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
WebURL string `json:"web_url"`
State string `json:"state"` // opened, closed
AuthorUsername string `json:"author_username"`
ProjectNamespace string `json:"project_namespace"`
ProjectPath string `json:"project_path"`
Labels []string `json:"labels"`
Assignees []string `json:"assignees"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// TaskMR associates a kandev task with a GitLab merge request, parallel to
// github.TaskPR. RepositoryID may be empty for single-repo tasks; multi-repo
// task launches set it so each repo's MR is distinguishable on the same
// (task, iid) key. ProjectPath holds the GitLab path-with-namespace
// ("group/project") used as the API :id.
//
// GitLab-shaped fields differ from GitHub:
// - State is "open" | "closed" | "merged" | "locked" (normalised from
// GitLab's "opened" wording).
// - ApprovalState is "" | "approved" | "pending" — derived from approvals
// received vs RequiredApprovals.
// - PipelineState mirrors GitHub's ChecksState ("success", "failure",
// "pending", "") — GitLab pipelines are the analogue of GitHub checks.
// - MergeStatus carries GitLab's own merge_status string verbatim
// (can_be_merged, cannot_be_merged, unchecked, …) for debugging.
type TaskMR struct {
ID string `json:"id" db:"id"`
TaskID string `json:"task_id" db:"task_id"`
RepositoryID string `json:"repository_id,omitempty" db:"repository_id"`
Host string `json:"host" db:"host"` // gitlab base URL the MR lives on
ProjectPath string `json:"project_path" db:"project_path"` // namespace/path
MRIID int `json:"mr_iid" db:"mr_iid"` // GitLab per-project sequential id
MRURL string `json:"mr_url" db:"mr_url"`
MRTitle string `json:"mr_title" db:"mr_title"`
HeadBranch string `json:"head_branch" db:"head_branch"`
BaseBranch string `json:"base_branch" db:"base_branch"`
AuthorUsername string `json:"author_username" db:"author_username"`
State string `json:"state" db:"state"` // open, closed, merged, locked
ApprovalState string `json:"approval_state" db:"approval_state"` // approved, pending, ""
PipelineState string `json:"pipeline_state" db:"pipeline_state"` // success, failure, pending, ""
MergeStatus string `json:"merge_status" db:"merge_status"`
Draft bool `json:"draft" db:"draft"`
ApprovalCount int `json:"approval_count" db:"approval_count"`
RequiredApprovals int `json:"required_approvals" db:"required_approvals"`
PipelineJobsTotal int `json:"pipeline_jobs_total" db:"pipeline_jobs_total"`
PipelineJobsPass int `json:"pipeline_jobs_pass" db:"pipeline_jobs_pass"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
MergedAt *time.Time `json:"merged_at,omitempty" db:"merged_at"`
ClosedAt *time.Time `json:"closed_at,omitempty" db:"closed_at"`
LastSyncedAt *time.Time `json:"last_synced_at,omitempty" db:"last_synced_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TaskMRsResponse is the shape returned by GET /workspaces/:id/task-mrs.
// Keyed by task ID; each task may have one entry per linked repository.
type TaskMRsResponse struct {
TaskMRs map[string][]*TaskMR `json:"task_mrs"`
}
+132
View File
@@ -0,0 +1,132 @@
package gitlab
import (
"context"
"errors"
"time"
)
// ErrNoClient is returned by NoopClient methods that cannot provide
// meaningful data.
var ErrNoClient = errors.New("gitlab client not configured")
// NoopClient is a GitLab client that returns empty results for all
// operations. Used when GitLab integration is not configured.
type NoopClient struct {
host string
}
// NewNoopClient builds a NoopClient that reports the given host (purely
// informational; no requests are issued).
func NewNoopClient(host string) *NoopClient {
if host == "" {
host = DefaultHost
}
return &NoopClient{host: host}
}
func (c *NoopClient) IsAuthenticated(context.Context) (bool, error) {
return false, nil
}
func (c *NoopClient) GetAuthenticatedUser(context.Context) (string, error) {
return "", nil
}
func (c *NoopClient) Host() string { return c.host }
func (c *NoopClient) GetMR(context.Context, string, int) (*MR, error) {
return nil, ErrNoClient
}
func (c *NoopClient) FindMRByBranch(context.Context, string, string) (*MR, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListAuthoredMRs(context.Context, string) ([]*MR, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListReviewRequestedMRs(context.Context, string, string) ([]*MR, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListUserGroups(context.Context) ([]Group, error) {
return nil, ErrNoClient
}
func (c *NoopClient) SearchGroupProjects(context.Context, string, string, int) ([]Project, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListMRApprovals(context.Context, string, int) ([]MRApproval, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListMRDiscussions(context.Context, string, int, *time.Time) ([]MRDiscussion, error) {
return nil, ErrNoClient
}
func (c *NoopClient) CreateMRDiscussionNote(context.Context, string, int, string, string) (*MRNote, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ResolveMRDiscussion(context.Context, string, int, string) error {
return ErrNoClient
}
func (c *NoopClient) ListPipelines(context.Context, string, string) ([]Pipeline, error) {
return nil, ErrNoClient
}
func (c *NoopClient) GetMRFeedback(context.Context, string, int) (*MRFeedback, error) {
return nil, ErrNoClient
}
func (c *NoopClient) GetMRStatus(context.Context, string, int) (*MRStatus, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListMRFiles(context.Context, string, int) ([]MRFile, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListMRCommits(context.Context, string, int) ([]MRCommitInfo, error) {
return nil, ErrNoClient
}
func (c *NoopClient) SubmitMRApproval(context.Context, string, int) error {
return ErrNoClient
}
func (c *NoopClient) SubmitMRUnapproval(context.Context, string, int) error {
return ErrNoClient
}
func (c *NoopClient) CreateMR(context.Context, string, string, string, string, string, bool) (*MR, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListProjectBranches(context.Context, string) ([]RepoBranch, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
return nil, ErrNoClient
}
func (c *NoopClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) {
return nil, ErrNoClient
}
func (c *NoopClient) SearchMRs(context.Context, string, string) ([]*MR, error) {
return nil, ErrNoClient
}
func (c *NoopClient) SearchMRsPaged(context.Context, string, string, int, int) (*MRSearchPage, error) {
return nil, ErrNoClient
}
func (c *NoopClient) GetIssueState(context.Context, string, int) (string, error) {
return "", ErrNoClient
}
@@ -0,0 +1,71 @@
package gitlab
import (
"context"
"errors"
"testing"
)
func TestNoopClient_SatisfiesClient(t *testing.T) {
var _ Client = (*NoopClient)(nil)
}
func TestNoopClient_IsAuthenticated_ReportsUnauthenticated(t *testing.T) {
c := NewNoopClient("")
ok, err := c.IsAuthenticated(context.Background())
if err != nil {
t.Fatalf("IsAuthenticated err = %v, want nil", err)
}
if ok {
t.Fatalf("IsAuthenticated = true, want false for noop client")
}
}
func TestNoopClient_Host_FallsBackToDefault(t *testing.T) {
c := NewNoopClient("")
if got := c.Host(); got != DefaultHost {
t.Fatalf("Host = %q, want %q", got, DefaultHost)
}
}
func TestNoopClient_Host_HonoursOverride(t *testing.T) {
c := NewNoopClient("https://gitlab.acme.corp")
if got := c.Host(); got != "https://gitlab.acme.corp" {
t.Fatalf("Host = %q, want override", got)
}
}
func TestNoopClient_DataMethods_ReturnErrNoClient(t *testing.T) {
c := NewNoopClient("")
ctx := context.Background()
cases := []struct {
name string
err error
}{
{"GetMR", mustErr2(c.GetMR(ctx, "g/p", 1))},
{"FindMRByBranch", mustErr2(c.FindMRByBranch(ctx, "g/p", "feat"))},
{"ListAuthoredMRs", mustErr2(c.ListAuthoredMRs(ctx, "g/p"))},
{"ListReviewRequestedMRs", mustErr2(c.ListReviewRequestedMRs(ctx, "", ""))},
{"ListMRApprovals", mustErr2(c.ListMRApprovals(ctx, "g/p", 1))},
{"ListMRDiscussions", mustErr2(c.ListMRDiscussions(ctx, "g/p", 1, nil))},
{"CreateMRDiscussionNote", mustErr2(c.CreateMRDiscussionNote(ctx, "g/p", 1, "d", "body"))},
{"ResolveMRDiscussion", c.ResolveMRDiscussion(ctx, "g/p", 1, "d")},
{"ListPipelines", mustErr2(c.ListPipelines(ctx, "g/p", "main"))},
{"GetMRFeedback", mustErr2(c.GetMRFeedback(ctx, "g/p", 1))},
{"GetMRStatus", mustErr2(c.GetMRStatus(ctx, "g/p", 1))},
{"SubmitMRApproval", c.SubmitMRApproval(ctx, "g/p", 1)},
{"SubmitMRUnapproval", c.SubmitMRUnapproval(ctx, "g/p", 1)},
{"CreateMR", mustErr2(c.CreateMR(ctx, "g/p", "src", "dst", "t", "b", false))},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !errors.Is(tc.err, ErrNoClient) {
t.Fatalf("err = %v, want ErrNoClient", tc.err)
}
})
}
}
// mustErr2 extracts the error from a (T, error) pair.
func mustErr2[T any](_ T, err error) error { return err }
+701
View File
@@ -0,0 +1,701 @@
package gitlab
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const (
apiPathPrefix = "/api/v4"
defaultPageSize = 50
maxPageSize = 100
requestTimeout = 30 * time.Second
defaultErrBytes = 4096
defaultPageStart = 1
)
// APIError represents an error response from the GitLab API.
type APIError struct {
StatusCode int
Endpoint string
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("GitLab API %s returned %d: %s", e.Endpoint, e.StatusCode, e.Body)
}
// PATClient implements Client against the GitLab REST v4 API using a
// personal access token (or any other token type sent via PRIVATE-TOKEN).
type PATClient struct {
host string
token string
httpClient *http.Client
// username caches the authenticated user's login after the first
// /user lookup. Service.GetStatus can race other status callers,
// so the cache field is guarded by usernameMu.
usernameMu sync.RWMutex
username string
}
// NewPATClient creates a new PAT-based GitLab client.
// host is the GitLab base URL (e.g. "https://gitlab.com" or
// "https://gitlab.acme.corp"); a trailing slash is trimmed. An empty host
// falls back to the public DefaultHost.
func NewPATClient(host, token string) *PATClient {
if host == "" {
host = DefaultHost
}
host = strings.TrimRight(host, "/")
return &PATClient{
host: host,
token: token,
httpClient: &http.Client{Timeout: requestTimeout},
}
}
func (c *PATClient) Host() string { return c.host }
func (c *PATClient) setHeaders(req *http.Request) {
req.Header.Set("PRIVATE-TOKEN", c.token)
req.Header.Set("Accept", "application/json")
}
// IsAuthenticated probes /user *uncached* — calling GetAuthenticatedUser
// would return the previously-cached username even after the token was
// revoked upstream, leaving the integration showing "connected" forever.
// A 401/403 means the token is bad and is surfaced as (false, nil) so
// callers can render "not connected". Any other error (network, 5xx,
// parse failure) is returned so it isn't silently reported as a bad token.
func (c *PATClient) IsAuthenticated(ctx context.Context) (bool, error) {
var user struct {
Username string `json:"username"`
}
if err := c.get(ctx, "/user", &user); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
return false, nil
}
return false, err
}
// Opportunistically refresh the cache so the next GetAuthenticatedUser
// caller doesn't have to re-fetch.
c.usernameMu.Lock()
c.username = user.Username
c.usernameMu.Unlock()
return true, nil
}
func (c *PATClient) GetAuthenticatedUser(ctx context.Context) (string, error) {
c.usernameMu.RLock()
cached := c.username
c.usernameMu.RUnlock()
if cached != "" {
return cached, nil
}
var user struct {
Username string `json:"username"`
}
if err := c.get(ctx, "/user", &user); err != nil {
return "", err
}
c.usernameMu.Lock()
c.username = user.Username
c.usernameMu.Unlock()
return user.Username, nil
}
// projectRef returns the URL-encoded project path used as :id in API URLs.
// GitLab requires the slash between namespace and path to be %2F-encoded —
// url.PathEscape leaves "/" alone, so do the substitution manually before
// URL-escaping the rest.
func projectRef(projectPath string) string {
return strings.ReplaceAll(url.PathEscape(projectPath), "/", "%2F")
}
// groupRef returns the URL-encoded group path used as :id in /groups/:id/...
// API URLs. Same %2F treatment as projectRef — required for subgroup paths
// like "acme/team" where the slash separates the parent group from the
// child group and a bare PathEscape leaves it as "/", which GitLab routes
// to the parent only and returns 404 for the subgroup.
func groupRef(groupPath string) string {
return strings.ReplaceAll(url.PathEscape(groupPath), "/", "%2F")
}
func (c *PATClient) GetMR(ctx context.Context, projectPath string, iid int) (*MR, error) {
var raw rawMR
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d", projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("get MR !%d: %w", iid, err)
}
return convertRawMR(&raw), nil
}
func (c *PATClient) FindMRByBranch(ctx context.Context, projectPath, branch string) (*MR, error) {
var raw []rawMR
endpoint := fmt.Sprintf(
"/projects/%s/merge_requests?source_branch=%s&state=opened&per_page=1",
projectRef(projectPath), url.QueryEscape(branch),
)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("find MR by branch: %w", err)
}
if len(raw) == 0 {
return nil, nil
}
return convertRawMR(&raw[0]), nil
}
func (c *PATClient) ListAuthoredMRs(ctx context.Context, projectPath string) ([]*MR, error) {
user, err := c.GetAuthenticatedUser(ctx)
if err != nil {
return nil, err
}
var raw []rawMR
endpoint := fmt.Sprintf(
"/projects/%s/merge_requests?state=opened&author_username=%s&per_page=100",
projectRef(projectPath), url.QueryEscape(user),
)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list MRs: %w", err)
}
return convertRawMRSlice(raw), nil
}
func (c *PATClient) ListReviewRequestedMRs(ctx context.Context, filter, customQuery string) ([]*MR, error) {
query := buildReviewMRQuery(filter, customQuery)
endpoint := "/merge_requests?" + query
var raw []rawMR
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list review-requested MRs: %w", err)
}
return convertRawMRSlice(raw), nil
}
func (c *PATClient) SearchMRs(ctx context.Context, filter, customQuery string) ([]*MR, error) {
page, err := c.SearchMRsPaged(ctx, filter, customQuery, defaultPageStart, defaultPageSize)
if err != nil {
return nil, err
}
return page.MRs, nil
}
func (c *PATClient) SearchMRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*MRSearchPage, error) {
page, perPage = clampSearchPage(page, perPage)
query := buildMRSearchQuery(filter, customQuery)
endpoint := fmt.Sprintf("/merge_requests?%s&page=%d&per_page=%d", query, page, perPage)
var raw []rawMR
total, err := c.getWithTotal(ctx, endpoint, &raw)
if err != nil {
return nil, fmt.Errorf("search MRs: %w", err)
}
return &MRSearchPage{
MRs: convertRawMRSlice(raw),
TotalCount: total,
Page: page,
PerPage: perPage,
}, nil
}
func (c *PATClient) ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) {
page, err := c.ListIssuesPaged(ctx, filter, customQuery, defaultPageStart, defaultPageSize)
if err != nil {
return nil, err
}
return page.Issues, nil
}
func (c *PATClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) {
page, perPage = clampSearchPage(page, perPage)
query := buildIssueSearchQuery(filter, customQuery)
endpoint := fmt.Sprintf("/issues?%s&page=%d&per_page=%d", query, page, perPage)
var raw []rawIssue
total, err := c.getWithTotal(ctx, endpoint, &raw)
if err != nil {
return nil, fmt.Errorf("search issues: %w", err)
}
issues := make([]*Issue, len(raw))
for i := range raw {
issues[i] = convertRawIssue(&raw[i])
}
return &IssueSearchPage{
Issues: issues,
TotalCount: total,
Page: page,
PerPage: perPage,
}, nil
}
func (c *PATClient) GetIssueState(ctx context.Context, projectPath string, iid int) (string, error) {
var result struct {
State string `json:"state"`
}
endpoint := fmt.Sprintf("/projects/%s/issues/%d", projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &result); err != nil {
return "", fmt.Errorf("get issue state: %w", err)
}
return result.State, nil
}
func (c *PATClient) ListUserGroups(ctx context.Context) ([]Group, error) {
var raw []struct {
ID int64 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
if err := c.get(ctx, "/groups?per_page=100&min_access_level=10", &raw); err != nil {
return nil, fmt.Errorf("list groups: %w", err)
}
groups := make([]Group, len(raw))
for i, g := range raw {
groups[i] = Group{ID: g.ID, Path: g.Path, Name: g.Name, AvatarURL: g.AvatarURL}
}
return groups, nil
}
func (c *PATClient) SearchGroupProjects(ctx context.Context, group, query string, limit int) ([]Project, error) {
if limit <= 0 {
limit = 20
}
endpoint := fmt.Sprintf("/groups/%s/projects?per_page=%d&simple=true&include_subgroups=true",
groupRef(group), limit)
if query != "" {
endpoint += "&search=" + url.QueryEscape(query)
}
var raw []rawProject
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list group projects: %w", err)
}
projects := make([]Project, len(raw))
for i := range raw {
projects[i] = convertRawProject(&raw[i])
}
return projects, nil
}
func (c *PATClient) ListMRApprovals(ctx context.Context, projectPath string, iid int) ([]MRApproval, error) {
approvals, _, err := c.fetchApprovals(ctx, projectPath, iid)
return approvals, err
}
// fetchApprovals issues a single GET against /approvals and returns both the
// list of approvers and the required-approvals count. Consolidating these so
// GetMRStatus doesn't have to hit the same endpoint twice per poll.
func (c *PATClient) fetchApprovals(ctx context.Context, projectPath string, iid int) ([]MRApproval, int, error) {
var raw struct {
ApprovedBy []struct {
User struct {
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
} `json:"user"`
} `json:"approved_by"`
ApprovalsRequired int `json:"approvals_required"`
UpdatedAt time.Time `json:"updated_at"`
}
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/approvals", projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, 0, fmt.Errorf("list approvals: %w", err)
}
approvals := make([]MRApproval, 0, len(raw.ApprovedBy))
for _, a := range raw.ApprovedBy {
approvals = append(approvals, MRApproval{
Username: a.User.Username,
Avatar: a.User.AvatarURL,
CreatedAt: raw.UpdatedAt,
})
}
return approvals, raw.ApprovalsRequired, nil
}
func (c *PATClient) ListMRDiscussions(ctx context.Context, projectPath string, iid int, since *time.Time) ([]MRDiscussion, error) {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/discussions?per_page=100",
projectRef(projectPath), iid)
var raw []rawDiscussion
for endpoint != "" {
var page []rawDiscussion
nextLink, err := c.getPaginated(ctx, endpoint, &page)
if err != nil {
return nil, fmt.Errorf("list discussions: %w", err)
}
raw = append(raw, page...)
endpoint = nextLink
}
discussions := make([]MRDiscussion, 0, len(raw))
for i := range raw {
d := convertRawDiscussion(&raw[i])
if since != nil && d.UpdatedAt.Before(*since) {
continue
}
discussions = append(discussions, d)
}
return discussions, nil
}
func (c *PATClient) CreateMRDiscussionNote(ctx context.Context, projectPath string, iid int, discussionID, body string) (*MRNote, error) {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/discussions/%s/notes",
projectRef(projectPath), iid, url.PathEscape(discussionID))
payload := map[string]string{"body": body}
var raw rawNote
if err := c.postJSON(ctx, endpoint, payload, &raw); err != nil {
return nil, fmt.Errorf("create discussion note: %w", err)
}
note := convertRawNote(&raw)
return &note, nil
}
func (c *PATClient) ResolveMRDiscussion(ctx context.Context, projectPath string, iid int, discussionID string) error {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/discussions/%s?resolved=true",
projectRef(projectPath), iid, url.PathEscape(discussionID))
return c.put(ctx, endpoint, nil)
}
func (c *PATClient) ListPipelines(ctx context.Context, projectPath, ref string) ([]Pipeline, error) {
endpoint := fmt.Sprintf("/projects/%s/pipelines?per_page=20", projectRef(projectPath))
if ref != "" {
endpoint += "&ref=" + url.QueryEscape(ref)
}
var raw []rawPipeline
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list pipelines: %w", err)
}
pipelines := make([]Pipeline, len(raw))
for i := range raw {
pipelines[i] = convertRawPipeline(&raw[i])
}
return pipelines, nil
}
func (c *PATClient) GetMRFeedback(ctx context.Context, projectPath string, iid int) (*MRFeedback, error) {
mr, err := c.GetMR(ctx, projectPath, iid)
if err != nil {
return nil, err
}
approvals, err := c.ListMRApprovals(ctx, projectPath, iid)
if err != nil {
return nil, err
}
discussions, err := c.ListMRDiscussions(ctx, projectPath, iid, nil)
if err != nil {
return nil, err
}
var pipelines []Pipeline
if mr.HeadSHA != "" || mr.HeadBranch != "" {
pipelines, err = c.ListPipelines(ctx, projectPath, mr.HeadBranch)
if err != nil {
return nil, err
}
}
return &MRFeedback{
MR: mr,
Approvals: approvals,
Discussions: discussions,
Pipelines: pipelines,
HasIssues: hasOpenDiscussions(discussions) || pipelineFailing(pipelines),
}, nil
}
func (c *PATClient) GetMRStatus(ctx context.Context, projectPath string, iid int) (*MRStatus, error) {
mr, err := c.GetMR(ctx, projectPath, iid)
if err != nil {
return nil, err
}
approvals, required, err := c.fetchApprovals(ctx, projectPath, iid)
if err != nil {
return nil, err
}
var pipelines []Pipeline
if mr.HeadSHA != "" || mr.HeadBranch != "" {
pipelines, err = c.ListPipelines(ctx, projectPath, mr.HeadBranch)
if err != nil {
return nil, err
}
}
pipelineState, jobsTotal, jobsPassing := summarizePipelines(pipelines)
approvalState := summarizeApprovals(len(approvals), required)
return &MRStatus{
MR: mr,
ApprovalState: approvalState,
PipelineState: pipelineState,
MergeStatus: mr.MergeStatus,
ApprovalCount: len(approvals),
RequiredApprovals: required,
PipelineJobsTotal: jobsTotal,
PipelineJobsPassing: jobsPassing,
}, nil
}
func (c *PATClient) ListMRFiles(ctx context.Context, projectPath string, iid int) ([]MRFile, error) {
var raw struct {
Changes []struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
NewFile bool `json:"new_file"`
DeletedFile bool `json:"deleted_file"`
RenamedFile bool `json:"renamed_file"`
Diff string `json:"diff"`
} `json:"changes"`
}
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/changes",
projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list MR changes: %w", err)
}
files := make([]MRFile, len(raw.Changes))
for i, ch := range raw.Changes {
additions, deletions := countDiffLines(ch.Diff)
files[i] = MRFile{
Filename: ch.NewPath,
Status: diffStatus(ch.NewFile, ch.DeletedFile, ch.RenamedFile),
Additions: additions,
Deletions: deletions,
Patch: ch.Diff,
OldPath: ch.OldPath,
}
}
return files, nil
}
func (c *PATClient) ListMRCommits(ctx context.Context, projectPath string, iid int) ([]MRCommitInfo, error) {
// The MR-commits endpoint doesn't return per-commit stats — only the
// per-repo /repository/commits/:sha?stats=true does. We deliberately
// don't fetch stats here; see the MRCommitInfo doc-comment.
var raw []struct {
ID string `json:"id"`
Message string `json:"message"`
AuthorName string `json:"author_name"`
AuthoredDate string `json:"authored_date"`
}
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/commits?per_page=100",
projectRef(projectPath), iid)
if err := c.get(ctx, endpoint, &raw); err != nil {
return nil, fmt.Errorf("list MR commits: %w", err)
}
commits := make([]MRCommitInfo, len(raw))
for i, r := range raw {
commits[i] = MRCommitInfo{
SHA: r.ID,
Message: r.Message,
AuthorName: r.AuthorName,
AuthorDate: r.AuthoredDate,
}
}
return commits, nil
}
func (c *PATClient) SubmitMRApproval(ctx context.Context, projectPath string, iid int) error {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/approve",
projectRef(projectPath), iid)
return c.post(ctx, endpoint, nil)
}
func (c *PATClient) SubmitMRUnapproval(ctx context.Context, projectPath string, iid int) error {
endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/unapprove",
projectRef(projectPath), iid)
return c.post(ctx, endpoint, nil)
}
func (c *PATClient) CreateMR(ctx context.Context, projectPath, sourceBranch, targetBranch, title, description string, draft bool) (*MR, error) {
finalTitle := title
if draft && !strings.HasPrefix(strings.ToLower(strings.TrimSpace(finalTitle)), "draft:") {
finalTitle = "Draft: " + finalTitle
}
payload := map[string]any{
"source_branch": sourceBranch,
"target_branch": targetBranch,
"title": finalTitle,
"description": description,
"remove_source_branch": true,
}
endpoint := fmt.Sprintf("/projects/%s/merge_requests", projectRef(projectPath))
var raw rawMR
if err := c.postJSON(ctx, endpoint, payload, &raw); err != nil {
return nil, fmt.Errorf("create MR: %w", err)
}
return convertRawMR(&raw), nil
}
func (c *PATClient) ListProjectBranches(ctx context.Context, projectPath string) ([]RepoBranch, error) {
endpoint := fmt.Sprintf("/projects/%s/repository/branches?per_page=100", projectRef(projectPath))
var branches []RepoBranch
for endpoint != "" {
var page []struct {
Name string `json:"name"`
}
nextLink, err := c.getPaginated(ctx, endpoint, &page)
if err != nil {
return nil, fmt.Errorf("list branches: %w", err)
}
for _, b := range page {
branches = append(branches, RepoBranch{Name: b.Name})
}
endpoint = nextLink
}
return branches, nil
}
// --- HTTP plumbing ---
func (c *PATClient) get(ctx context.Context, endpoint string, result any) error {
_, err := c.getWithTotal(ctx, endpoint, result)
return err
}
func (c *PATClient) getWithTotal(ctx context.Context, endpoint string, result any) (int, error) {
u := c.host + apiPathPrefix + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, err
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("request %s: %w", endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, defaultErrBytes))
return 0, &APIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)}
}
total := 0
if t := resp.Header.Get("X-Total"); t != "" {
if n, convErr := strconv.Atoi(t); convErr == nil {
total = n
}
}
if result == nil {
// Drain so the underlying connection can be reused for keep-alive;
// HTTP/1.1 transports only pool when the body is fully consumed.
_, _ = io.Copy(io.Discard, resp.Body)
return total, nil
}
return total, json.NewDecoder(resp.Body).Decode(result)
}
func (c *PATClient) getPaginated(ctx context.Context, endpoint string, result any) (string, error) {
u := c.host + apiPathPrefix + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("request %s: %w", endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, defaultErrBytes))
return "", &APIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)}
}
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return "", err
}
return parseNextLink(resp.Header.Get("Link"), c.host+apiPathPrefix), nil
}
func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) error {
return c.doWrite(ctx, http.MethodPost, endpoint, body, nil)
}
func (c *PATClient) put(ctx context.Context, endpoint string, body []byte) error {
return c.doWrite(ctx, http.MethodPut, endpoint, body, nil)
}
func (c *PATClient) postJSON(ctx context.Context, endpoint string, payload, result any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
return c.doWrite(ctx, http.MethodPost, endpoint, body, result)
}
func (c *PATClient) doWrite(ctx context.Context, method, endpoint string, body []byte, result any) error {
u := c.host + apiPathPrefix + endpoint
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
if err != nil {
return err
}
c.setHeaders(req)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request %s %s: %w", method, endpoint, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, defaultErrBytes))
return &APIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)}
}
if result == nil {
// Drain to allow HTTP/1.1 connection reuse on the next call.
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(result)
}
// parseNextLink extracts the "next" URL path+query from a GitLab Link header
// and strips the API base prefix so the caller can pass the result back to
// get() (which re-prepends host + apiPathPrefix). Returns "" if the link
// targets a different host or doesn't share the apiBase prefix — passing a
// bare absolute URL back to get() would double-prefix it and break
// pagination, so it's safer to stop than to silently misroute the next page.
func parseNextLink(header, apiBase string) string {
if header == "" {
return ""
}
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if !strings.Contains(part, `rel="next"`) {
continue
}
start := strings.Index(part, "<")
end := strings.Index(part, ">")
if start < 0 || end < 0 || end <= start {
continue
}
link := part[start+1 : end]
if strings.HasPrefix(link, apiBase) {
return link[len(apiBase):]
}
return ""
}
return ""
}
func clampSearchPage(page, perPage int) (int, int) {
if page < 1 {
page = 1
}
if perPage <= 0 {
perPage = defaultPageSize
}
if perPage > maxPageSize {
perPage = maxPageSize
}
return page, perPage
}
@@ -0,0 +1,537 @@
package gitlab
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// newTestServer wires a handler under /api/v4/* and returns the host URL.
func newTestServer(t *testing.T, handler http.Handler) (host string, teardown func()) {
t.Helper()
mux := http.NewServeMux()
mux.Handle("/api/v4/", http.StripPrefix("/api/v4", handler))
srv := httptest.NewServer(mux)
return srv.URL, srv.Close
}
func TestPATClient_GetAuthenticatedUser(t *testing.T) {
calls := 0
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/user" {
t.Errorf("path = %q, want /user", r.URL.Path)
}
if r.Header.Get("PRIVATE-TOKEN") != "tok" {
t.Errorf("PRIVATE-TOKEN header = %q, want tok", r.Header.Get("PRIVATE-TOKEN"))
}
_ = json.NewEncoder(w).Encode(map[string]any{"username": "alice"})
}))
defer stop()
c := NewPATClient(host, "tok")
user, err := c.GetAuthenticatedUser(context.Background())
if err != nil {
t.Fatalf("err = %v", err)
}
if user != "alice" {
t.Fatalf("user = %q, want alice", user)
}
// Cached on second call — the server must not be hit again. A non-
// caching implementation would still pass an err-only assertion, so
// the request counter is what actually proves the cache.
if _, err := c.GetAuthenticatedUser(context.Background()); err != nil {
t.Fatalf("cached call err = %v", err)
}
if calls != 1 {
t.Errorf("server called %d times, want 1 (second call must hit cache)", calls)
}
}
func TestPATClient_GetAuthenticatedUser_AuthFailure(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"401 Unauthorized"}`))
}))
defer stop()
c := NewPATClient(host, "bad")
_, err := c.GetAuthenticatedUser(context.Background())
if err == nil {
t.Fatal("expected error")
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want *APIError", err)
}
if apiErr.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", apiErr.StatusCode)
}
}
func TestPATClient_GetMR(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// EscapedPath preserves %2F; r.URL.Path is the decoded form.
if got, want := r.URL.EscapedPath(), "/projects/group%2Fproject/merge_requests/42"; got != want {
t.Errorf("escaped path = %q, want %q (URL-encoded slash)", got, want)
}
_ = json.NewEncoder(w).Encode(rawMR{
ID: 1, IID: 42, ProjectID: 7,
Title: "Add feature", State: "opened",
SourceBranch: "feat/x", TargetBranch: "main",
SHA: "abc123",
Author: rawUser{Username: "alice"},
References: struct {
Full string `json:"full"`
}{Full: "group/project!42"},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
})
}))
defer stop()
c := NewPATClient(host, "tok")
mr, err := c.GetMR(context.Background(), "group/project", 42)
if err != nil {
t.Fatalf("err = %v", err)
}
if mr.IID != 42 {
t.Errorf("iid = %d, want 42", mr.IID)
}
if mr.State != "open" {
t.Errorf("state = %q, want open (normalized from opened)", mr.State)
}
if mr.HeadBranch != "feat/x" || mr.BaseBranch != "main" {
t.Errorf("branches = %q/%q, want feat/x/main", mr.HeadBranch, mr.BaseBranch)
}
if mr.AuthorUsername != "alice" {
t.Errorf("author = %q, want alice", mr.AuthorUsername)
}
}
func TestPATClient_FindMRByBranch_Empty(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
mr, err := c.FindMRByBranch(context.Background(), "g/p", "missing")
if err != nil {
t.Fatalf("err = %v", err)
}
if mr != nil {
t.Fatalf("mr = %+v, want nil", mr)
}
}
func TestPATClient_CreateMR_DraftPrefix(t *testing.T) {
var receivedBody map[string]any
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
_ = json.NewEncoder(w).Encode(rawMR{IID: 7, Title: "Draft: feat", State: "opened"})
}))
defer stop()
c := NewPATClient(host, "tok")
mr, err := c.CreateMR(context.Background(), "g/p", "feat", "main", "feat", "body", true)
if err != nil {
t.Fatalf("err = %v", err)
}
if mr.IID != 7 {
t.Errorf("iid = %d, want 7", mr.IID)
}
if got, want := receivedBody["title"], "Draft: feat"; got != want {
t.Errorf("title sent = %v, want %q", got, want)
}
}
func TestPATClient_CreateMR_NoDoubleDraftPrefix(t *testing.T) {
var receivedBody map[string]any
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
_ = json.NewEncoder(w).Encode(rawMR{IID: 8, State: "opened"})
}))
defer stop()
c := NewPATClient(host, "tok")
if _, err := c.CreateMR(context.Background(), "g/p", "feat", "main", "Draft: already", "", true); err != nil {
t.Fatalf("err = %v", err)
}
if got := receivedBody["title"].(string); strings.Count(got, "Draft:") != 1 {
t.Errorf("title = %q, expected single Draft: prefix", got)
}
}
func TestPATClient_ResolveDiscussion_UsesPUT(t *testing.T) {
called := false
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.Method != http.MethodPut {
t.Errorf("method = %s, want PUT", r.Method)
}
if !strings.Contains(r.URL.RawQuery, "resolved=true") {
t.Errorf("query = %q, want resolved=true", r.URL.RawQuery)
}
w.WriteHeader(http.StatusOK)
}))
defer stop()
c := NewPATClient(host, "tok")
if err := c.ResolveMRDiscussion(context.Background(), "g/p", 1, "abc123"); err != nil {
t.Fatalf("err = %v", err)
}
if !called {
t.Fatal("server was not called")
}
}
func TestPATClient_ListMRDiscussions_ConvertsThread(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{
"id": "thread-1",
"notes": [
{"id":1,"body":"first","resolvable":true,"resolved":false,"author":{"username":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
{"id":2,"body":"reply","author":{"username":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
]
}]`))
}))
defer stop()
c := NewPATClient(host, "tok")
d, err := c.ListMRDiscussions(context.Background(), "g/p", 1, nil)
if err != nil {
t.Fatalf("err = %v", err)
}
if len(d) != 1 {
t.Fatalf("got %d discussions, want 1", len(d))
}
if !d[0].Resolvable {
t.Error("resolvable = false, want true (taken from first note)")
}
if d[0].Resolved {
t.Error("resolved = true, want false")
}
if len(d[0].Notes) != 2 {
t.Fatalf("notes = %d, want 2", len(d[0].Notes))
}
wantUpdate := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)
if !d[0].UpdatedAt.Equal(wantUpdate) {
t.Errorf("updated_at = %v, want %v (latest reply)", d[0].UpdatedAt, wantUpdate)
}
}
func TestPATClient_ListMRDiscussions_FiltersBySince(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[
{"id":"old","notes":[{"id":1,"created_at":"2026-04-01T00:00:00Z","updated_at":"2026-04-01T00:00:00Z"}]},
{"id":"new","notes":[{"id":2,"created_at":"2026-05-15T00:00:00Z","updated_at":"2026-05-15T00:00:00Z"}]}
]`))
}))
defer stop()
since := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
c := NewPATClient(host, "tok")
d, err := c.ListMRDiscussions(context.Background(), "g/p", 1, &since)
if err != nil {
t.Fatalf("err = %v", err)
}
if len(d) != 1 || d[0].ID != "new" {
t.Fatalf("got %#v, want only the 'new' discussion", d)
}
}
func TestPATClient_SearchMRsPaged_HonoursTotalHeader(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Total", "153")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page == 1 {
_, _ = w.Write([]byte(`[{"iid":1,"state":"opened","author":{"username":"a"},"references":{"full":"g/p!1"}}]`))
return
}
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
page, err := c.SearchMRsPaged(context.Background(), "", "", 1, 25)
if err != nil {
t.Fatalf("err = %v", err)
}
if page.TotalCount != 153 {
t.Errorf("total = %d, want 153", page.TotalCount)
}
if len(page.MRs) != 1 {
t.Errorf("mrs = %d, want 1", len(page.MRs))
}
}
func TestPATClient_ListIssuesPaged_HonoursTotalHeader(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// X-Total drives pagination; the body returns a small slice so the
// test verifies header + body wire up independently.
w.Header().Set("X-Total", "87")
if r.URL.Path != "/issues" {
// newTestServer strips the /api/v4 prefix before dispatch.
t.Errorf("path = %q, want /issues", r.URL.Path)
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
perPage := r.URL.Query().Get("per_page")
if perPage != "20" {
t.Errorf("per_page = %q, want 20 — paging args must reach the server", perPage)
}
if page == 2 {
_, _ = w.Write([]byte(`[{"iid":42,"state":"opened","author":{"username":"alice"},"references":{"full":"g/p#42"}}]`))
return
}
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
page, err := c.ListIssuesPaged(context.Background(), "", "", 2, 20)
if err != nil {
t.Fatalf("err = %v", err)
}
if page.TotalCount != 87 {
t.Errorf("total = %d, want 87 (X-Total header)", page.TotalCount)
}
if page.Page != 2 || page.PerPage != 20 {
t.Errorf("page/perPage = %d/%d, want 2/20 (echoed back)", page.Page, page.PerPage)
}
if len(page.Issues) != 1 || page.Issues[0].IID != 42 {
t.Errorf("issues = %+v, want one issue with IID 42", page.Issues)
}
}
func TestParseNextLink(t *testing.T) {
apiBase := "https://gitlab.example.com/api/v4"
cases := []struct {
name string
header string
want string
}{
{"empty", "", ""},
{"next", `<https://gitlab.example.com/api/v4/issues?page=2>; rel="next"`, "/issues?page=2"},
{"prev-only", `<https://gitlab.example.com/api/v4/issues?page=2>; rel="prev"`, ""},
{"next-then-last", `<https://gitlab.example.com/api/v4/issues?page=3>; rel="next", <https://gitlab.example.com/api/v4/issues?page=10>; rel="last"`, "/issues?page=3"},
// Cross-host or wrong-base URLs are dropped rather than returned
// raw — passing a bare absolute URL back to get() would double-
// prefix it with host + apiPathPrefix.
{"foreign-host-dropped", `<https://other.example.com/api/v4/issues?page=2>; rel="next"`, ""},
{"no-apibase-dropped", `</issues?page=2>; rel="next"`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseNextLink(tc.header, apiBase)
if got != tc.want {
t.Errorf("parseNextLink(%q) = %q, want %q", tc.header, got, tc.want)
}
})
}
}
func TestPATClient_IsAuthenticated_PropagatesNonAuthErrors(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"boom"}`))
}))
defer stop()
c := NewPATClient(host, "tok")
ok, err := c.IsAuthenticated(context.Background())
if err == nil {
t.Fatal("expected error for 500 response (transport failure must not be reported as 'not authenticated')")
}
if ok {
t.Error("authenticated = true on 500, want false")
}
}
func TestPATClient_IsAuthenticated_401IsClean(t *testing.T) {
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer stop()
c := NewPATClient(host, "bad")
ok, err := c.IsAuthenticated(context.Background())
if err != nil {
t.Errorf("err = %v, want nil for 401 (it's a clean 'not authenticated' signal)", err)
}
if ok {
t.Error("authenticated = true on 401, want false")
}
}
// Regression: if a token gets revoked upstream, IsAuthenticated must surface
// the failure even though the username was previously cached. The previous
// implementation called GetAuthenticatedUser, which short-circuited on the
// cache and made revoked tokens appear connected forever.
func TestPATClient_IsAuthenticated_DoesNotTrustStaleCache(t *testing.T) {
calls := 0
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
if calls == 1 {
_, _ = w.Write([]byte(`{"username":"alice"}`))
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer stop()
c := NewPATClient(host, "tok")
// Warm the cache.
if _, err := c.GetAuthenticatedUser(context.Background()); err != nil {
t.Fatalf("warmup err = %v", err)
}
ok, err := c.IsAuthenticated(context.Background())
if err != nil {
t.Errorf("err = %v, want nil for 401", err)
}
if ok {
t.Error("authenticated = true after upstream revoked token, want false (cache must not mask revocation)")
}
}
// SearchGroupProjects must %2F-encode slashes in subgroup paths so the
// resulting URL is /groups/acme%2Fteam/projects, not /groups/acme/team/projects
// (which GitLab routes to the parent only and returns 404 for). Without this
// regression test a future refactor that switched groupRef back to a bare
// url.PathEscape would silently break subgroup lookups.
func TestPATClient_SearchGroupProjects_EncodesSubgroupSlash(t *testing.T) {
var receivedPath string
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// EscapedPath() preserves %2F; r.URL.Path is the decoded form.
receivedPath = r.URL.EscapedPath()
_, _ = w.Write([]byte(`[]`))
}))
defer stop()
c := NewPATClient(host, "tok")
if _, err := c.SearchGroupProjects(context.Background(), "acme/team", "", 5); err != nil {
t.Fatalf("err = %v", err)
}
// newTestServer mounts handlers under /api/v4/* via StripPrefix, so the
// handler sees the path without the /api/v4 prefix.
want := "/groups/acme%2Fteam/projects"
if receivedPath != want {
t.Errorf("escaped path = %q, want %q (subgroup slash must be %%2F-encoded)", receivedPath, want)
}
}
func TestNormalizeMRState(t *testing.T) {
cases := map[string]string{
"opened": "open",
"closed": "closed",
"merged": "merged",
"locked": "locked",
"": "",
}
for in, want := range cases {
if got := normalizeMRState(in); got != want {
t.Errorf("normalizeMRState(%q) = %q, want %q", in, got, want)
}
}
}
func TestSplitFullReference(t *testing.T) {
// projectPath is the full namespace-qualified path so callers can pass
// it straight to projectRef. namespace is everything before the final
// "/" — same shape as Project.Namespace.
cases := []struct {
in string
wantNamespace string
wantPath string
}{
{"group/project!42", "group", "group/project"},
{"group/sub/project!7", "group/sub", "group/sub/project"},
{"group/project#10", "group", "group/project"},
{"noslash", "", ""},
{"", "", ""},
}
for _, tc := range cases {
ns, p := splitFullReference(tc.in)
if ns != tc.wantNamespace || p != tc.wantPath {
t.Errorf("splitFullReference(%q) = (%q, %q), want (%q, %q)", tc.in, ns, p, tc.wantNamespace, tc.wantPath)
}
}
}
func TestSummarizePipelines(t *testing.T) {
cases := []struct {
name string
input []Pipeline
state string
expectsTotal bool
}{
{"empty", nil, "", false},
{"success", []Pipeline{{Status: "success", JobsTotal: 3, JobsPassing: 3}}, "success", true},
{"failed", []Pipeline{{Status: "failed"}}, "failure", false},
{"running", []Pipeline{{Status: "running"}}, "pending", false},
{"skipped-suppressed", []Pipeline{{Status: "skipped"}}, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
state, total, _ := summarizePipelines(tc.input)
if state != tc.state {
t.Errorf("state = %q, want %q", state, tc.state)
}
if tc.expectsTotal && total == 0 {
t.Error("expected jobs total > 0")
}
})
}
}
func TestSummarizeApprovals(t *testing.T) {
cases := []struct {
have, required int
want string
}{
{0, 0, ""},
{1, 0, "approved"},
{2, 2, "approved"},
{1, 2, "pending"},
{0, 1, "pending"},
}
for _, tc := range cases {
if got := summarizeApprovals(tc.have, tc.required); got != tc.want {
t.Errorf("summarizeApprovals(%d,%d) = %q, want %q", tc.have, tc.required, got, tc.want)
}
}
}
func TestCountDiffLines(t *testing.T) {
diff := "--- a/foo.go\n+++ b/foo.go\n@@ -1,3 +1,3 @@\n-old\n+new1\n+new2\n unchanged\n"
add, del := countDiffLines(diff)
if add != 2 {
t.Errorf("additions = %d, want 2", add)
}
if del != 1 {
t.Errorf("deletions = %d, want 1", del)
}
}
func TestClampSearchPage(t *testing.T) {
cases := []struct{ inP, inPP, wantP, wantPP int }{
{0, 0, 1, defaultPageSize},
{-3, 5, 1, 5},
{2, 250, 2, maxPageSize},
{5, 30, 5, 30},
}
for _, tc := range cases {
p, pp := clampSearchPage(tc.inP, tc.inPP)
if p != tc.wantP || pp != tc.wantPP {
t.Errorf("clamp(%d,%d) = (%d,%d), want (%d,%d)", tc.inP, tc.inPP, p, pp, tc.wantP, tc.wantPP)
}
}
}
+41
View File
@@ -0,0 +1,41 @@
package gitlab
import (
"context"
"github.com/kandev/kandev/internal/common/logger"
)
// Provide builds the GitLab service stack: discovers a host (via the
// optional HostStore, falling back to DefaultHost), resolves the best
// available client, and returns a *Service plus a cleanup function.
//
// secrets is required for PAT-fallback auth; passing nil disables it.
// hostStore is optional — when present, the persisted host is used
// instead of DefaultHost.
func Provide(
ctx context.Context,
secrets SecretProvider,
hostStore HostStore,
log *logger.Logger,
) (*Service, func() error, error) {
host := DefaultHost
if hostStore != nil {
if persisted, err := hostStore.GetHost(ctx); err == nil && persisted != "" {
host = persisted
}
}
client, authMethod, err := NewClient(ctx, host, secrets, log)
if err != nil {
log.Warn("GitLab client not available: " + err.Error())
}
svc := NewService(host, client, authMethod, secrets, log)
if hostStore != nil {
svc.SetHostStore(hostStore)
}
cleanup := func() error { return nil }
return svc, cleanup, nil
}
+419
View File
@@ -0,0 +1,419 @@
package gitlab
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// secretNameToken is the canonical secret-store name for the GitLab PAT.
const secretNameToken = "GITLAB_TOKEN"
// ErrInvalidToken is returned by ConfigureToken when the supplied token is
// empty or fails the /user probe. The controller layer uses errors.Is to
// route this to HTTP 400 instead of 500 — a sentinel is more durable than
// string-matching against the error message, which would silently break
// the status mapping if the message wording changes.
var ErrInvalidToken = errors.New("invalid token")
// SecretManager handles secret create/update/delete for the token.
type SecretManager interface {
Create(ctx context.Context, name, value string) (id string, err error)
Update(ctx context.Context, id, value string) error
Delete(ctx context.Context, id string) error
}
// HostStore persists the configured GitLab host.
type HostStore interface {
GetHost(ctx context.Context) (string, error)
SetHost(ctx context.Context, host string) error
}
// Service coordinates GitLab integration operations. v1 surface is
// deliberately small: status, token configure/clear, host configure, MR
// feedback fetch, and MR discussion reply/resolve. Watches, presets, and
// stats are intentionally deferred to a follow-up.
type Service struct {
mu sync.RWMutex
host string
client Client
authMethod string
secrets SecretProvider
secretManager SecretManager
hostStore HostStore
store *Store
logger *logger.Logger
}
// SetStore wires the task↔MR persistence layer. Optional — when nil the
// task-mr endpoints return empty results and SyncTaskMR is a no-op.
func (s *Service) SetStore(store *Store) {
s.mu.Lock()
defer s.mu.Unlock()
s.store = store
}
// NewService builds a Service from an already-resolved client. Callers
// typically use Provide() instead of constructing this directly.
func NewService(host string, client Client, authMethod string, secrets SecretProvider, log *logger.Logger) *Service {
if host == "" {
host = DefaultHost
}
return &Service{
host: host,
client: client,
authMethod: authMethod,
secrets: secrets,
logger: log,
}
}
// SetSecretManager wires the secret-write dependency.
func (s *Service) SetSecretManager(m SecretManager) {
s.mu.Lock()
defer s.mu.Unlock()
s.secretManager = m
}
// SetHostStore wires the host-persistence dependency.
func (s *Service) SetHostStore(h HostStore) {
s.mu.Lock()
defer s.mu.Unlock()
s.hostStore = h
}
// Client returns the current underlying Client (may be a NoopClient).
func (s *Service) Client() Client {
s.mu.RLock()
defer s.mu.RUnlock()
return s.client
}
// Host returns the configured GitLab base URL.
func (s *Service) Host() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.host
}
// GetStatus returns the connection status surfaced to the frontend.
func (s *Service) GetStatus(ctx context.Context) (*Status, error) {
s.mu.RLock()
client := s.client
host := s.host
authMethod := s.authMethod
s.mu.RUnlock()
tokenConfigured, tokenSecretID, err := s.findTokenSecret(ctx)
if err != nil {
return nil, fmt.Errorf("look up token secret: %w", err)
}
if client == nil {
// Defensive — every Provide/NewClient path returns at least a
// NoopClient, so in practice this branch is unreachable. Keep
// RequiredScopes as an empty slice so the JSON contract matches
// the always-an-array shape declared on the TypeScript side.
return &Status{
AuthMethod: AuthMethodNone,
Host: host,
TokenConfigured: tokenConfigured,
TokenSecretID: tokenSecretID,
RequiredScopes: []string{},
}, nil
}
authenticated, authErr := client.IsAuthenticated(ctx)
username := ""
if authenticated {
username, _ = client.GetAuthenticatedUser(ctx)
}
status := &Status{
Authenticated: authenticated,
Username: username,
AuthMethod: authMethod,
Host: host,
TokenConfigured: tokenConfigured,
TokenSecretID: tokenSecretID,
RequiredScopes: []string{"api", "read_user"},
}
if authErr != nil {
// IsAuthenticated returns (false, nil) for 401/403 — that's a
// known "bad token" signal. Anything reaching here is a transport
// failure (network, 5xx, parse error) the user needs to see as
// "GitLab unreachable" rather than "not connected", so they don't
// delete a valid token during a transient outage.
status.ConnectionError = authErr.Error()
}
if g, ok := client.(*GLabClient); ok {
status.GLabVersion = g.Version()
}
return status, nil
}
// ConfigureToken stores a new PAT in the secret manager and rebuilds the
// client. Validates the token by calling /user before persisting.
func (s *Service) ConfigureToken(ctx context.Context, token string) error {
token = strings.TrimSpace(token)
if token == "" {
return fmt.Errorf("%w: empty", ErrInvalidToken)
}
if s.secretManager == nil {
return errors.New("secret manager not configured")
}
s.mu.RLock()
host := s.host
s.mu.RUnlock()
probe := NewPATClient(host, token)
if _, probeErr := probe.GetAuthenticatedUser(ctx); probeErr != nil {
// Only 401 / 403 mean "the token is bad". Network failures, 5xx,
// DNS errors etc. propagate without the ErrInvalidToken wrap so the
// controller surfaces them as "GitLab unreachable" instead of
// "invalid token" — otherwise during a GitLab outage a user might
// delete their (valid) token assuming it had been rejected.
var apiErr *APIError
if errors.As(probeErr, &apiErr) && (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
return fmt.Errorf("%w: %w", ErrInvalidToken, probeErr)
}
return fmt.Errorf("probe token: %w", probeErr)
}
configured, secretID, err := s.findTokenSecret(ctx)
if err != nil {
return fmt.Errorf("look up token secret: %w", err)
}
switch {
case configured && secretID != "":
if err := s.secretManager.Update(ctx, secretID, token); err != nil {
return fmt.Errorf("update token: %w", err)
}
default:
if _, err := s.secretManager.Create(ctx, secretNameToken, token); err != nil {
return fmt.Errorf("create token: %w", err)
}
}
// Build the installed client inside the write lock using the *current*
// s.host — if ConfigureHost ran between our snapshot above and now we'd
// otherwise install a client pointing at the previous host, leaving
// s.host and s.client desynced until the next reconfigure. The token
// was validated against `probe`; here we just construct a fresh client
// at the up-to-date host.
s.mu.Lock()
s.client = NewPATClient(s.host, token)
s.authMethod = AuthMethodPAT
installedHost := s.host
s.mu.Unlock()
// Log the host the installed client actually targets, not the
// pre-validation snapshot — a concurrent ConfigureHost between the
// two would otherwise leave the log entry referring to a stale value.
s.logger.Info("GitLab token configured", zap.String("host", installedHost))
return nil
}
// ClearToken removes the stored PAT and falls back to noop / glab.
func (s *Service) ClearToken(ctx context.Context) error {
if s.secretManager == nil {
return errors.New("secret manager not configured")
}
configured, secretID, err := s.findTokenSecret(ctx)
if err != nil {
return fmt.Errorf("look up token secret: %w", err)
}
if !configured || secretID == "" {
return nil
}
if err := s.secretManager.Delete(ctx, secretID); err != nil {
return fmt.Errorf("delete token: %w", err)
}
// Build the fallback client outside the write lock — NewClient may
// shell out to `glab auth status` (up to glabAuthTimeout = 10s), and
// holding the write lock for that long would freeze every concurrent
// GetStatus / GetMRFeedback / Client() call. Pre-build against a
// snapshot of s.host, then swap inside a short critical section.
// Guard the swap with a host-equality check so a concurrent
// ConfigureHost that already installed a newer client for a new
// host doesn't get clobbered by our stale-host build.
s.mu.RLock()
hostSnap := s.host
s.mu.RUnlock()
client, authMethod, _ := NewClient(ctx, hostSnap, s.secrets, s.logger)
s.mu.Lock()
if s.host == hostSnap {
s.client = client
s.authMethod = authMethod
}
// else: ConfigureHost moved s.host while we were probing — leave
// its (newer, correctly-targeted) client in place. The next
// reconfigure or auth-health poller will converge if needed.
s.mu.Unlock()
return nil
}
// ConfigureHost persists a new GitLab host URL and rebuilds the client.
// The host is normalized by stripping trailing slashes; an empty string
// resets to DefaultHost.
func (s *Service) ConfigureHost(ctx context.Context, host string) error {
host = strings.TrimRight(strings.TrimSpace(host), "/")
if host == "" {
host = DefaultHost
}
if !strings.HasPrefix(host, "https://") && !strings.HasPrefix(host, "http://") {
return errors.New("host must include scheme (https:// or http://)")
}
if err := CheckHost(ctx, host); err != nil {
return fmt.Errorf("host unreachable: %w", err)
}
// Persist before mutating the in-memory state so a failed write
// doesn't leave the running service pointing at a host that wasn't
// committed to disk.
if s.hostStore != nil {
if err := s.hostStore.SetHost(ctx, host); err != nil {
return fmt.Errorf("persist host: %w", err)
}
}
client, authMethod, _ := NewClient(ctx, host, s.secrets, s.logger)
s.mu.Lock()
s.host = host
s.client = client
s.authMethod = authMethod
s.mu.Unlock()
return nil
}
// GetMRFeedback proxies to the underlying client.
func (s *Service) GetMRFeedback(ctx context.Context, projectPath string, iid int) (*MRFeedback, error) {
return s.Client().GetMRFeedback(ctx, projectPath, iid)
}
// CreateMRDiscussionNote proxies to the underlying client.
func (s *Service) CreateMRDiscussionNote(ctx context.Context, projectPath string, iid int, discussionID, body string) (*MRNote, error) {
return s.Client().CreateMRDiscussionNote(ctx, projectPath, iid, discussionID, body)
}
// ResolveMRDiscussion proxies to the underlying client.
func (s *Service) ResolveMRDiscussion(ctx context.Context, projectPath string, iid int, discussionID string) error {
return s.Client().ResolveMRDiscussion(ctx, projectPath, iid, discussionID)
}
// findTokenSecret reports whether a GitLab token is stored in the secret
// store. Returns (configured, secretID, error). A nil secrets provider is
// treated as "not configured" without error; a List failure is returned so
// callers don't mistake a backend outage for "token absent" (which would
// hide a still-present secret from ClearToken / GetStatus).
func (s *Service) findTokenSecret(ctx context.Context) (bool, string, error) {
if s.secrets == nil {
return false, "", nil
}
items, err := s.secrets.List(ctx)
if err != nil {
return false, "", fmt.Errorf("list secrets: %w", err)
}
for _, item := range items {
if !item.HasValue {
continue
}
if item.Name == secretNameToken || item.Name == "gitlab_token" {
return true, item.ID, nil
}
}
return false, "", nil
}
// --- Task ↔ MR association ---
// SyncTaskMR fetches the MR's current state from GitLab and upserts the
// task↔MR row. Called from the orchestrator when an agent creates an MR via
// the `pr` skill, and from the topbar's manual refresh. Returns the upserted
// row so the caller can broadcast it on the WS bus.
//
// repositoryID is the task's repository UUID (empty for single-repo tasks).
// projectPath is the GitLab namespace/path. iid is the MR's per-project id.
func (s *Service) SyncTaskMR(ctx context.Context, taskID, repositoryID, projectPath string, iid int) (*TaskMR, error) {
s.mu.RLock()
client := s.client
store := s.store
host := s.host
s.mu.RUnlock()
if store == nil {
return nil, errors.New("gitlab store not configured")
}
if client == nil {
return nil, ErrNoClient
}
status, err := client.GetMRStatus(ctx, projectPath, iid)
if err != nil {
return nil, fmt.Errorf("fetch MR status: %w", err)
}
if status == nil || status.MR == nil {
return nil, fmt.Errorf("MR !%d not found in %s", iid, projectPath)
}
now := time.Now().UTC()
mr := status.MR
row := &TaskMR{
TaskID: taskID,
RepositoryID: repositoryID,
Host: host,
ProjectPath: projectPath,
MRIID: mr.IID,
MRURL: mr.WebURL,
MRTitle: mr.Title,
HeadBranch: mr.HeadBranch,
BaseBranch: mr.BaseBranch,
AuthorUsername: mr.AuthorUsername,
State: mr.State,
ApprovalState: status.ApprovalState,
PipelineState: status.PipelineState,
MergeStatus: status.MergeStatus,
Draft: mr.Draft,
ApprovalCount: status.ApprovalCount,
RequiredApprovals: status.RequiredApprovals,
PipelineJobsTotal: status.PipelineJobsTotal,
PipelineJobsPass: status.PipelineJobsPassing,
CreatedAt: mr.CreatedAt,
MergedAt: mr.MergedAt,
ClosedAt: mr.ClosedAt,
LastSyncedAt: &now,
}
if err := store.UpsertTaskMR(ctx, row); err != nil {
return nil, fmt.Errorf("upsert task MR: %w", err)
}
return row, nil
}
// ListTaskMRsByWorkspace surfaces all MR associations under a workspace,
// grouped by task ID. Returns an empty map when no store is configured.
func (s *Service) ListTaskMRsByWorkspace(ctx context.Context, workspaceID string) (map[string][]*TaskMR, error) {
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
return map[string][]*TaskMR{}, nil
}
return store.ListTaskMRsByWorkspaceID(ctx, workspaceID)
}
// ListTaskMRsByTask returns the MR(s) linked to a single task.
func (s *Service) ListTaskMRsByTask(ctx context.Context, taskID string) ([]*TaskMR, error) {
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
return nil, nil
}
return store.ListTaskMRsByTask(ctx, taskID)
}
@@ -0,0 +1,550 @@
package gitlab
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeSecretManager is an in-memory SecretManager + SecretProvider used to
// drive ConfigureToken / ClearToken without touching the real secret store.
type fakeSecretManager struct {
items []*SecretListItem
values map[string]string
createErr error
updateErr error
deleteErr error
createCalls int
updateCalls int
deleteCalls int
}
func newFakeSecretManager() *fakeSecretManager {
return &fakeSecretManager{values: map[string]string{}}
}
func (f *fakeSecretManager) List(context.Context) ([]*SecretListItem, error) {
return f.items, nil
}
func (f *fakeSecretManager) Reveal(_ context.Context, id string) (string, error) {
return f.values[id], nil
}
func (f *fakeSecretManager) Create(_ context.Context, name, value string) (string, error) {
f.createCalls++
if f.createErr != nil {
return "", f.createErr
}
id := "id-" + name
f.items = append(f.items, &SecretListItem{ID: id, Name: name, HasValue: true})
f.values[id] = value
return id, nil
}
func (f *fakeSecretManager) Update(_ context.Context, id, value string) error {
f.updateCalls++
if f.updateErr != nil {
return f.updateErr
}
f.values[id] = value
return nil
}
func (f *fakeSecretManager) Delete(_ context.Context, id string) error {
f.deleteCalls++
if f.deleteErr != nil {
return f.deleteErr
}
delete(f.values, id)
out := f.items[:0]
for _, it := range f.items {
if it.ID != id {
out = append(out, it)
}
}
f.items = out
return nil
}
// userHandler returns an http.Handler that responds to /api/v4/user as if it
// were a real GitLab. accept controls whether a request is treated as
// authorized.
func userHandler(accept func(token string) bool) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) {
if !accept(r.Header.Get("PRIVATE-TOKEN")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"username":"alice"}`))
})
mux.HandleFunc("/api/v4/version", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"version":"16.0.0"}`))
})
return mux
}
func newServiceFixture(t *testing.T, handler http.Handler) (*Service, *httptest.Server, *fakeSecretManager) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
log := newTestLogger(t)
mgr := newFakeSecretManager()
svc := NewService(srv.URL, NewNoopClient(srv.URL), AuthMethodNone, mgr, log)
svc.SetSecretManager(mgr)
return svc, srv, mgr
}
func TestService_ConfigureToken_ValidTokenCreatesSecretAndSwapsClient(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(token string) bool {
return token == "good-token"
}))
if err := svc.ConfigureToken(context.Background(), "good-token"); err != nil {
t.Fatalf("ConfigureToken err = %v", err)
}
if mgr.createCalls != 1 {
t.Errorf("create calls = %d, want 1", mgr.createCalls)
}
if mgr.updateCalls != 0 {
t.Errorf("update calls = %d, want 0 on first configure", mgr.updateCalls)
}
if _, ok := svc.Client().(*PATClient); !ok {
t.Errorf("client = %T, want *PATClient after configure", svc.Client())
}
status, _ := svc.GetStatus(context.Background())
if status.AuthMethod != AuthMethodPAT {
t.Errorf("auth_method = %q, want pat", status.AuthMethod)
}
}
func TestService_ConfigureToken_InvalidTokenReturnsErrorAndKeepsNoop(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return false }))
err := svc.ConfigureToken(context.Background(), "bad-token")
if err == nil {
t.Fatal("expected error for invalid token")
}
if !strings.Contains(err.Error(), "invalid token") {
t.Errorf("err = %v, want 'invalid token' prefix", err)
}
if mgr.createCalls != 0 {
t.Errorf("create calls = %d, want 0 — secret must not be written on invalid token", mgr.createCalls)
}
if _, ok := svc.Client().(*NoopClient); !ok {
t.Errorf("client = %T, want *NoopClient (unchanged after failure)", svc.Client())
}
}
func TestService_ConfigureToken_PreExistingTokenCallsUpdateNotCreate(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(token string) bool {
return token == "new-token"
}))
mgr.items = []*SecretListItem{{ID: "id-GITLAB_TOKEN", Name: "GITLAB_TOKEN", HasValue: true}}
mgr.values["id-GITLAB_TOKEN"] = "old-token"
if err := svc.ConfigureToken(context.Background(), "new-token"); err != nil {
t.Fatalf("ConfigureToken err = %v", err)
}
if mgr.updateCalls != 1 {
t.Errorf("update calls = %d, want 1", mgr.updateCalls)
}
if mgr.createCalls != 0 {
t.Errorf("create calls = %d, want 0 when secret exists", mgr.createCalls)
}
if mgr.values["id-GITLAB_TOKEN"] != "new-token" {
t.Errorf("stored token = %q, want new-token", mgr.values["id-GITLAB_TOKEN"])
}
}
func TestService_ConfigureToken_RejectsEmpty(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return true }))
err := svc.ConfigureToken(context.Background(), " ")
if err == nil {
t.Fatal("expected error for empty token")
}
if mgr.createCalls != 0 {
t.Errorf("create calls = %d, want 0", mgr.createCalls)
}
}
func TestService_ConfigureToken_ErrorsWhenSecretManagerMissing(t *testing.T) {
log := newTestLogger(t)
svc := NewService("", NewNoopClient(""), AuthMethodNone, nil, log)
err := svc.ConfigureToken(context.Background(), "anything")
if err == nil {
t.Fatal("expected error when secret manager not configured")
}
}
func TestService_ClearToken_NoSecretIsNoOp(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return true }))
t.Setenv("GITLAB_TOKEN", "") // make sure factory falls through to noop
if err := svc.ClearToken(context.Background()); err != nil {
t.Fatalf("ClearToken err = %v on empty store, want nil", err)
}
if mgr.deleteCalls != 0 {
t.Errorf("delete calls = %d, want 0", mgr.deleteCalls)
}
}
func TestService_ClearToken_DeletesSecretAndRebuildsClient(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return true }))
t.Setenv("GITLAB_TOKEN", "")
mgr.items = []*SecretListItem{{ID: "id-GITLAB_TOKEN", Name: "GITLAB_TOKEN", HasValue: true}}
mgr.values["id-GITLAB_TOKEN"] = "old"
if err := svc.ClearToken(context.Background()); err != nil {
t.Fatalf("ClearToken err = %v", err)
}
if mgr.deleteCalls != 1 {
t.Errorf("delete calls = %d, want 1", mgr.deleteCalls)
}
// After clearing the only PAT, the factory should fall back to noop
// (glab CLI may or may not be on the test host, so accept both).
if _, isPAT := svc.Client().(*PATClient); isPAT {
t.Errorf("client = *PATClient after clear, want non-PAT fallback")
}
}
func TestService_ConfigureHost_NormalisesAndPersists(t *testing.T) {
svc, srv, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
if err := svc.ConfigureHost(context.Background(), srv.URL+"/"); err != nil {
t.Fatalf("ConfigureHost err = %v", err)
}
if svc.Host() != srv.URL {
t.Errorf("host = %q, want %q (trailing slash trimmed)", svc.Host(), srv.URL)
}
}
func TestService_ConfigureHost_RejectsMissingScheme(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
err := svc.ConfigureHost(context.Background(), "gitlab.example.com")
if err == nil {
t.Fatal("expected error for host without scheme")
}
if !strings.Contains(err.Error(), "scheme") {
t.Errorf("err = %v, want mention of scheme", err)
}
}
func TestService_ConfigureHost_UnreachableHostReturnsError(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
// 192.0.2.0/24 is reserved by RFC 5737 for documentation; connecting
// to it deterministically fails (no route / no listener).
err := svc.ConfigureHost(context.Background(), "http://192.0.2.1:1/")
if err == nil {
t.Fatal("expected unreachable error")
}
if !strings.Contains(err.Error(), "unreachable") {
t.Errorf("err = %v, want 'unreachable' in message", err)
}
}
func TestService_GetStatus_ReportsAuthenticatedAfterConfigure(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(token string) bool {
return token == "ok"
}))
if err := svc.ConfigureToken(context.Background(), "ok"); err != nil {
t.Fatalf("ConfigureToken err = %v", err)
}
status, err := svc.GetStatus(context.Background())
if err != nil {
t.Fatalf("GetStatus err = %v", err)
}
if !status.Authenticated {
t.Error("status.Authenticated = false, want true")
}
if status.Username != "alice" {
t.Errorf("status.Username = %q, want alice", status.Username)
}
if !status.TokenConfigured {
t.Error("status.TokenConfigured = false, want true")
}
if status.ConnectionError != "" {
t.Errorf("status.ConnectionError = %q, want empty on healthy probe", status.ConnectionError)
}
}
// Regression: a 5xx (or any non-401/403 error) from the IsAuthenticated probe
// must surface as ConnectionError so the UI can render "unreachable" instead
// of "not connected" — the prior code dropped the error and made transient
// outages look like a bad token.
func TestService_GetStatus_PopulatesConnectionErrorOn5xx(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
svc, _, _ := newServiceFixture(t, mux)
// Force the service to actually call the upstream by swapping in a
// PATClient pointed at our fake-failing server.
svc.mu.Lock()
svc.client = NewPATClient(svc.host, "tok")
svc.authMethod = AuthMethodPAT
svc.mu.Unlock()
status, err := svc.GetStatus(context.Background())
if err != nil {
t.Fatalf("GetStatus err = %v", err)
}
if status.Authenticated {
t.Error("Authenticated = true on 5xx, want false")
}
if status.ConnectionError == "" {
t.Error("ConnectionError empty on 5xx, want populated (a transport failure must distinguish itself from 'not connected')")
}
}
// Counterpart: a 401 is a clean "bad token" signal — IsAuthenticated returns
// (false, nil) — and must leave ConnectionError empty so the UI doesn't
// render an "unreachable" warning over a legitimately-rejected token.
func TestService_GetStatus_ClearsConnectionErrorOn401(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
svc, _, _ := newServiceFixture(t, mux)
svc.mu.Lock()
svc.client = NewPATClient(svc.host, "bad")
svc.authMethod = AuthMethodPAT
svc.mu.Unlock()
status, err := svc.GetStatus(context.Background())
if err != nil {
t.Fatalf("GetStatus err = %v", err)
}
if status.Authenticated {
t.Error("Authenticated = true on 401, want false")
}
if status.ConnectionError != "" {
t.Errorf("ConnectionError = %q on 401, want empty (401 is a clean not-authenticated signal, not a transport failure)", status.ConnectionError)
}
}
// ConfigureToken must wrap ErrInvalidToken so the controller can route the
// failure to HTTP 400 via errors.Is, instead of string-matching on the
// error message. Covers both the empty-token path and the failed-probe path.
func TestService_ConfigureToken_WrapsErrInvalidToken(t *testing.T) {
t.Run("empty token", func(t *testing.T) {
log := newTestLogger(t)
svc := NewService("", NewNoopClient(""), AuthMethodNone, nil, log)
svc.SetSecretManager(newFakeSecretManager())
err := svc.ConfigureToken(context.Background(), " ")
if !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want errors.Is ErrInvalidToken", err)
}
})
t.Run("rejected by probe", func(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return false }))
err := svc.ConfigureToken(context.Background(), "bad-token")
if !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want errors.Is ErrInvalidToken", err)
}
})
}
// Regression: a 5xx (or any non-401/403) error from the probe must NOT be
// wrapped as ErrInvalidToken — that would surface as HTTP 400 "invalid token"
// to a user during a GitLab outage and risk them deleting a valid token.
func TestService_ConfigureToken_DoesNotWrapTransportErrorAsErrInvalidToken(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
svc, _, _ := newServiceFixture(t, mux)
err := svc.ConfigureToken(context.Background(), "valid-but-server-is-down")
if err == nil {
t.Fatal("expected error when probe hits 5xx")
}
if errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, must NOT be ErrInvalidToken on 5xx (would mislead user during an outage)", err)
}
}
// --- Task ↔ MR association ---
// withTaskMRStore wires a temporary GitLab Store onto the service so the
// task-MR methods exercise real SQLite. Returns the store for direct
// inspection.
func withTaskMRStore(t *testing.T, svc *Service) *Store {
t.Helper()
store := newTestStore(t)
svc.SetStore(store)
return store
}
// swapToMock replaces the service's client with an in-memory MockClient that
// tests can pre-seed with MRs. Returns the mock for further seeding.
func swapToMock(t *testing.T, svc *Service) *MockClient {
t.Helper()
mock := NewMockClient(svc.Host())
svc.mu.Lock()
svc.client = mock
svc.mu.Unlock()
return mock
}
func TestService_SyncTaskMR_UpsertsFromMockClient(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
store := withTaskMRStore(t, svc)
mock := swapToMock(t, svc)
seedTask(t, store, "task-1", "ws-1")
mock.SeedMR("acme/api", &MR{
IID: 42,
Title: "feat: thing",
HeadBranch: "feat/x",
BaseBranch: "main",
State: mrStateOpen,
WebURL: "https://gitlab.example/acme/api/-/merge_requests/42",
HeadSHA: "abc123",
CreatedAt: time.Now().UTC(),
})
row, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 42)
if err != nil {
t.Fatalf("SyncTaskMR err = %v", err)
}
if row.MRTitle != "feat: thing" || row.HeadBranch != "feat/x" || row.BaseBranch != "main" {
t.Errorf("unexpected upserted row: %+v", row)
}
if row.LastSyncedAt == nil {
t.Error("LastSyncedAt not stamped")
}
// Round-trip through the store: the row must be readable by ID.
stored, _ := store.ListTaskMRsByTask(context.Background(), "task-1")
if len(stored) != 1 || stored[0].MRTitle != "feat: thing" {
t.Fatalf("store roundtrip failed: %+v", stored)
}
}
func TestService_SyncTaskMR_PreservesPipelineJobsMapping(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
withTaskMRStore(t, svc)
mock := swapToMock(t, svc)
mock.SeedMR("acme/api", &MR{IID: 7, HeadSHA: "deadbeef", HeadBranch: "f", State: mrStateOpen})
// Two finished pipelines: one all-pass, one with a failure.
finished := time.Now().UTC()
mock.SeedPipelines("acme/api", []Pipeline{
{ID: 1, Status: "success", SHA: "deadbeef", JobsTotal: 3, JobsPassing: 3, FinishedAt: &finished},
})
row, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 7)
if err != nil {
t.Fatalf("sync err = %v", err)
}
if row.PipelineJobsTotal != 3 || row.PipelineJobsPass != 3 {
t.Errorf("pipeline jobs mapping wrong: total=%d pass=%d (want 3/3) — field-name regression",
row.PipelineJobsTotal, row.PipelineJobsPass)
}
}
func TestService_SyncTaskMR_ReturnsErrWhenMRNotFound(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
withTaskMRStore(t, svc)
swapToMock(t, svc)
_, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 999)
if err == nil {
t.Fatal("expected error for unseeded MR")
}
}
func TestService_SyncTaskMR_ErrorWhenStoreUnset(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
swapToMock(t, svc) // no SetStore — store remains nil
_, err := svc.SyncTaskMR(context.Background(), "task", "", "p", 1)
if err == nil {
t.Fatal("expected error when store is nil")
}
if !strings.Contains(err.Error(), "store") {
t.Errorf("err = %v, want mention of store", err)
}
}
func TestService_ListTaskMRsByWorkspace_EmptyWhenStoreUnset(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
got, err := svc.ListTaskMRsByWorkspace(context.Background(), "ws-1")
if err != nil {
t.Fatalf("err = %v", err)
}
if len(got) != 0 {
t.Errorf("got %d rows, want 0 when store is nil", len(got))
}
}
func TestService_ListTaskMRsByWorkspace_ScopesByWorkspaceJoin(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
store := withTaskMRStore(t, svc)
mock := swapToMock(t, svc)
seedTask(t, store, "task-a", "ws-1")
seedTask(t, store, "task-b", "ws-2")
mock.SeedMR("p1", &MR{IID: 1, HeadBranch: "x", BaseBranch: "main", State: mrStateOpen, CreatedAt: time.Now().UTC()})
mock.SeedMR("p2", &MR{IID: 2, HeadBranch: "y", BaseBranch: "main", State: mrStateOpen, CreatedAt: time.Now().UTC()})
if _, err := svc.SyncTaskMR(context.Background(), "task-a", "", "p1", 1); err != nil {
t.Fatalf("sync a: %v", err)
}
if _, err := svc.SyncTaskMR(context.Background(), "task-b", "", "p2", 2); err != nil {
t.Fatalf("sync b: %v", err)
}
got, err := svc.ListTaskMRsByWorkspace(context.Background(), "ws-1")
if err != nil {
t.Fatalf("err = %v", err)
}
if len(got) != 1 || len(got["task-a"]) != 1 {
t.Fatalf("ws-1 result = %+v, want task-a with one MR", got)
}
if _, leaked := got["task-b"]; leaked {
t.Error("ws-2's MR leaked into ws-1 result — workspace JOIN broken")
}
}
func TestService_ListTaskMRsByTask_DelegatesToStore(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
store := withTaskMRStore(t, svc)
mock := swapToMock(t, svc)
seedTask(t, store, "task-1", "ws-1")
mock.SeedMR("acme/api", &MR{IID: 1, HeadBranch: "x", BaseBranch: "main", State: mrStateOpen, CreatedAt: time.Now().UTC()})
if _, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 1); err != nil {
t.Fatalf("sync: %v", err)
}
got, err := svc.ListTaskMRsByTask(context.Background(), "task-1")
if err != nil {
t.Fatalf("err = %v", err)
}
if len(got) != 1 || got[0].ProjectPath != "acme/api" {
t.Errorf("got = %+v, want acme/api", got)
}
}
func TestService_ListTaskMRsByTask_NilWhenStoreUnset(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
got, err := svc.ListTaskMRsByTask(context.Background(), "task-1")
if err != nil {
t.Fatalf("err = %v", err)
}
if got != nil {
t.Errorf("got %v, want nil when store unset", got)
}
}
// Sanity: the package-level errors used by the tests are real values, not
// just docstrings — guards against accidental rename.
var _ error = ErrNoClient
var _ error = ErrInvalidToken
var _ = errors.New
+201
View File
@@ -0,0 +1,201 @@
package gitlab
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// Store backs the task↔MR association used by the topbar / review surface.
// Parallel to internal/github.Store's TaskPR plumbing. Kept intentionally
// minimal in v1 — watchers and review queues live behind their own tables
// when phase 3 lands.
type Store struct {
db *sqlx.DB
ro *sqlx.DB
}
// NewStore initialises the gitlab Store and creates its tables. db is the
// writer pool; ro is the read-only pool (same SQLite file, separate handle
// to avoid serialising reads behind writes). Both must be non-nil.
func NewStore(db, ro *sqlx.DB) (*Store, error) {
if db == nil || ro == nil {
return nil, errors.New("gitlab store: db and ro must be non-nil")
}
s := &Store{db: db, ro: ro}
if err := s.createTables(); err != nil {
return nil, fmt.Errorf("create gitlab tables: %w", err)
}
return s, nil
}
const createTablesSQL = `
CREATE TABLE IF NOT EXISTS gitlab_task_mrs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
repository_id TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '',
project_path TEXT NOT NULL,
mr_iid INTEGER NOT NULL,
mr_url TEXT NOT NULL,
mr_title TEXT NOT NULL,
head_branch TEXT NOT NULL,
base_branch TEXT NOT NULL,
author_username TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'open',
approval_state TEXT NOT NULL DEFAULT '',
pipeline_state TEXT NOT NULL DEFAULT '',
merge_status TEXT NOT NULL DEFAULT '',
draft INTEGER NOT NULL DEFAULT 0,
approval_count INTEGER DEFAULT 0,
required_approvals INTEGER DEFAULT 0,
pipeline_jobs_total INTEGER DEFAULT 0,
pipeline_jobs_pass INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
merged_at DATETIME,
closed_at DATETIME,
last_synced_at DATETIME,
updated_at DATETIME NOT NULL,
UNIQUE(task_id, repository_id, project_path, mr_iid)
);
CREATE INDEX IF NOT EXISTS idx_gitlab_task_mrs_task_id ON gitlab_task_mrs(task_id);
`
func (s *Store) createTables() error {
_, err := s.db.Exec(createTablesSQL)
return err
}
// taskMRSelectCols is the projection used by every SELECT so dev DBs that
// still have dropped columns (older revisions defined `unresolved_threads`
// and `discussion_count` here) don't break sqlx struct binding.
const taskMRSelectCols = `id, task_id, repository_id, host, project_path, mr_iid,
mr_url, mr_title, head_branch, base_branch, author_username, state,
approval_state, pipeline_state, merge_status, draft, approval_count,
required_approvals, pipeline_jobs_total, pipeline_jobs_pass,
created_at, merged_at, closed_at, last_synced_at, updated_at`
// taskMRSelectColsQualified is the same projection prefixed with `gtm.`,
// for the workspace JOIN where `tasks` shares column names (id, created_at,
// updated_at).
const taskMRSelectColsQualified = `gtm.id, gtm.task_id, gtm.repository_id,
gtm.host, gtm.project_path, gtm.mr_iid, gtm.mr_url, gtm.mr_title,
gtm.head_branch, gtm.base_branch, gtm.author_username, gtm.state,
gtm.approval_state, gtm.pipeline_state, gtm.merge_status, gtm.draft,
gtm.approval_count, gtm.required_approvals, gtm.pipeline_jobs_total,
gtm.pipeline_jobs_pass, gtm.created_at, gtm.merged_at, gtm.closed_at,
gtm.last_synced_at, gtm.updated_at`
// UpsertTaskMR creates or updates a task↔MR association keyed by
// (task_id, repository_id, project_path, mr_iid). Generates an ID on first
// insert; otherwise replaces the row's mutable fields and bumps updated_at.
func (s *Store) UpsertTaskMR(ctx context.Context, tm *TaskMR) error {
now := time.Now().UTC()
tm.UpdatedAt = now
existing, err := s.findTaskMR(ctx, tm.TaskID, tm.RepositoryID, tm.ProjectPath, tm.MRIID)
if err != nil {
return err
}
if existing != nil {
tm.ID = existing.ID
tm.CreatedAt = existing.CreatedAt
return s.updateTaskMR(ctx, tm)
}
if tm.ID == "" {
tm.ID = uuid.New().String()
}
if tm.CreatedAt.IsZero() {
tm.CreatedAt = now
}
_, err = s.db.NamedExecContext(ctx, `
INSERT INTO gitlab_task_mrs (
id, task_id, repository_id, host, project_path, mr_iid, mr_url, mr_title,
head_branch, base_branch, author_username, state, approval_state, pipeline_state,
merge_status, draft, approval_count, required_approvals,
pipeline_jobs_total, pipeline_jobs_pass,
created_at, merged_at, closed_at, last_synced_at, updated_at
) VALUES (
:id, :task_id, :repository_id, :host, :project_path, :mr_iid, :mr_url, :mr_title,
:head_branch, :base_branch, :author_username, :state, :approval_state, :pipeline_state,
:merge_status, :draft, :approval_count, :required_approvals,
:pipeline_jobs_total, :pipeline_jobs_pass,
:created_at, :merged_at, :closed_at, :last_synced_at, :updated_at
)`, tm)
return err
}
func (s *Store) updateTaskMR(ctx context.Context, tm *TaskMR) error {
_, err := s.db.NamedExecContext(ctx, `
UPDATE gitlab_task_mrs SET
host = :host, mr_url = :mr_url, mr_title = :mr_title,
head_branch = :head_branch, base_branch = :base_branch,
author_username = :author_username, state = :state,
approval_state = :approval_state, pipeline_state = :pipeline_state,
merge_status = :merge_status, draft = :draft,
approval_count = :approval_count, required_approvals = :required_approvals,
pipeline_jobs_total = :pipeline_jobs_total, pipeline_jobs_pass = :pipeline_jobs_pass,
merged_at = :merged_at, closed_at = :closed_at,
last_synced_at = :last_synced_at, updated_at = :updated_at
WHERE id = :id`, tm)
return err
}
func (s *Store) findTaskMR(ctx context.Context, taskID, repositoryID, projectPath string, iid int) (*TaskMR, error) {
var tm TaskMR
err := s.ro.GetContext(ctx, &tm,
`SELECT `+taskMRSelectCols+` FROM gitlab_task_mrs
WHERE task_id = ? AND repository_id = ? AND project_path = ? AND mr_iid = ?
LIMIT 1`, taskID, repositoryID, projectPath, iid)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &tm, nil
}
// ListTaskMRsByTask returns every MR association for a task, oldest first.
func (s *Store) ListTaskMRsByTask(ctx context.Context, taskID string) ([]*TaskMR, error) {
var mrs []TaskMR
if err := s.ro.SelectContext(ctx, &mrs,
`SELECT `+taskMRSelectCols+` FROM gitlab_task_mrs
WHERE task_id = ? ORDER BY created_at ASC`, taskID); err != nil {
return nil, err
}
out := make([]*TaskMR, 0, len(mrs))
for i := range mrs {
out = append(out, &mrs[i])
}
return out, nil
}
// ListTaskMRsByWorkspaceID returns every MR association for tasks inside the
// given workspace, grouped by task_id. Empty map when the workspace has no
// GitLab MRs.
func (s *Store) ListTaskMRsByWorkspaceID(ctx context.Context, workspaceID string) (map[string][]*TaskMR, error) {
var mrs []TaskMR
if err := s.ro.SelectContext(ctx, &mrs,
`SELECT `+taskMRSelectColsQualified+` FROM gitlab_task_mrs gtm
INNER JOIN tasks t ON gtm.task_id = t.id
WHERE t.workspace_id = ?
ORDER BY gtm.created_at ASC`, workspaceID); err != nil {
return nil, err
}
out := make(map[string][]*TaskMR)
for i := range mrs {
out[mrs[i].TaskID] = append(out[mrs[i].TaskID], &mrs[i])
}
return out, nil
}
// DeleteTaskMR removes a single task↔MR row.
func (s *Store) DeleteTaskMR(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_task_mrs WHERE id = ?`, id)
return err
}
+242
View File
@@ -0,0 +1,242 @@
package gitlab
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "gitlab.db")
dbConn, err := db.OpenSQLite(dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
if _, err := sqlxDB.Exec(`CREATE TABLE tasks (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT '',
archived_at DATETIME
)`); err != nil {
t.Fatalf("create tasks table: %v", err)
}
store, err := NewStore(sqlxDB, sqlxDB)
if err != nil {
t.Fatalf("new store: %v", err)
}
return store
}
func seedTask(t *testing.T, store *Store, taskID, workspaceID string) {
t.Helper()
if _, err := store.db.Exec(
`INSERT INTO tasks (id, workspace_id) VALUES (?, ?)`,
taskID, workspaceID,
); err != nil {
t.Fatalf("seed task %s: %v", taskID, err)
}
}
func newTestMR(taskID, repoID, project string, iid int) *TaskMR {
return &TaskMR{
TaskID: taskID,
RepositoryID: repoID,
Host: "https://gitlab.com",
ProjectPath: project,
MRIID: iid,
MRURL: "https://gitlab.com/" + project + "/-/merge_requests/1",
MRTitle: "test MR",
HeadBranch: "feat/x",
BaseBranch: "main",
State: "open",
CreatedAt: time.Now().UTC(),
}
}
func TestStore_UpsertTaskMR_InsertsThenUpdates(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
tm := newTestMR("task-1", "", "acme/api", 42)
if err := store.UpsertTaskMR(ctx, tm); err != nil {
t.Fatalf("initial upsert: %v", err)
}
if tm.ID == "" {
t.Fatal("expected ID populated after insert")
}
if tm.UpdatedAt.IsZero() {
t.Fatal("expected UpdatedAt populated after insert")
}
originalID := tm.ID
originalCreated := tm.CreatedAt
// Second upsert with new mutable fields — id and created_at must be
// preserved while title and state get the new values.
tm.MRTitle = "updated title"
tm.State = "merged"
if err := store.UpsertTaskMR(ctx, tm); err != nil {
t.Fatalf("second upsert: %v", err)
}
if tm.ID != originalID {
t.Errorf("ID changed across upsert: %q -> %q", originalID, tm.ID)
}
if !tm.CreatedAt.Equal(originalCreated) {
t.Errorf("CreatedAt clobbered: %v vs %v", originalCreated, tm.CreatedAt)
}
got, err := store.ListTaskMRsByTask(ctx, "task-1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 1 {
t.Fatalf("rows = %d, want 1 (upsert must not duplicate)", len(got))
}
if got[0].MRTitle != "updated title" || got[0].State != "merged" {
t.Errorf("title/state not updated: %+v", got[0])
}
}
func TestStore_UpsertTaskMR_KeyedByRepoIDAndProjectAndIID(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
// Three rows under the same task, distinguished by repository_id and IID.
rows := []*TaskMR{
newTestMR("task-1", "repo-a", "acme/api", 1),
newTestMR("task-1", "repo-b", "acme/api", 1), // same project + iid, different repo
newTestMR("task-1", "repo-a", "acme/web", 2), // same repo, different project + iid
}
for _, r := range rows {
if err := store.UpsertTaskMR(ctx, r); err != nil {
t.Fatalf("upsert %s/%d: %v", r.ProjectPath, r.MRIID, err)
}
}
got, err := store.ListTaskMRsByTask(ctx, "task-1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 3 {
t.Fatalf("rows = %d, want 3 (composite key must allow all three)", len(got))
}
}
func TestStore_ListTaskMRsByWorkspaceID_IsolatesWorkspaces(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
seedTask(t, store, "task-a", "ws-1")
seedTask(t, store, "task-b", "ws-2")
if err := store.UpsertTaskMR(ctx, newTestMR("task-a", "", "acme/api", 1)); err != nil {
t.Fatalf("upsert ws-1 MR: %v", err)
}
if err := store.UpsertTaskMR(ctx, newTestMR("task-b", "", "other/proj", 2)); err != nil {
t.Fatalf("upsert ws-2 MR: %v", err)
}
gotWS1, err := store.ListTaskMRsByWorkspaceID(ctx, "ws-1")
if err != nil {
t.Fatalf("list ws-1: %v", err)
}
if len(gotWS1) != 1 || len(gotWS1["task-a"]) != 1 {
t.Fatalf("ws-1 result = %+v, want one task with one MR", gotWS1)
}
if _, leaked := gotWS1["task-b"]; leaked {
t.Error("ws-1 query leaked task-b from ws-2")
}
gotWS2, err := store.ListTaskMRsByWorkspaceID(ctx, "ws-2")
if err != nil {
t.Fatalf("list ws-2: %v", err)
}
if len(gotWS2) != 1 || gotWS2["task-b"][0].ProjectPath != "other/proj" {
t.Errorf("ws-2 result = %+v, want task-b/other/proj", gotWS2)
}
gotEmpty, err := store.ListTaskMRsByWorkspaceID(ctx, "ws-nonexistent")
if err != nil {
t.Fatalf("list empty: %v", err)
}
if len(gotEmpty) != 0 {
t.Errorf("empty-workspace result = %+v, want empty map", gotEmpty)
}
}
func TestStore_ListTaskMRsByWorkspaceID_OrdersByCreatedAtAsc(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
seedTask(t, store, "task-1", "ws-1")
older := newTestMR("task-1", "", "acme/api", 1)
older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
newer := newTestMR("task-1", "", "acme/api", 2)
newer.CreatedAt = time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
// Insert newer first to verify ORDER BY is not relying on insertion order.
if err := store.UpsertTaskMR(ctx, newer); err != nil {
t.Fatalf("upsert newer: %v", err)
}
if err := store.UpsertTaskMR(ctx, older); err != nil {
t.Fatalf("upsert older: %v", err)
}
got, err := store.ListTaskMRsByWorkspaceID(ctx, "ws-1")
if err != nil {
t.Fatalf("list: %v", err)
}
mrs := got["task-1"]
if len(mrs) != 2 {
t.Fatalf("rows = %d, want 2", len(mrs))
}
if mrs[0].MRIID != 1 || mrs[1].MRIID != 2 {
t.Errorf("order = [%d, %d], want [1, 2] (created_at ASC)", mrs[0].MRIID, mrs[1].MRIID)
}
}
func TestStore_ListTaskMRsByTask_ReturnsEmptyForUnknownTask(t *testing.T) {
store := newTestStore(t)
got, err := store.ListTaskMRsByTask(context.Background(), "missing-task")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 0 {
t.Errorf("rows = %d, want 0 for unknown task", len(got))
}
}
func TestStore_DeleteTaskMR_RemovesByID(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
tm := newTestMR("task-1", "", "acme/api", 1)
if err := store.UpsertTaskMR(ctx, tm); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := store.DeleteTaskMR(ctx, tm.ID); err != nil {
t.Fatalf("delete: %v", err)
}
got, _ := store.ListTaskMRsByTask(ctx, "task-1")
if len(got) != 0 {
t.Errorf("rows after delete = %d, want 0", len(got))
}
}
func TestStore_DeleteTaskMR_UnknownIDIsNoOp(t *testing.T) {
store := newTestStore(t)
if err := store.DeleteTaskMR(context.Background(), "no-such-id"); err != nil {
t.Errorf("delete unknown = %v, want nil (DELETE of 0 rows is not an error)", err)
}
}
func TestStore_NewStore_RejectsNilDB(t *testing.T) {
if _, err := NewStore(nil, nil); err == nil {
t.Fatal("expected error when both db handles are nil")
}
}
+24 -8
View File
@@ -34,25 +34,41 @@ func DetectGitProtocol() string {
// For HTTPS: https://github.com/{owner}/{name}.git
// Returns an error if the provider is not supported.
func CloneURL(provider, owner, name, protocol string) (string, error) {
host, err := providerHost(provider)
if err != nil {
return "", err
return CloneURLWithHost(provider, "", owner, name, protocol)
}
// CloneURLWithHost is like CloneURL but accepts an explicit host. When host
// is non-empty (and stripped of scheme/trailing-slash), it overrides the
// provider's default — used for self-managed GitLab. When host is empty,
// behaves exactly like CloneURL.
func CloneURLWithHost(provider, host, owner, name, protocol string) (string, error) {
resolvedHost := strings.TrimRight(host, "/")
resolvedHost = strings.TrimPrefix(strings.TrimPrefix(resolvedHost, "https://"), "http://")
if resolvedHost == "" {
var err error
resolvedHost, err = providerHost(provider)
if err != nil {
return "", err
}
}
if protocol == ProtocolSSH {
return fmt.Sprintf("git@%s:%s/%s.git", host, owner, name), nil
// scp-style "git@host:path" can't carry a port — when the host has
// one (gitlab.acme.corp:2222) fall back to the ssh:// URL form,
// which git understands and accepts a port natively.
if strings.Contains(resolvedHost, ":") {
return fmt.Sprintf("ssh://git@%s/%s/%s.git", resolvedHost, owner, name), nil
}
return fmt.Sprintf("git@%s:%s/%s.git", resolvedHost, owner, name), nil
}
return fmt.Sprintf("https://%s/%s/%s.git", host, owner, name), nil
return fmt.Sprintf("https://%s/%s/%s.git", resolvedHost, owner, name), nil
}
// providerHost maps a provider name to its git host.
// Only "github" (and the empty string) are currently supported.
// "gitlab" and "bitbucket" entries are placeholders for future support.
func providerHost(provider string) (string, error) {
switch strings.ToLower(provider) {
case "github", "":
return "github.com", nil
case "gitlab":
// TODO: GitLab support is not yet implemented
return "gitlab.com", nil
case "bitbucket":
// TODO: Bitbucket support is not yet implemented
@@ -63,6 +63,57 @@ func TestCloneURL(t *testing.T) {
}
}
func TestCloneURLWithHost(t *testing.T) {
tests := []struct {
name string
provider string
host string
owner string
repo string
protocol string
want string
}{
{
"gitlab self-managed SSH",
"gitlab", "https://gitlab.acme.corp", "team", "service", ProtocolSSH,
"git@gitlab.acme.corp:team/service.git",
},
{
"gitlab self-managed HTTPS",
"gitlab", "https://gitlab.acme.corp/", "team", "service", ProtocolHTTPS,
"https://gitlab.acme.corp/team/service.git",
},
{
"empty host falls back to provider default",
"gitlab", "", "team", "service", ProtocolSSH,
"git@gitlab.com:team/service.git",
},
{
"http scheme honored",
"gitlab", "http://gitlab.local", "team", "service", ProtocolHTTPS,
"https://gitlab.local/team/service.git",
},
{
// scp-style "git@host:path" can't carry a port; ssh:// URL
// form is the only correct shape when one is present.
"self-managed SSH with port falls back to ssh:// URL",
"gitlab", "https://gitlab.acme.corp:2222", "team", "service", ProtocolSSH,
"ssh://git@gitlab.acme.corp:2222/team/service.git",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CloneURLWithHost(tt.provider, tt.host, tt.owner, tt.repo, tt.protocol)
if err != nil {
t.Fatalf("err = %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestProviderHost(t *testing.T) {
tests := []struct {
provider string
+380
View File
@@ -0,0 +1,380 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import {
IconBrandGitlab,
IconBug,
IconCheck,
IconClock,
IconExternalLink,
IconGitMerge,
IconRefresh,
IconX,
} from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Spinner } from "@kandev/ui/spinner";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@kandev/ui/tabs";
import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api";
import { useGitLabUserIssues, useGitLabUserMRs } from "@/hooks/domains/gitlab/use-user-search";
import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab";
// Filter values map to GitLab's "scope" query param semantics.
const MR_FILTERS = [
{ value: "assigned_to_me", label: "Assigned" },
{ value: "created_by_me", label: "Authored" },
{ value: "review_requested", label: "Review requested" },
] as const;
const ISSUE_FILTERS = [
{ value: "assigned_to_me", label: "Assigned" },
{ value: "created_by_me", label: "Created" },
] as const;
function MRStateBadge({ mr }: { mr: MR }) {
const state = mr.state === "opened" ? "open" : mr.state;
if (state === "merged") {
return (
<Badge variant="secondary" className="gap-1 bg-purple-500/10 text-purple-500">
<IconCheck className="h-3 w-3" /> Merged
</Badge>
);
}
if (state === "closed") {
return (
<Badge variant="outline" className="gap-1">
<IconX className="h-3 w-3" /> Closed
</Badge>
);
}
if (mr.draft) {
return (
<Badge variant="outline" className="gap-1 text-muted-foreground">
<IconClock className="h-3 w-3" /> Draft
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1 text-emerald-500">
<IconGitMerge className="h-3 w-3" /> Open
</Badge>
);
}
function IssueStateBadge({ issue }: { issue: Issue }) {
if (issue.state === "closed") {
return (
<Badge variant="outline" className="gap-1">
<IconCheck className="h-3 w-3" /> Closed
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1 text-emerald-500">
<IconBug className="h-3 w-3" /> Open
</Badge>
);
}
function NotConnectedNotice() {
return (
<Card>
<CardContent className="py-10 text-center space-y-2">
<IconBrandGitlab className="h-10 w-10 mx-auto text-muted-foreground" />
<p className="text-sm font-medium">GitLab not connected</p>
<p className="text-xs text-muted-foreground">
Connect your GitLab account to browse merge requests and issues from this page.
</p>
<Button asChild size="sm" variant="outline" className="cursor-pointer mt-2">
<Link href="/settings/integrations/gitlab">Open settings</Link>
</Button>
</CardContent>
</Card>
);
}
function MRList({ filter, query }: { filter: string; query: string }) {
const { items: mrs, loading, error } = useGitLabUserMRs(filter, query);
if (loading) {
return (
<div className="flex justify-center py-6">
<Spinner className="h-4 w-4" />
</div>
);
}
if (error) {
return <p className="text-sm text-destructive py-4">{error}</p>;
}
if (mrs.length === 0) {
return (
<p className="text-sm text-muted-foreground py-6 text-center">No merge requests found.</p>
);
}
return (
<ul className="space-y-1.5">
{mrs.map((mr) => (
<li key={`${mr.project_path}-${mr.iid}`}>
<Link
href={mr.web_url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-md border hover:border-primary/40 transition-colors cursor-pointer"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium truncate">
<span className="truncate">{mr.title}</span>
<IconExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-1 truncate">
{mr.project_path} !{mr.iid} · {mr.head_branch} → {mr.base_branch}
</p>
</div>
<MRStateBadge mr={mr} />
</div>
</Link>
</li>
))}
</ul>
);
}
function IssueList({ filter, query }: { filter: string; query: string }) {
const { items: issues, loading, error } = useGitLabUserIssues(filter, query);
if (loading) {
return (
<div className="flex justify-center py-6">
<Spinner className="h-4 w-4" />
</div>
);
}
if (error) {
return <p className="text-sm text-destructive py-4">{error}</p>;
}
if (issues.length === 0) {
return <p className="text-sm text-muted-foreground py-6 text-center">No issues found.</p>;
}
return (
<ul className="space-y-1.5">
{issues.map((issue) => (
<li key={`${issue.project_path}-${issue.iid}`}>
<Link
href={issue.web_url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-md border hover:border-primary/40 transition-colors cursor-pointer"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium truncate">
<span className="truncate">{issue.title}</span>
<IconExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-1 truncate">
{issue.project_path} #{issue.iid}
{issue.labels.length > 0 && ` · ${issue.labels.slice(0, 3).join(", ")}`}
</p>
</div>
<IssueStateBadge issue={issue} />
</div>
</Link>
</li>
))}
</ul>
);
}
export function GitLabPageClient({ workspaceId: _workspaceId }: { workspaceId?: string }) {
const [status, setStatus] = useState<GitLabStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [mrFilter, setMrFilter] = useState<string>(MR_FILTERS[0].value);
const [issueFilter, setIssueFilter] = useState<string>(ISSUE_FILTERS[0].value);
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
useEffect(() => {
const t = setTimeout(() => setDebounced(search), 200);
return () => clearTimeout(t);
}, [search]);
const reloadStatus = useCallback(() => {
setStatusLoading(true);
fetchGitLabStatus({ cache: "no-store" })
.then(setStatus)
.catch(() => setStatus(null))
.finally(() => setStatusLoading(false));
}, []);
useEffect(() => {
let cancelled = false;
fetchGitLabStatus({ cache: "no-store" })
.then((s) => {
if (!cancelled) setStatus(s);
})
.catch(() => {
if (!cancelled) setStatus(null);
})
.finally(() => {
if (!cancelled) setStatusLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// Pass debounced search through as the custom_query. GitLab understands
// free-text search via the `search` URL param; mapping the raw text to it
// keeps the surface simple — power users can still type the param-style
// string like "labels=bug&author_username=me" and the backend forwards
// it as customQuery.
const customQuery = useMemo(() => {
const q = debounced.trim();
if (!q) return "";
if (q.includes("=")) return q; // assume raw key=value
return `search=${encodeURIComponent(q)}`;
}, [debounced]);
const connected = status?.authenticated || status?.token_configured;
return (
<div className="space-y-4 p-4 md:p-6">
<PageHeader status={status} statusLoading={statusLoading} onRefresh={reloadStatus} />
{!statusLoading && !connected ? (
<NotConnectedNotice />
) : (
<BrowseTabs
search={search}
setSearch={setSearch}
mrFilter={mrFilter}
setMrFilter={setMrFilter}
issueFilter={issueFilter}
setIssueFilter={setIssueFilter}
customQuery={customQuery}
/>
)}
</div>
);
}
function PageHeader({
status,
statusLoading,
onRefresh,
}: {
status: GitLabStatus | null;
statusLoading: boolean;
onRefresh: () => void;
}) {
return (
<header className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<IconBrandGitlab className="h-5 w-5 text-orange-500" />
<div>
<h2 className="text-xl font-bold">GitLab</h2>
<p className="text-xs text-muted-foreground">
{status?.host ?? "https://gitlab.com"} · merge requests and issues
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={statusLoading}
className="gap-1 cursor-pointer"
>
<IconRefresh className="h-3 w-3" />
Refresh
</Button>
</header>
);
}
function BrowseTabs({
search,
setSearch,
mrFilter,
setMrFilter,
issueFilter,
setIssueFilter,
customQuery,
}: {
search: string;
setSearch: (v: string) => void;
mrFilter: string;
setMrFilter: (v: string) => void;
issueFilter: string;
setIssueFilter: (v: string) => void;
customQuery: string;
}) {
return (
<>
<Input
placeholder="Search (free text, or paste GitLab query like labels=bug&state=opened)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="font-mono text-sm"
/>
<Tabs defaultValue="mrs">
<TabsList>
<TabsTrigger value="mrs" className="gap-1.5">
<IconGitMerge className="h-3.5 w-3.5" />
Merge requests
</TabsTrigger>
<TabsTrigger value="issues" className="gap-1.5">
<IconBug className="h-3.5 w-3.5" />
Issues
</TabsTrigger>
</TabsList>
<TabsContent value="mrs" className="space-y-3">
<FilterButtons
value={mrFilter}
onChange={setMrFilter}
options={MR_FILTERS as readonly { value: string; label: string }[]}
/>
<MRList filter={mrFilter} query={customQuery} />
</TabsContent>
<TabsContent value="issues" className="space-y-3">
<FilterButtons
value={issueFilter}
onChange={setIssueFilter}
options={ISSUE_FILTERS as readonly { value: string; label: string }[]}
/>
<IssueList filter={issueFilter} query={customQuery} />
</TabsContent>
</Tabs>
</>
);
}
function FilterButtons({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: readonly { value: string; label: string }[];
}) {
return (
<div className="flex gap-1.5 flex-wrap">
{options.map((f) => (
<Button
key={f.value}
size="sm"
variant={value === f.value ? "default" : "outline"}
onClick={() => onChange(f.value)}
className="cursor-pointer"
>
{f.label}
</Button>
))}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { listWorkspacesAction } from "@/app/actions/workspaces";
import { fetchUserSettings } from "@/lib/api";
import { StateHydrator } from "@/components/state-hydrator";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import { GitLabPageClient } from "./gitlab-page-client";
import type { Workspace, UserSettingsResponse } from "@/lib/types/http";
import type { AppState } from "@/lib/state/store";
// Minimal SSR entrypoint for /gitlab. v1 surface: list the current user's
// open MRs + issues and let them click through to GitLab. The browse-and-
// trigger flow (review/issue watchers, presets) is parallel to /github and
// will land alongside the watchers backend in a follow-up.
export default async function GitLabPage() {
let workspaces: Workspace[] = [];
let workspaceId: string | undefined;
let userSettingsResponse: UserSettingsResponse | null = null;
try {
const [workspacesResponse, settingsResponse] = await Promise.all([
listWorkspacesAction(),
fetchUserSettings({ cache: "no-store" }).catch(() => null),
]);
workspaces = workspacesResponse.workspaces;
userSettingsResponse = settingsResponse;
workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id;
} catch (error) {
console.error("Failed to load GitLab page data:", error);
}
const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse);
const initialState: Partial<AppState> = {
workspaces: { items: workspaces, activeId: workspaceId ?? null },
userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null },
};
return (
<>
<StateHydrator initialState={initialState} />
<GitLabPageClient workspaceId={workspaceId} />
</>
);
}
@@ -0,0 +1,5 @@
import { GitLabIntegrationPage } from "@/components/gitlab/gitlab-settings";
export default function IntegrationsGitLabPage() {
return <GitLabIntegrationPage />;
}
+13 -1
View File
@@ -1,5 +1,11 @@
import Link from "next/link";
import { IconBrandGithub, IconBrandSlack, IconHexagon, IconTicket } from "@tabler/icons-react";
import {
IconBrandGithub,
IconBrandGitlab,
IconBrandSlack,
IconHexagon,
IconTicket,
} from "@tabler/icons-react";
import { Card, CardContent } from "@kandev/ui/card";
const INTEGRATIONS = [
@@ -9,6 +15,12 @@ const INTEGRATIONS = [
description: "PR review queues, issue watchers, and OAuth credentials.",
Icon: IconBrandGithub,
},
{
href: "/settings/integrations/gitlab",
label: "GitLab",
description: "Merge request creation, discussion replies, and self-managed hosts.",
Icon: IconBrandGitlab,
},
{
href: "/settings/integrations/jira",
label: "Jira",
@@ -0,0 +1,314 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
IconAlertTriangle,
IconBrandGitlab,
IconCheck,
IconEye,
IconEyeOff,
IconKey,
IconRefresh,
IconTrash,
IconWorld,
IconX,
} from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Separator } from "@kandev/ui/separator";
import { Spinner } from "@kandev/ui/spinner";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import {
clearGitLabToken,
configureGitLabHost,
configureGitLabToken,
fetchGitLabStatus,
} from "@/lib/api/domains/gitlab-api";
import type { GitLabStatus } from "@/lib/types/gitlab";
const DEFAULT_HOST = "https://gitlab.com";
function StatusBadge({ status }: { status: GitLabStatus | null }) {
if (!status) return null;
if (status.authenticated) {
return (
<Badge variant="secondary" className="gap-1">
<IconCheck className="h-3 w-3" /> Connected
</Badge>
);
}
// A non-empty connection_error means the probe failed for transport reasons
// (network / 5xx / parse) — distinct from "no token configured", which has
// an empty connection_error and authenticated=false.
if (status.connection_error) {
return (
<Badge
variant="outline"
className="gap-1 border-amber-500/60 text-amber-700 dark:text-amber-300"
>
<IconAlertTriangle className="h-3 w-3" /> Unreachable
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1">
<IconX className="h-3 w-3" /> Not connected
</Badge>
);
}
// ConnectionErrorAlert renders the per-host transport failure separately from
// the "bad token" path so users see "GitLab is currently unreachable" instead
// of "your token is broken" during an outage. Hidden when the probe succeeded
// or when no token is configured (nothing to probe).
function ConnectionErrorAlert({ status }: { status: GitLabStatus | null }) {
if (!status?.connection_error) return null;
return (
<Alert variant="destructive">
<IconAlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
Couldn&apos;t reach <code className="font-mono text-xs">{status.host}</code>:{" "}
{status.connection_error}
<span className="block text-xs opacity-80 mt-1">
Your token may still be valid — this looks like a network or upstream issue.
</span>
</AlertDescription>
</Alert>
);
}
function AuthMethodBadge({ method }: { method: GitLabStatus["auth_method"] }) {
const labels: Record<GitLabStatus["auth_method"], string> = {
glab_cli: "glab CLI",
pat: "Personal access token",
none: "Not configured",
mock: "Mock (test)",
};
return <Badge variant="outline">{labels[method] ?? method}</Badge>;
}
function HostForm({ initial, onSaved }: { initial: string; onSaved: () => void }) {
const [host, setHost] = useState(initial);
const [saving, setSaving] = useState(false);
const { toast } = useToast();
useEffect(() => {
setHost(initial);
}, [initial]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!host.trim()) return;
setSaving(true);
try {
await configureGitLabHost(host.trim());
toast({ description: "GitLab host updated", variant: "success" });
onSaved();
} catch (err) {
toast({
description: err instanceof Error ? err.message : "Failed to update host",
variant: "error",
});
} finally {
setSaving(false);
}
},
[host, toast, onSaved],
);
return (
<form onSubmit={handleSubmit} className="flex gap-2 items-center">
<IconWorld className="h-4 w-4 text-muted-foreground shrink-0" />
<Input
type="url"
placeholder={DEFAULT_HOST}
value={host}
onChange={(e) => setHost(e.target.value)}
className="font-mono text-sm"
disabled={saving}
/>
<Button type="submit" disabled={saving || !host.trim()} className="cursor-pointer">
{saving ? <Spinner className="h-3 w-3" /> : "Save host"}
</Button>
</form>
);
}
function TokenForm({ onSuccess }: { onSuccess: () => void }) {
const [token, setToken] = useState("");
const [showToken, setShowToken] = useState(false);
const [saving, setSaving] = useState(false);
const { toast } = useToast();
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) return;
setSaving(true);
try {
await configureGitLabToken(token.trim());
toast({ description: "GitLab token configured", variant: "success" });
setToken("");
onSuccess();
} catch (err) {
toast({
description: err instanceof Error ? err.message : "Failed to save token",
variant: "error",
});
} finally {
setSaving(false);
}
},
[token, toast, onSuccess],
);
return (
<form onSubmit={handleSubmit} className="flex gap-2 items-center">
<IconKey className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="relative flex-1">
<Input
type={showToken ? "text" : "password"}
placeholder="glpat-xxxxxxxxxxxxxxxxxxxx"
value={token}
onChange={(e) => setToken(e.target.value)}
className="font-mono text-sm pr-9"
disabled={saving}
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
aria-label={showToken ? "Hide token" : "Show token"}
>
{showToken ? <IconEyeOff className="h-4 w-4" /> : <IconEye className="h-4 w-4" />}
</button>
</div>
<Button type="submit" disabled={saving || !token.trim()} className="cursor-pointer">
{saving ? <Spinner className="h-3 w-3" /> : "Save token"}
</Button>
</form>
);
}
function ClearTokenButton({ onCleared }: { onCleared: () => void }) {
const [busy, setBusy] = useState(false);
const { toast } = useToast();
return (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await clearGitLabToken();
toast({ description: "GitLab token cleared" });
onCleared();
} catch (err) {
toast({
description: err instanceof Error ? err.message : "Failed to clear token",
variant: "error",
});
} finally {
setBusy(false);
}
}}
className="gap-1 cursor-pointer"
>
{busy ? <Spinner className="h-3 w-3" /> : <IconTrash className="h-3 w-3" />}
Clear token
</Button>
);
}
export function GitLabIntegrationPage() {
const [status, setStatus] = useState<GitLabStatus | null>(null);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
setLoading(true);
try {
const next = await fetchGitLabStatus({ cache: "no-store" });
setStatus(next);
} catch {
setStatus(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
return (
<SettingsSection
title="GitLab"
description="Connect a GitLab account so kandev can open merge requests, read review discussions, and reply to / resolve them on your behalf."
icon={<IconBrandGitlab className="h-4 w-4" />}
action={
<Button
variant="outline"
size="sm"
onClick={() => void reload()}
disabled={loading}
className="gap-1 cursor-pointer"
>
<IconRefresh className="h-3 w-3" />
Refresh
</Button>
}
>
<Card>
<CardContent className="space-y-4 py-4">
<ConnectionErrorAlert status={status} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusBadge status={status} />
{status && <AuthMethodBadge method={status.auth_method} />}
{status?.glab_version && (
<Badge variant="outline" className="font-mono text-xs">
glab {status.glab_version}
</Badge>
)}
</div>
{status?.username && (
<span className="text-xs text-muted-foreground">
Logged in as <span className="font-medium">{status.username}</span>
</span>
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
GitLab host URL. Override for self-managed instances; leave at the default for
gitlab.com.
</p>
<HostForm initial={status?.host ?? DEFAULT_HOST} onSaved={() => void reload()} />
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Personal access token. Required scopes: <code>api</code>, <code>read_user</code>.
Stored encrypted in the kandev secret store.
</p>
{status?.token_configured && <ClearTokenButton onCleared={() => void reload()} />}
</div>
<TokenForm onSuccess={() => void reload()} />
</div>
</CardContent>
</Card>
</SettingsSection>
);
}
@@ -0,0 +1,95 @@
"use client";
import { memo } from "react";
import Link from "next/link";
import { IconBrandGitlab, IconCheck, IconClock, IconGitMerge, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTaskMRs } from "@/hooks/domains/gitlab/use-task-mr";
import { useAppStore } from "@/components/state-provider";
import type { TaskMR } from "@/lib/types/gitlab";
/**
* Icon + colour for an MR's combined state. Mirrors github's pr-task-icon
* priority order so a merged MR reads the same as a merged PR: terminal
* states first, then pipeline failures / changes-requested, then ready-to-
* merge, then awaiting-something, then pipeline-running.
*/
function MRStatusIcon({ mr }: { mr: TaskMR }) {
if (mr.state === "merged") return <IconCheck className="h-3 w-3 text-purple-500" />;
if (mr.state === "closed") return <IconX className="h-3 w-3 text-muted-foreground" />;
if (mr.pipeline_state === "failure") return <IconX className="h-3 w-3 text-red-500" />;
if (mr.approval_state === "approved" && mr.pipeline_state === "success" && !mr.draft) {
return <IconCheck className="h-3 w-3 text-emerald-400" />;
}
if (mr.approval_state === "pending") return <IconClock className="h-3 w-3 text-sky-400" />;
if (mr.pipeline_state === "pending") return <IconClock className="h-3 w-3 text-yellow-500" />;
return null;
}
function statusTextColor(mr: TaskMR): string {
if (mr.state === "merged") return "text-purple-500";
if (mr.state === "closed") return "text-muted-foreground";
if (mr.pipeline_state === "failure") return "text-red-500";
if (mr.approval_state === "approved") return "text-emerald-400";
if (mr.approval_state === "pending") return "text-sky-400";
return "text-muted-foreground";
}
function MRSingleButton({ mr }: { mr: TaskMR }) {
const tooltip = `${mr.project_path} !${mr.mr_iid} — ${mr.mr_title}`;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
data-testid="mr-topbar-button"
data-mr-iid={mr.mr_iid}
data-mr-state={mr.state}
size="sm"
variant="outline"
className="cursor-pointer gap-1.5 px-2"
>
<Link href={mr.mr_url} target="_blank" rel="noopener noreferrer">
<IconGitMerge className={`h-4 w-4 ${statusTextColor(mr)}`} />
<span className="text-xs font-medium">!{mr.mr_iid}</span>
<MRStatusIcon mr={mr} />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
function MRMultiButton({ mrs }: { mrs: TaskMR[] }) {
// 2+ MRs collapse into a single GitLab icon with a count. Detail panel
// / dropdown for fan-out lands with phase 4.
const failed = mrs.filter((m) => m.pipeline_state === "failure").length;
const merged = mrs.filter((m) => m.state === "merged").length;
const open = mrs.filter((m) => m.state === "open" || m.state === "opened").length;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer gap-1.5 px-2">
<IconBrandGitlab className="h-4 w-4 text-orange-500" />
<span className="text-xs font-medium">{mrs.length} MRs</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{open > 0 && <div>{open} open</div>}
{failed > 0 && <div className="text-red-500">{failed} failing</div>}
{merged > 0 && <div className="text-purple-500">{merged} merged</div>}
</TooltipContent>
</Tooltip>
);
}
export const MRTopbarButton = memo(function MRTopbarButton() {
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const mrs = useTaskMRs(activeTaskId);
if (mrs.length === 0) return null;
if (mrs.length === 1) return <MRSingleButton mr={mrs[0]} />;
return <MRMultiButton mrs={mrs} />;
});
@@ -11,6 +11,7 @@ import type { GitHubStatus } from "@/lib/types/github";
import { TooltipProvider } from "@kandev/ui/tooltip";
const useGitHubStatusMock = vi.hoisted(() => vi.fn());
const useGitLabAvailableMock = vi.hoisted(() => vi.fn());
const useJiraAvailableMock = vi.hoisted(() => vi.fn());
const useLinearAvailableMock = vi.hoisted(() => vi.fn());
@@ -18,6 +19,10 @@ vi.mock("@/hooks/domains/github/use-github-status", () => ({
useGitHubStatus: useGitHubStatusMock,
}));
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
useGitLabAvailable: useGitLabAvailableMock,
}));
vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({
useJiraAvailable: useJiraAvailableMock,
}));
@@ -39,10 +44,12 @@ function status(overrides: Partial<GitHubStatus>): GitHubStatus {
function mockAvailability({
githubReady,
gitlabReady = false,
jiraAvailable,
linearAvailable,
}: {
githubReady: boolean;
gitlabReady?: boolean;
jiraAvailable: boolean;
linearAvailable: boolean;
}) {
@@ -50,6 +57,7 @@ function mockAvailability({
status: githubReady ? status({ token_configured: true }) : status({}),
loading: false,
});
useGitLabAvailableMock.mockReturnValue(gitlabReady);
useJiraAvailableMock.mockReturnValue(jiraAvailable);
useLinearAvailableMock.mockReturnValue(linearAvailable);
}
@@ -94,6 +102,7 @@ describe("getAvailableIntegrationLinks", () => {
expect(
getAvailableIntegrationLinks({
githubReady: true,
gitlabReady: false,
jiraAvailable: false,
linearAvailable: true,
}),
@@ -107,6 +116,7 @@ describe("getAvailableIntegrationLinks", () => {
expect(
getAvailableIntegrationLinks({
githubReady: false,
gitlabReady: false,
jiraAvailable: false,
linearAvailable: false,
}),
@@ -11,17 +11,24 @@ import {
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconBrandGithub, IconHexagon, IconPlugConnected, IconTicket } from "@tabler/icons-react";
import {
IconBrandGithub,
IconBrandGitlab,
IconHexagon,
IconPlugConnected,
IconTicket,
} from "@tabler/icons-react";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr";
import type { GitHubStatus } from "@/lib/types/github";
type MobileIntegrationsSectionProps = {
onNavigate: () => void;
};
type IntegrationId = "github" | "jira" | "linear";
type IntegrationId = "github" | "gitlab" | "jira" | "linear";
type IntegrationLink = {
id: IntegrationId;
@@ -31,18 +38,21 @@ type IntegrationLink = {
type IntegrationAvailability = {
githubReady: boolean;
gitlabReady: boolean;
jiraAvailable: boolean;
linearAvailable: boolean;
};
const INTEGRATION_LINKS: IntegrationLink[] = [
{ id: "github", label: "GitHub", href: "/github" },
{ id: "gitlab", label: "GitLab", href: "/gitlab" },
{ id: "jira", label: "Jira", href: "/jira" },
{ id: "linear", label: "Linear", href: "/linear" },
];
const INTEGRATION_ICONS = {
github: IconBrandGithub,
gitlab: IconBrandGitlab,
jira: IconTicket,
linear: IconHexagon,
} satisfies Record<IntegrationId, typeof IconBrandGithub>;
@@ -51,11 +61,13 @@ const HOVER_CLOSE_DELAY_MS = 180;
export function getAvailableIntegrationLinks({
githubReady,
gitlabReady,
jiraAvailable,
linearAvailable,
}: IntegrationAvailability): IntegrationLink[] {
return INTEGRATION_LINKS.filter((link) => {
if (link.id === "github") return githubReady;
if (link.id === "gitlab") return gitlabReady;
if (link.id === "jira") return jiraAvailable;
return linearAvailable;
});
@@ -73,12 +85,14 @@ export function getGitHubIntegrationStatus(status: GitHubStatus | null, loading:
function useConfiguredIntegrationLinks(): IntegrationLink[] {
const { status, loading } = useGitHubStatus();
const gitlabAvailable = useGitLabAvailable();
const jiraAvailable = useJiraAvailable();
const linearAvailable = useLinearAvailable();
const githubStatus = getGitHubIntegrationStatus(status, loading);
return getAvailableIntegrationLinks({
githubReady: githubStatus.ready,
gitlabReady: gitlabAvailable,
jiraAvailable,
linearAvailable,
});
+2
View File
@@ -18,6 +18,7 @@ import { useKanbanData, useKanbanActions, useKanbanNavigation } from "@/hooks/do
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow";
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useTaskMultiSelect } from "@/hooks/use-task-multi-select";
import { HomepageCommands } from "./homepage-commands";
@@ -246,6 +247,7 @@ function useKanbanBoardSetup(
useAllWorkflowSnapshots(workspaceState.activeId);
useWorkspacePRs(workspaceState.activeId);
useWorkspaceMRs(workspaceState.activeId);
const hooks = useKanbanBoardHooks(searchQuery, workspaceState, workflowsState);
const { handleOpenTask, handleCardClick } = useKanbanNavigation({
@@ -13,6 +13,7 @@ import {
IconKey,
IconMessageCircle,
IconBrandGithub,
IconBrandGitlab,
IconBrandSlack,
IconHexagon,
IconSparkles,
@@ -155,6 +156,7 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec
function IntegrationsSidebarSection({ pathname }: { pathname: string }) {
const items: Array<{ href: string; label: string; Icon: typeof IconBrandGithub }> = [
{ href: "/settings/integrations/github", label: "GitHub", Icon: IconBrandGithub },
{ href: "/settings/integrations/gitlab", label: "GitLab", Icon: IconBrandGitlab },
{ href: "/settings/integrations/jira", label: "Jira", Icon: IconTicket },
{ href: "/settings/integrations/linear", label: "Linear", Icon: IconHexagon },
{ href: "/settings/integrations/slack", label: "Slack", Icon: IconBrandSlack },
@@ -34,6 +34,10 @@ vi.mock("@/components/github/pr-topbar-button", () => ({
PRTopbarButton: () => null,
}));
vi.mock("@/components/gitlab/mr-topbar-button", () => ({
MRTopbarButton: () => null,
}));
vi.mock("@/components/jira/jira-ticket-button", () => ({
JiraTicketButton: () => null,
extractJiraKey: () => null,
@@ -25,6 +25,7 @@ import { EditorsMenu } from "@/components/task/editors-menu";
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
import { DocumentControls } from "@/components/task/document/document-controls";
import { PRTopbarButton } from "@/components/github/pr-topbar-button";
import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button";
import { JiraTicketButton, extractJiraKey } from "@/components/jira/jira-ticket-button";
import { JiraLinkButton } from "@/components/jira/jira-link-button";
import { LinearIssueButton, extractLinearKey } from "@/components/linear/linear-issue-button";
@@ -317,7 +318,11 @@ function AttentionStatusGroup({
sessionId={activeSessionId}
isAgentctlReady={isAgentctlReady}
/>
{/* PR (GitHub) and MR (GitLab) buttons each render nothing when no
rows match, so showing both covers GitHub-only, GitLab-only, and
multi-repo tasks without needing an explicit provider switch. */}
<PRTopbarButton />
<MRTopbarButton />
<IssueTrackerButtons taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
</>
)}
@@ -0,0 +1,231 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createElement, type ReactNode } from "react";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { StateProvider, useAppStore } from "@/components/state-provider";
import type { GitLabStatus, TaskMR } from "@/lib/types/gitlab";
const fetchGitLabStatusMock = vi.fn<[], Promise<GitLabStatus | null>>();
const listWorkspaceTaskMRsMock = vi.fn<
[string],
Promise<{ task_mrs: Record<string, TaskMR[]> } | null>
>();
vi.mock("@/lib/api/domains/gitlab-api", () => ({
fetchGitLabStatus: () => fetchGitLabStatusMock(),
listWorkspaceTaskMRs: (workspaceId: string) => listWorkspaceTaskMRsMock(workspaceId),
}));
import { useGitLabAvailable, useTaskMRs, useWorkspaceMRs } from "./use-task-mr";
function wrapper({ children }: { children: ReactNode }) {
return createElement(StateProvider, null, children);
}
afterEach(() => cleanup());
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
return {
id: "mr-1",
task_id: "task-1",
host: "https://gitlab.com",
project_path: "acme/api",
mr_iid: 1,
mr_url: "",
mr_title: "Test",
head_branch: "feat",
base_branch: "main",
author_username: "alice",
state: "open",
approval_state: "",
pipeline_state: "",
merge_status: "",
draft: false,
approval_count: 0,
required_approvals: 0,
pipeline_jobs_total: 0,
pipeline_jobs_pass: 0,
created_at: "",
updated_at: "",
...overrides,
};
}
function makeStatus(overrides: Partial<GitLabStatus> = {}): GitLabStatus {
return {
authenticated: true,
username: "alice",
auth_method: "pat",
host: "https://gitlab.com",
token_configured: true,
required_scopes: ["api"],
...overrides,
};
}
describe("useWorkspaceMRs", () => {
beforeEach(() => {
listWorkspaceTaskMRsMock.mockReset();
});
it("hydrates the store with the workspace's task MRs", async () => {
const mr = makeMR({ task_id: "task-1" });
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: { "task-1": [mr] } });
const { result } = renderHook(
() => {
useWorkspaceMRs("ws-1");
return useAppStore((s) => s.taskMRs.byTaskId);
},
{ wrapper },
);
await waitFor(() => expect(result.current["task-1"]).toEqual([mr]));
expect(listWorkspaceTaskMRsMock).toHaveBeenCalledWith("ws-1");
});
it("does not refetch when the workspace id stays the same", async () => {
listWorkspaceTaskMRsMock.mockResolvedValue({ task_mrs: {} });
const { rerender } = renderHook(({ ws }: { ws: string | null }) => useWorkspaceMRs(ws), {
wrapper,
initialProps: { ws: "ws-1" },
});
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1));
rerender({ ws: "ws-1" });
rerender({ ws: "ws-1" });
expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1);
});
it("clears MRs and invalidates in-flight requests when workspace becomes null", async () => {
// First fetch is slow — we will switch to null before it resolves to
// verify the in-flight result is dropped by the request-id guard.
let resolveFirst: (v: { task_mrs: Record<string, TaskMR[]> }) => void = () => {};
const firstPromise = new Promise<{ task_mrs: Record<string, TaskMR[]> }>((res) => {
resolveFirst = res;
});
listWorkspaceTaskMRsMock.mockReturnValueOnce(firstPromise);
const { result, rerender } = renderHook(
({ ws }: { ws: string | null }) => {
useWorkspaceMRs(ws);
return useAppStore((s) => s.taskMRs.byTaskId);
},
{ wrapper, initialProps: { ws: "ws-1" as string | null } },
);
// Pre-populate the store so we can observe it being cleared.
const setInitial = renderHook(() => useAppStore((s) => s.setTaskMRs), { wrapper });
act(() => {
setInitial.result.current({ "task-1": [makeMR()] });
});
rerender({ ws: null });
await waitFor(() => expect(result.current).toEqual({}));
// The first fetch resolves *after* the null switch — its data must
// NOT land in the store.
const mr = makeMR({ task_id: "task-1" });
await act(async () => {
resolveFirst({ task_mrs: { "task-1": [mr] } });
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current).toEqual({});
});
it("clears fetchedRef on failure so a workspace switch can retry that id later", async () => {
listWorkspaceTaskMRsMock.mockRejectedValueOnce(new Error("boom"));
const { rerender } = renderHook(({ ws }: { ws: string | null }) => useWorkspaceMRs(ws), {
wrapper,
initialProps: { ws: "ws-1" },
});
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1));
// Bounce through another workspace and back to ws-1. Without the
// failure-path reset of fetchedRef, the second visit to ws-1 would
// be a no-op because the hook would still think the fetch succeeded.
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: {} });
rerender({ ws: "ws-2" });
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(2));
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: {} });
rerender({ ws: "ws-1" });
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(3));
});
});
describe("useTaskMRs", () => {
it("returns the same array reference across renders when empty", () => {
const { result, rerender } = renderHook(() => useTaskMRs("task-empty"), { wrapper });
const first = result.current;
rerender();
rerender();
// If we returned `[]` literal each call, this would fail and the
// zustand selector would loop forever.
expect(result.current).toBe(first);
});
it("reads the task's MRs from the store", async () => {
const mr = makeMR({ task_id: "task-1" });
const { result } = renderHook(
() => {
const setTaskMRs = useAppStore((s) => s.setTaskMRs);
const mrs = useTaskMRs("task-1");
return { setTaskMRs, mrs };
},
{ wrapper },
);
act(() => {
result.current.setTaskMRs({ "task-1": [mr] });
});
expect(result.current.mrs).toEqual([mr]);
});
});
describe("useGitLabAvailable", () => {
beforeEach(() => {
fetchGitLabStatusMock.mockReset();
});
it("returns true when GitLab is authenticated", async () => {
fetchGitLabStatusMock.mockResolvedValue(makeStatus({ authenticated: true }));
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
await waitFor(() => expect(result.current).toBe(true));
});
it("returns true when a token is configured but probe says unauthenticated", async () => {
// token_configured is a softer signal — the integration is set up but
// the probe might be stale. We want the integration to appear in menus.
fetchGitLabStatusMock.mockResolvedValue(
makeStatus({ authenticated: false, token_configured: true }),
);
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
await waitFor(() => expect(result.current).toBe(true));
});
it("returns false when neither flag is set", async () => {
fetchGitLabStatusMock.mockResolvedValue(
makeStatus({ authenticated: false, token_configured: false }),
);
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
// The probe runs once on mount — give it a tick to land.
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalled());
expect(result.current).toBe(false);
});
it("returns false when the probe rejects (offline / no client)", async () => {
fetchGitLabStatusMock.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalled());
expect(result.current).toBe(false);
});
it("re-probes when the window regains focus", async () => {
fetchGitLabStatusMock.mockResolvedValue(makeStatus());
renderHook(() => useGitLabAvailable(), { wrapper });
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalledTimes(1));
act(() => {
window.dispatchEvent(new Event("focus"));
});
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalledTimes(2));
});
});
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { fetchGitLabStatus, listWorkspaceTaskMRs } from "@/lib/api/domains/gitlab-api";
import { useAppStore } from "@/components/state-provider";
import type { TaskMR } from "@/lib/types/gitlab";
/**
* Hydrate the gitlab task-MRs slice for a workspace. Fetches once per
* workspaceId switch and clears the cache on null. Mirrors useWorkspacePRs
* for GitHub but stays minimal (no WS subscription yet — that lands with
* the poller in a follow-up phase).
*/
export function useWorkspaceMRs(workspaceId: string | null) {
const setTaskMRs = useAppStore((state) => state.setTaskMRs);
const resetTaskMRs = useAppStore((state) => state.resetTaskMRs);
const fetchedRef = useRef<string | null>(null);
const requestRef = useRef(0);
useEffect(() => {
if (!workspaceId) {
// Invalidate any in-flight request and clear the cached MRs so a
// workspace switch / sign-out doesn't leave the previous workspace's
// MRs visible until the next fetch.
requestRef.current += 1;
fetchedRef.current = null;
resetTaskMRs();
return;
}
if (fetchedRef.current === workspaceId) return;
const requestId = ++requestRef.current;
fetchedRef.current = workspaceId;
listWorkspaceTaskMRs(workspaceId, { cache: "no-store" })
.then((response) => {
if (requestRef.current !== requestId) return;
setTaskMRs(response?.task_mrs ?? {});
})
.catch(() => {
if (requestRef.current === requestId) {
fetchedRef.current = null; // allow retry on failure
}
});
}, [workspaceId, setTaskMRs, resetTaskMRs]);
}
// Stable empty array so the zustand selector output stays referentially
// equal across renders when a task has no MRs. Returning a fresh [] each
// call triggers an infinite re-render loop.
const EMPTY_MRS: TaskMR[] = [];
/** Return MRs linked to a task. Reads directly from the store. */
export function useTaskMRs(taskId: string | null): TaskMR[] {
return useAppStore((state) =>
taskId ? (state.taskMRs.byTaskId[taskId] ?? EMPTY_MRS) : EMPTY_MRS,
);
}
/**
* Returns whether GitLab is configured enough to surface in the integrations
* menu. Token-configured or authenticated counts as "available" — same bar
* as useGitHubStatus's `ready` flag. Probes /status on mount + after window
* regains focus so settings changes propagate without a hard reload.
*/
export function useGitLabAvailable(): boolean {
const [available, setAvailable] = useState(false);
useEffect(() => {
let cancelled = false;
const probe = () => {
// `cache` MUST be top-level — fetchJson reads options.cache directly
// and overwrites init.cache with undefined. See lib/api/client.ts.
fetchGitLabStatus({ cache: "no-store" })
.then((s) => {
if (!cancelled) setAvailable(Boolean(s?.authenticated || s?.token_configured));
})
.catch(() => {
if (!cancelled) setAvailable(false);
});
};
probe();
window.addEventListener("focus", probe);
return () => {
cancelled = true;
window.removeEventListener("focus", probe);
};
}, []);
return available;
}
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import type { Issue, MR, MRSearchPage, IssueSearchPage } from "@/lib/types/gitlab";
const searchUserMRsMock = vi.fn<[unknown], Promise<MRSearchPage | null>>();
const searchUserIssuesMock = vi.fn<[unknown], Promise<IssueSearchPage | null>>();
vi.mock("@/lib/api/domains/gitlab-api", () => ({
searchUserMRs: (args: unknown) => searchUserMRsMock(args),
searchUserIssues: (args: unknown) => searchUserIssuesMock(args),
}));
import { useGitLabUserIssues, useGitLabUserMRs } from "./use-user-search";
afterEach(() => cleanup());
function fakeMR(): MR {
return {
id: 1,
iid: 1,
project_id: 1,
title: "",
url: "",
web_url: "",
state: "opened",
head_branch: "feat",
head_sha: "",
base_branch: "main",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
body: "",
draft: false,
merge_status: "",
has_conflicts: false,
additions: 0,
deletions: 0,
reviewers: [],
assignees: [],
created_at: "",
updated_at: "",
};
}
function fakeIssue(): Issue {
return {
id: 1,
iid: 1,
project_id: 1,
title: "",
body: "",
url: "",
web_url: "",
state: "opened",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
labels: [],
assignees: [],
created_at: "",
updated_at: "",
};
}
describe("useGitLabUserMRs", () => {
beforeEach(() => {
searchUserMRsMock.mockReset();
});
it("forwards filter, query, and perPage to the API", async () => {
searchUserMRsMock.mockResolvedValueOnce({ mrs: [], total_count: 0, page: 1, per_page: 25 });
renderHook(() => useGitLabUserMRs("authored", "labels=bug", 25));
await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalledTimes(1));
expect(searchUserMRsMock).toHaveBeenCalledWith({
filter: "authored",
customQuery: "labels=bug",
perPage: 25,
});
});
it("populates items on success and clears loading", async () => {
const mr = fakeMR();
searchUserMRsMock.mockResolvedValueOnce({ mrs: [mr], total_count: 1, page: 1, per_page: 50 });
const { result } = renderHook(() => useGitLabUserMRs("a", ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([mr]);
expect(result.current.error).toBeNull();
});
it("surfaces an error message and empty items on rejection", async () => {
searchUserMRsMock.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useGitLabUserMRs("a", ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("boom");
expect(result.current.items).toEqual([]);
});
it("ignores stale responses when inputs change mid-flight", async () => {
let resolveFirst: (v: MRSearchPage) => void = () => {};
searchUserMRsMock.mockReturnValueOnce(
new Promise<MRSearchPage>((res) => {
resolveFirst = res;
}),
);
const second = fakeMR();
searchUserMRsMock.mockResolvedValueOnce({
mrs: [second],
total_count: 1,
page: 1,
per_page: 50,
});
const { result, rerender } = renderHook(
({ filter }: { filter: string }) => useGitLabUserMRs(filter, ""),
{ initialProps: { filter: "a" } },
);
rerender({ filter: "b" });
await waitFor(() => expect(result.current.items).toEqual([second]));
// The first request resolves *after* the swap — it must NOT clobber
// the newer items list.
resolveFirst({
mrs: [fakeMR(), fakeMR()],
total_count: 2,
page: 1,
per_page: 50,
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current.items).toEqual([second]);
});
});
describe("useGitLabUserIssues", () => {
beforeEach(() => {
searchUserIssuesMock.mockReset();
});
it("forwards args and returns items", async () => {
const issue = fakeIssue();
searchUserIssuesMock.mockResolvedValueOnce({
issues: [issue],
total_count: 1,
page: 1,
per_page: 50,
});
const { result } = renderHook(() => useGitLabUserIssues("assigned_to_me", ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([issue]);
});
it("handles rejection without populating items", async () => {
searchUserIssuesMock.mockRejectedValueOnce(new Error("net"));
const { result } = renderHook(() => useGitLabUserIssues("a", ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("net");
expect(result.current.items).toEqual([]);
});
});
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { searchUserIssues, searchUserMRs } from "@/lib/api/domains/gitlab-api";
import type { Issue, MR } from "@/lib/types/gitlab";
type SearchState<T> = {
items: T[];
loading: boolean;
error: string | null;
};
function initial<T>(): SearchState<T> {
return { items: [], loading: true, error: null };
}
async function fetchMRs(
filter: string,
query: string,
perPage: number,
onResult: (s: SearchState<MR>) => void,
) {
onResult({ items: [], loading: true, error: null });
try {
const res = await searchUserMRs({ filter, customQuery: query, perPage });
onResult({ items: res?.mrs ?? [], loading: false, error: null });
} catch (err) {
onResult({
items: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to load MRs",
});
}
}
async function fetchIssues(
filter: string,
query: string,
perPage: number,
onResult: (s: SearchState<Issue>) => void,
) {
onResult({ items: [], loading: true, error: null });
try {
const res = await searchUserIssues({ filter, customQuery: query, perPage });
onResult({ items: res?.issues ?? [], loading: false, error: null });
} catch (err) {
onResult({
items: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to load issues",
});
}
}
/** Fetch the current user's MRs from GitLab. Re-runs whenever filter or query change. */
export function useGitLabUserMRs(filter: string, query: string, perPage = 50): SearchState<MR> {
const [state, setState] = useState<SearchState<MR>>(initial);
const requestRef = useRef(0);
useEffect(() => {
const requestId = ++requestRef.current;
void fetchMRs(filter, query, perPage, (next) => {
if (requestRef.current !== requestId) return;
setState(next);
});
}, [filter, query, perPage]);
return state;
}
/** Fetch the current user's issues from GitLab. */
export function useGitLabUserIssues(
filter: string,
query: string,
perPage = 50,
): SearchState<Issue> {
const [state, setState] = useState<SearchState<Issue>>(initial);
const requestRef = useRef(0);
useEffect(() => {
const requestId = ++requestRef.current;
void fetchIssues(filter, query, perPage, (next) => {
if (requestRef.current !== requestId) return;
setState(next);
});
}, [filter, query, perPage]);
return state;
}
+173
View File
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureGitLabHost,
configureGitLabToken,
clearGitLabToken,
fetchGitLabStatus,
listTaskMRs,
listWorkspaceTaskMRs,
searchUserIssues,
searchUserMRs,
syncTaskMR,
} from "./gitlab-api";
const originalFetch = global.fetch;
const SELF_MANAGED_HOST = "https://gitlab.acme.corp";
function mockResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("gitlab-api — auth", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("fetchGitLabStatus calls /api/v1/gitlab/status", async () => {
fetchSpy.mockResolvedValueOnce(
mockResponse({
authenticated: true,
username: "alice",
auth_method: "pat",
host: "https://gitlab.com",
token_configured: true,
required_scopes: ["api"],
}),
);
const status = await fetchGitLabStatus();
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/status");
expect(status.username).toBe("alice");
expect(status.auth_method).toBe("pat");
});
it("configureGitLabToken POSTs the token", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ configured: true }));
const result = await configureGitLabToken("glpat-123");
expect(result.configured).toBe(true);
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ token: "glpat-123" });
});
it("clearGitLabToken issues DELETE", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ cleared: true }));
await clearGitLabToken();
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("DELETE");
});
it("configureGitLabHost POSTs the host", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ configured: true, host: SELF_MANAGED_HOST }));
const result = await configureGitLabHost(SELF_MANAGED_HOST);
expect(result.host).toBe(SELF_MANAGED_HOST);
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(JSON.parse(init.body as string)).toEqual({ host: SELF_MANAGED_HOST });
});
});
describe("gitlab-api — task MRs", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("listWorkspaceTaskMRs encodes the workspace id and hits /workspaces/:id/task-mrs", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ task_mrs: {} }));
await listWorkspaceTaskMRs("ws id/with slash");
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/workspaces/");
expect(url).toContain(encodeURIComponent("ws id/with slash"));
expect(url).toContain("/task-mrs");
});
it("listTaskMRs encodes the task id and hits /tasks/:id/mrs", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ task_mrs: [] }));
await listTaskMRs("task/123");
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain(`/api/v1/gitlab/tasks/${encodeURIComponent("task/123")}/mrs`);
});
it("syncTaskMR POSTs the project/iid body to /tasks/:id/mrs/sync", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ id: "1", task_id: "t-1", mr_iid: 99 }));
await syncTaskMR("t-1", {
project_path: "acme/api",
iid: 99,
repository_id: "repo-a",
});
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/tasks/t-1/mrs/sync");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
project_path: "acme/api",
iid: 99,
repository_id: "repo-a",
});
});
});
describe("gitlab-api — user search", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("searchUserMRs builds the query string and disables cache", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ mrs: [], total_count: 0 }));
await searchUserMRs({
filter: "assigned_to_me",
customQuery: "labels=bug",
page: 2,
perPage: 25,
});
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/user/mrs");
expect(url).toContain("filter=assigned_to_me");
// custom_query gets URL-encoded by URLSearchParams.
expect(url).toContain(`custom_query=${encodeURIComponent("labels=bug")}`);
expect(url).toContain("page=2");
expect(url).toContain("per_page=25");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
// `cache` must be at the top level of fetch init; reading from
// options.init.cache (as an earlier revision did) silently no-ops.
expect(init.cache).toBe("no-store");
});
it("searchUserIssues builds the query string and disables cache", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ issues: [], total_count: 0 }));
await searchUserIssues({ filter: "created_by_me", perPage: 10 });
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/user/issues");
expect(url).toContain("filter=created_by_me");
expect(url).toContain("per_page=10");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.cache).toBe("no-store");
});
});
+101
View File
@@ -0,0 +1,101 @@
import { fetchJson, type ApiRequestOptions } from "../client";
import type {
GitLabStatus,
GitLabConfigureTokenResponse,
GitLabClearTokenResponse,
GitLabConfigureHostResponse,
TaskMR,
TaskMRsResponse,
MR,
Issue,
MRSearchPage,
IssueSearchPage,
} from "@/lib/types/gitlab";
export async function fetchGitLabStatus(options?: ApiRequestOptions) {
return fetchJson<GitLabStatus>("/api/v1/gitlab/status", options);
}
export async function configureGitLabToken(token: string) {
return fetchJson<GitLabConfigureTokenResponse>("/api/v1/gitlab/token", {
init: { method: "POST", body: JSON.stringify({ token }) },
});
}
export async function clearGitLabToken() {
return fetchJson<GitLabClearTokenResponse>("/api/v1/gitlab/token", {
init: { method: "DELETE" },
});
}
export async function configureGitLabHost(host: string) {
return fetchJson<GitLabConfigureHostResponse>("/api/v1/gitlab/host", {
init: { method: "POST", body: JSON.stringify({ host }) },
});
}
/** List every MR association for tasks in a workspace, grouped by task ID. */
export async function listWorkspaceTaskMRs(workspaceId: string, options?: ApiRequestOptions) {
return fetchJson<TaskMRsResponse>(
`/api/v1/gitlab/workspaces/${encodeURIComponent(workspaceId)}/task-mrs`,
options,
);
}
/** List the MRs linked to a single task. */
export async function listTaskMRs(taskId: string, options?: ApiRequestOptions) {
return fetchJson<{ task_mrs: TaskMR[] | null }>(
`/api/v1/gitlab/tasks/${encodeURIComponent(taskId)}/mrs`,
options,
);
}
/**
* Sync a task↔MR row from GitLab. Used by the `pr` skill after creating an MR
* and by the topbar's manual refresh. project_path is "namespace/path".
*/
export async function syncTaskMR(
taskId: string,
body: { project_path: string; iid: number; repository_id?: string },
) {
return fetchJson<TaskMR>(`/api/v1/gitlab/tasks/${encodeURIComponent(taskId)}/mrs/sync`, {
init: { method: "POST", body: JSON.stringify(body) },
});
}
/** Search the current user's MRs. filter is one of "assigned", "authored",
* "review_requested" (matches GitLab's `scope` query param). */
export async function searchUserMRs(params: {
filter?: string;
customQuery?: string;
page?: number;
perPage?: number;
}) {
const qs = new URLSearchParams();
if (params.filter) qs.set("filter", params.filter);
if (params.customQuery) qs.set("custom_query", params.customQuery);
if (params.page) qs.set("page", String(params.page));
if (params.perPage) qs.set("per_page", String(params.perPage));
return fetchJson<MRSearchPage>(`/api/v1/gitlab/user/mrs?${qs.toString()}`, {
cache: "no-store",
});
}
/** Search the current user's issues. */
export async function searchUserIssues(params: {
filter?: string;
customQuery?: string;
page?: number;
perPage?: number;
}) {
const qs = new URLSearchParams();
if (params.filter) qs.set("filter", params.filter);
if (params.customQuery) qs.set("custom_query", params.customQuery);
if (params.page) qs.set("page", String(params.page));
if (params.perPage) qs.set("per_page", String(params.perPage));
return fetchJson<IssueSearchPage>(`/api/v1/gitlab/user/issues?${qs.toString()}`, {
cache: "no-store",
});
}
export type { MR, Issue, MRSearchPage, IssueSearchPage };
+3
View File
@@ -6,6 +6,7 @@ import {
defaultSessionRuntimeState,
defaultUIState,
defaultGitHubState,
defaultGitLabState,
defaultJiraState,
defaultLinearState,
defaultOfficeState,
@@ -67,6 +68,7 @@ export const defaultState = {
reviewWatches: defaultGitHubState.reviewWatches,
issueWatches: defaultGitHubState.issueWatches,
actionPresets: defaultGitHubState.actionPresets,
taskMRs: defaultGitLabState.taskMRs,
jiraIssueWatches: defaultJiraState.jiraIssueWatches,
linearIssueWatches: defaultLinearState.linearIssueWatches,
office: defaultOfficeState.office,
@@ -157,6 +159,7 @@ export function mergeInitialState(initialState?: Partial<DefaultState>): Default
reviewWatches: { ...defaultState.reviewWatches, ...initialState.reviewWatches },
issueWatches: { ...defaultState.issueWatches, ...initialState.issueWatches },
actionPresets: { ...defaultState.actionPresets, ...initialState.actionPresets },
taskMRs: { ...defaultState.taskMRs, ...initialState.taskMRs },
jiraIssueWatches: { ...defaultState.jiraIssueWatches, ...initialState.jiraIssueWatches },
linearIssueWatches: {
...defaultState.linearIssueWatches,
@@ -0,0 +1,132 @@
import { describe, it, expect } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createGitLabSlice } from "./gitlab-slice";
import type { GitLabSlice } from "./types";
import type { TaskMR } from "@/lib/types/gitlab";
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
return {
id: "id",
task_id: "task-1",
host: "https://gitlab.com",
project_path: "acme/api",
mr_iid: 1,
mr_url: "https://gitlab.com/acme/api/-/merge_requests/1",
mr_title: "Test MR",
head_branch: "feat",
base_branch: "main",
author_username: "alice",
state: "open",
approval_state: "",
pipeline_state: "",
merge_status: "",
draft: false,
approval_count: 0,
required_approvals: 0,
pipeline_jobs_total: 0,
pipeline_jobs_pass: 0,
created_at: "",
updated_at: "",
...overrides,
};
}
function makeStore() {
return create<GitLabSlice>()(immer((...a) => createGitLabSlice(...a)));
}
describe("setTaskMRs", () => {
it("replaces byTaskId wholesale", () => {
const store = makeStore();
const mrA = makeMR({ id: "a" });
store.getState().setTaskMRs({ "task-1": [mrA] });
expect(store.getState().taskMRs.byTaskId["task-1"]).toEqual([mrA]);
store.getState().setTaskMRs({ "task-2": [makeMR({ id: "b" })] });
expect(store.getState().taskMRs.byTaskId).not.toHaveProperty("task-1");
expect(store.getState().taskMRs.byTaskId["task-2"]).toHaveLength(1);
});
});
describe("setTaskMR", () => {
it("appends an MR when the task has no rows yet", () => {
const store = makeStore();
const mr = makeMR({ repository_id: "repo-a" });
store.getState().setTaskMR("task-1", mr);
expect(store.getState().taskMRs.byTaskId["task-1"]).toEqual([mr]);
});
it("upserts the same (repo, project, iid) key in place", () => {
const store = makeStore();
const original = makeMR({ id: "a", repository_id: "repo-a", mr_iid: 5 });
const updated = makeMR({
id: "a",
repository_id: "repo-a",
mr_iid: 5,
mr_title: "renamed",
});
store.getState().setTaskMR("task-1", original);
store.getState().setTaskMR("task-1", updated);
const list = store.getState().taskMRs.byTaskId["task-1"];
expect(list).toHaveLength(1);
expect(list[0]!.mr_title).toBe("renamed");
});
it("keeps multi-repo MRs distinct on (repository_id, project_path, mr_iid)", () => {
const store = makeStore();
// Same project + iid, different repositories — must coexist.
const repoA = makeMR({ id: "a", repository_id: "repo-a", mr_iid: 1 });
const repoB = makeMR({ id: "b", repository_id: "repo-b", mr_iid: 1 });
// Same repo, different project — must also coexist.
const repoAOtherProject = makeMR({
id: "c",
repository_id: "repo-a",
project_path: "acme/web",
mr_iid: 1,
});
// Same repo + project, different iid — must coexist (multiple open MRs).
const repoASecondMR = makeMR({ id: "d", repository_id: "repo-a", mr_iid: 99 });
store.getState().setTaskMR("task-1", repoA);
store.getState().setTaskMR("task-1", repoB);
store.getState().setTaskMR("task-1", repoAOtherProject);
store.getState().setTaskMR("task-1", repoASecondMR);
const list = store.getState().taskMRs.byTaskId["task-1"];
expect(list).toHaveLength(4);
expect(list.map((m) => m.id).sort()).toEqual(["a", "b", "c", "d"]);
});
it("treats missing repository_id as the empty key (single-repo tasks)", () => {
// A multi-repo MR with repository_id and a single-repo MR with the same
// (project_path, mr_iid) but no repository_id must still coexist — the
// empty repo key is its own slot.
const store = makeStore();
const noRepo = makeMR({ id: "x", mr_iid: 1 });
const withRepo = makeMR({ id: "y", repository_id: "repo-a", mr_iid: 1 });
store.getState().setTaskMR("task-1", noRepo);
store.getState().setTaskMR("task-1", withRepo);
const list = store.getState().taskMRs.byTaskId["task-1"];
expect(list).toHaveLength(2);
expect(list.map((m) => m.id).sort()).toEqual(["x", "y"]);
});
});
describe("resetTaskMRs", () => {
it("clears byTaskId so a workspace switch doesn't leak previous MRs", () => {
const store = makeStore();
store.getState().setTaskMR("task-1", makeMR({ id: "a" }));
expect(store.getState().taskMRs.byTaskId["task-1"]).toHaveLength(1);
store.getState().resetTaskMRs();
expect(store.getState().taskMRs.byTaskId).toEqual({});
});
});
@@ -0,0 +1,44 @@
import type { StateCreator } from "zustand";
import type { GitLabSlice, GitLabSliceState } from "./types";
export const defaultGitLabState: GitLabSliceState = {
taskMRs: { byTaskId: {} },
};
type ImmerSet = Parameters<
StateCreator<GitLabSlice, [["zustand/immer", never]], [], GitLabSlice>
>[0];
export const createGitLabSlice: StateCreator<
GitLabSlice,
[["zustand/immer", never]],
[],
GitLabSlice
> = (set: ImmerSet) => ({
...defaultGitLabState,
setTaskMRs: (mrs) =>
set((draft) => {
draft.taskMRs.byTaskId = mrs;
}),
setTaskMR: (taskId, mr) =>
set((draft) => {
const existing = draft.taskMRs.byTaskId[taskId] ?? [];
// Key by (repository_id, project_path, mr_iid) so multi-repo and
// multiple MRs for the same task coexist. Mirrors the backend's
// UNIQUE constraint on gitlab_task_mrs.
const repoKey = mr.repository_id ?? "";
const idx = existing.findIndex(
(m) =>
(m.repository_id ?? "") === repoKey &&
m.project_path === mr.project_path &&
m.mr_iid === mr.mr_iid,
);
if (idx >= 0) existing[idx] = mr;
else existing.push(mr);
draft.taskMRs.byTaskId[taskId] = existing;
}),
resetTaskMRs: () =>
set((draft) => {
draft.taskMRs.byTaskId = {};
}),
});
+23
View File
@@ -0,0 +1,23 @@
import type { TaskMR } from "@/lib/types/gitlab";
export type TaskMRsState = {
/** Each task may have multiple MRs (one per repository for multi-repo tasks). */
byTaskId: Record<string, TaskMR[]>;
};
export type GitLabSliceState = {
taskMRs: TaskMRsState;
};
export type GitLabSliceActions = {
setTaskMRs: (mrs: Record<string, TaskMR[]>) => void;
/**
* Upsert a single MR for a task — keyed by (repository_id, project_path,
* mr_iid) so multi-repo + multi-MR scenarios coexist without overwriting.
*/
setTaskMR: (taskId: string, mr: TaskMR) => void;
/** Clear all task MR state. Used on workspace switch / sign-out. */
resetTaskMRs: () => void;
};
export type GitLabSlice = GitLabSliceState & GitLabSliceActions;
+7
View File
@@ -9,6 +9,7 @@ export {
} from "./session-runtime/session-runtime-slice";
export { createUISlice, defaultUIState } from "./ui/ui-slice";
export { createGitHubSlice, defaultGitHubState } from "./github/github-slice";
export { createGitLabSlice, defaultGitLabState } from "./gitlab/gitlab-slice";
export { createJiraSlice, defaultJiraState } from "./jira/jira-slice";
export { createLinearSlice, defaultLinearState } from "./linear/linear-slice";
export { createOfficeSlice, defaultOfficeState } from "./office/office-slice";
@@ -27,6 +28,12 @@ export type {
} from "./session-runtime/types";
export type { UISlice, UISliceState, UISliceActions } from "./ui/types";
export type { GitHubSlice, GitHubSliceState, GitHubSliceActions } from "./github/types";
export type {
GitLabSlice,
GitLabSliceState,
GitLabSliceActions,
TaskMRsState,
} from "./gitlab/types";
export type {
JiraSlice,
JiraSliceState,
+14
View File
@@ -32,6 +32,7 @@ import {
createSessionRuntimeSlice,
createUISlice,
createGitHubSlice,
createGitLabSlice,
createJiraSlice,
createLinearSlice,
createOfficeSlice,
@@ -44,6 +45,7 @@ import {
defaultSessionRuntimeState,
defaultUIState,
defaultGitHubState,
defaultGitLabState,
defaultJiraState,
defaultLinearState,
defaultOfficeState,
@@ -88,6 +90,7 @@ import type {
// Re-export all types from slices for backwards compatibility (split out
// to keep this file under the max-lines limit).
export type * from "./store-reexports";
import type { TaskMR } from "@/lib/types/gitlab";
import type { JiraIssueWatch } from "@/lib/types/jira";
import type { LinearIssueWatch } from "@/lib/types/linear";
import type {
@@ -186,6 +189,9 @@ export type AppState = {
actionPresets: (typeof defaultGitHubState)["actionPresets"];
prFeedbackCache: (typeof defaultGitHubState)["prFeedbackCache"];
// GitLab slice
taskMRs: (typeof defaultGitLabState)["taskMRs"];
// JIRA slice
jiraIssueWatches: (typeof defaultJiraState)["jiraIssueWatches"];
@@ -246,6 +252,11 @@ export type AppState = {
setPRFeedbackCacheEntry: (key: string, feedback: PRFeedback) => void;
removePRFeedbackCacheEntry: (key: string) => void;
// GitLab actions
setTaskMRs: (mrs: Record<string, TaskMR[]>) => void;
setTaskMR: (taskId: string, mr: TaskMR) => void;
resetTaskMRs: () => void;
// JIRA actions
setJiraIssueWatches: (watches: JiraIssueWatch[]) => void;
setJiraIssueWatchesLoading: (loading: boolean) => void;
@@ -561,6 +572,8 @@ export function createAppStore(initialState?: Partial<AppState>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...createGitHubSlice(set as any, get as any, api as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...createGitLabSlice(set as any, get as any, api as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...createJiraSlice(set as any, get as any, api as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...createLinearSlice(set as any, get as any, api as any),
@@ -622,6 +635,7 @@ export function createAppStore(initialState?: Partial<AppState>) {
reviewWatches: merged.reviewWatches,
issueWatches: merged.issueWatches,
actionPresets: merged.actionPresets,
taskMRs: merged.taskMRs,
jiraIssueWatches: merged.jiraIssueWatches,
linearIssueWatches: merged.linearIssueWatches,
office: merged.office,
+160
View File
@@ -0,0 +1,160 @@
/**
* Connection status for the GitLab integration. Returned by
* `GET /api/v1/gitlab/status` and shaped by `internal/gitlab.Status`.
*/
export type GitLabStatus = {
authenticated: boolean;
username: string;
auth_method: "glab_cli" | "pat" | "none" | "mock";
host: string;
token_configured: boolean;
token_secret_id?: string;
glab_version?: string;
glab_outdated?: boolean;
required_scopes: string[];
diagnostics?: GitLabAuthDiagnostics;
/**
* Transport-layer error from the most recent `IsAuthenticated` probe
* (network down, 5xx, parse failure). Distinct from `authenticated:
* false` with no `connection_error`, which means a 401/403 / no token
* configured. Present so the UI can render "GitLab unreachable" instead
* of "not connected" during an outage and stop users from rotating an
* actually-valid token.
*/
connection_error?: string;
};
export type GitLabAuthDiagnostics = {
command: string;
output: string;
exit_code: number;
};
/**
* Task ↔ MR association — parallel to github's TaskPR. Surfaces what the MR
* topbar button needs to render (state + counts + a click target). Backend
* row in `gitlab_task_mrs`.
*/
export type TaskMR = {
id: string;
task_id: string;
repository_id?: string;
host: string;
project_path: string;
mr_iid: number;
mr_url: string;
mr_title: string;
head_branch: string;
base_branch: string;
author_username: string;
state: "open" | "closed" | "merged" | "locked" | string;
approval_state: "" | "approved" | "pending" | string;
pipeline_state: "" | "success" | "failure" | "pending" | string;
merge_status: string;
draft: boolean;
approval_count: number;
required_approvals: number;
pipeline_jobs_total: number;
pipeline_jobs_pass: number;
created_at: string;
merged_at?: string;
closed_at?: string;
last_synced_at?: string;
updated_at: string;
};
/** Response shape for `GET /api/v1/gitlab/workspaces/:id/task-mrs`. */
export type TaskMRsResponse = {
task_mrs: Record<string, TaskMR[]>;
};
/** Merge request returned by /api/v1/gitlab/user/mrs (matches backend MR). */
export type MR = {
id: number;
iid: number;
project_id: number;
title: string;
url: string;
web_url: string;
state: "open" | "closed" | "merged" | "locked" | "opened" | string;
head_branch: string;
head_sha: string;
base_branch: string;
author_username: string;
project_namespace: string;
project_path: string;
body: string;
draft: boolean;
merge_status: string;
has_conflicts: boolean;
additions: number;
deletions: number;
reviewers: { username: string; name: string; type: string }[];
assignees: { username: string; name: string; type: string }[];
created_at: string;
updated_at: string;
merged_at?: string;
closed_at?: string;
};
/** Issue returned by /api/v1/gitlab/user/issues. */
export type Issue = {
id: number;
iid: number;
project_id: number;
title: string;
body: string;
url: string;
web_url: string;
state: "opened" | "closed" | string;
author_username: string;
project_namespace: string;
project_path: string;
labels: string[];
assignees: string[];
created_at: string;
updated_at: string;
closed_at?: string;
};
export type MRSearchPage = {
mrs: MR[];
total_count: number;
page: number;
per_page: number;
};
export type IssueSearchPage = {
issues: Issue[];
total_count: number;
page: number;
per_page: number;
};
export type GitLabConfigureTokenResponse = { configured: boolean };
export type GitLabClearTokenResponse = { cleared: boolean };
export type GitLabConfigureHostResponse = { configured: boolean; host: string };
export type GitLabMRNote = {
id: number;
author: string;
author_avatar?: string;
author_is_bot?: boolean;
body: string;
type?: string;
system?: boolean;
created_at: string;
updated_at: string;
};
export type GitLabMRDiscussion = {
id: string;
resolvable: boolean;
resolved: boolean;
notes: GitLabMRNote[];
path?: string;
line?: number;
old_line?: number;
created_at: string;
updated_at: string;
};
+1
View File
@@ -64,6 +64,7 @@ Per-workspace credentials and triage triggers for external services.
|---|---|
| [slack](integrations/slack.md) | shipped |
| [external-mcp](integrations/external-mcp.md) | draft |
| [gitlab-integration](gitlab-integration/spec.md) | shipped |
## workspaces/ — workspace lifecycle
+345
View File
@@ -0,0 +1,345 @@
# 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).
+108
View File
@@ -0,0 +1,108 @@
---
status: shipped
created: 2026-05-04
owner: tbd
---
# GitLab Integration
## Why
Kandev's code-host integration is GitHub-only: PR browsing, review queue, issue
watches, repo cloning, and the agent `pr` skill all assume `github.com` and the
`gh` CLI. Users on GitLab (gitlab.com or self-managed) can't connect their
account, can't see their merge requests in the MyGitHub-style page, can't have
agents auto-clone or open MRs, and have to manually paste tokens for each task.
This excludes a large chunk of professional users — and self-managed GitLab is
common in regulated/enterprise environments where users have no choice.
Tracked in [#820](https://github.com/kdlbs/kandev/issues/820).
## What
- A GitLab integration runs alongside the GitHub one. Both can be configured at
the same time; neither replaces the other.
- Users authenticate GitLab via one of:
- `glab` CLI (auto-detected if installed and logged in).
- Personal access token stored in the secret store as `GITLAB_TOKEN` (or set
via the env var of the same name on the backend host).
- Self-managed instances are supported by configuring a GitLab host URL
(default `https://gitlab.com`). The host applies to API calls, clone URLs,
and web links.
- Repositories can be added with `provider = "gitlab"`. The repo cloner builds
SSH (`git@<host>:<owner>/<name>.git`) and HTTPS clone URLs against the
configured host, and authenticates HTTPS clones with the GitLab token when
one is available.
- A "GitLab" page mirrors the existing GitHub page: a sidebar of MR / issue
presets, a search bar, paginated lists, save-preset support, and the
quick-task launcher. MRs are labelled "Merge requests" throughout the UI;
the underlying state model is shared with PRs (open/closed/merged, draft,
approvals, pipelines).
- For an MR linked to a task, the agent UI shows the same review surface it
shows for PRs: title, description, head/base branches, mergeable state,
approval status, pipeline status, and threaded discussions.
- A review-watch and an issue-watch poller mirror the GitHub ones: users can
subscribe to MRs needing their review, or to GitLab issues matching a
filter, and Kandev creates tasks when matches appear.
- The agent `pr` skill works for GitLab repos: when the task's repo is
`provider = "gitlab"`, the skill opens a merge request via `glab` (or the
REST API with the stored token), targeting the same base branch logic used
for GitHub.
- Settings live under `/settings/integrations/gitlab` with a page parallel to
the GitHub one: connection status, host URL field, auth method (CLI vs PAT),
reconnect CTA, and a token field that writes to the secret store.
- The orchestrator and credential providers pass `GITLAB_TOKEN` into agent
environments the same way `GITHUB_TOKEN` is passed today, so agents running
in containers can run `glab` or `git push` without extra setup.
## Scenarios
- **GIVEN** a user with `glab` authenticated against `gitlab.com`, **WHEN**
they open Kandev for the first time, **THEN** the GitLab integration shows
as connected without requiring any manual token entry, and their merge
requests appear on the GitLab page.
- **GIVEN** a user on a self-managed GitLab at `https://gitlab.acme.corp`,
**WHEN** they enter the host URL and a personal access token in
Settings → Integrations → GitLab, **THEN** the connection status flips to
connected, MR/issue lists populate against the custom host, and clone URLs
for added repos use `gitlab.acme.corp`.
- **GIVEN** a task whose repository has `provider = "gitlab"`, **WHEN** the
agent runs the `pr` skill, **THEN** a merge request is opened on the
configured GitLab host targeting the repo's default branch, and the MR URL
is recorded against the task.
- **GIVEN** the user has a GitLab review watch for `assignee=@me`, **WHEN** a
new MR is assigned to them upstream, **THEN** within one poll interval a
Kandev task is created in the configured workflow step, linked to that MR.
- **GIVEN** GitLab is connected and GitHub is also connected, **WHEN** the
user opens the kanban top bar, **THEN** both the GitHub and GitLab page
buttons are visible and each lists items from its own provider only.
- **GIVEN** the user revokes their GitLab token externally, **WHEN** the
background poller next runs, **THEN** the integration's status flips to
"auth required" with a reconnect banner on the GitLab page and the settings
page, and no further API calls are issued until the user reconnects.
- **GIVEN** no GitLab auth is configured, **WHEN** the GitLab page is opened,
**THEN** it shows a "not connected" notice linking to settings, and no
network calls are made to any GitLab host.
## Out of scope
- GitLab webhook ingestion. v1 polls only, like the GitHub integration.
- GitLab CI pipeline editing or job log streaming inside Kandev. Pipeline
status is shown read-only as part of MR feedback.
- GitLab Issues used as a Jira/Linear-style task source (link issue → task
with state syncing, structured field mapping). Kandev's Jira and Linear
integrations cover that pattern; this spec treats GitLab as a code host
parallel to GitHub, not a project-tracker integration.
- Group-level MR / issue listings outside the user's own scope (no
org-wide dashboards in v1).
- GitLab-specific features without a GitHub analogue: approval rules editing,
protected-branch management, MR templates authoring, Duo / GitLab AI
integration.
- OAuth login via GitLab as a Kandev sign-in method.
- Migration tooling for moving tasks/repos between GitHub and GitLab.
- Bitbucket. The placeholder in `repoclone/protocol.go` stays a placeholder.