From 68aeefa2966d2dfe773cb93803511e1789f78869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 23 May 2026 23:17:16 +0100 Subject: [PATCH] feat(gitlab): add GitLab integration with MR / discussions support (#861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 ` 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- .agents/skills/pr/SKILL.md | 90 ++- AGENTS.md | 1 + apps/backend/cmd/kandev/helpers.go | 6 + apps/backend/cmd/kandev/services.go | 73 ++ apps/backend/cmd/kandev/types.go | 2 + apps/backend/internal/gitlab/client.go | 111 +++ .../backend/internal/gitlab/client_helpers.go | 454 ++++++++++++ .../internal/gitlab/client_helpers_test.go | 48 ++ apps/backend/internal/gitlab/connectivity.go | 79 ++ .../internal/gitlab/connectivity_test.go | 104 +++ apps/backend/internal/gitlab/constants.go | 12 + apps/backend/internal/gitlab/controller.go | 269 +++++++ apps/backend/internal/gitlab/factory.go | 85 +++ apps/backend/internal/gitlab/factory_test.go | 163 ++++ apps/backend/internal/gitlab/glab_client.go | 158 ++++ .../internal/gitlab/glab_client_test.go | 48 ++ apps/backend/internal/gitlab/mock_client.go | 417 +++++++++++ .../internal/gitlab/mock_client_test.go | 94 +++ apps/backend/internal/gitlab/models.go | 301 ++++++++ apps/backend/internal/gitlab/noop_client.go | 132 ++++ .../internal/gitlab/noop_client_test.go | 71 ++ apps/backend/internal/gitlab/pat_client.go | 701 ++++++++++++++++++ .../internal/gitlab/pat_client_test.go | 537 ++++++++++++++ apps/backend/internal/gitlab/provider.go | 41 + apps/backend/internal/gitlab/service.go | 419 +++++++++++ apps/backend/internal/gitlab/service_test.go | 550 ++++++++++++++ apps/backend/internal/gitlab/store.go | 201 +++++ apps/backend/internal/gitlab/store_test.go | 242 ++++++ apps/backend/internal/repoclone/protocol.go | 32 +- .../internal/repoclone/protocol_test.go | 51 ++ apps/web/app/gitlab/gitlab-page-client.tsx | 380 ++++++++++ apps/web/app/gitlab/page.tsx | 43 ++ .../app/settings/integrations/gitlab/page.tsx | 5 + apps/web/app/settings/integrations/page.tsx | 14 +- .../web/components/gitlab/gitlab-settings.tsx | 314 ++++++++ .../components/gitlab/mr-topbar-button.tsx | 95 +++ .../integrations/integrations-menu.test.ts | 10 + .../integrations/integrations-menu.tsx | 18 +- apps/web/components/kanban-board.tsx | 2 + .../settings/settings-app-sidebar.tsx | 2 + .../web/components/task/task-top-bar.test.tsx | 4 + apps/web/components/task/task-top-bar.tsx | 5 + .../hooks/domains/gitlab/use-task-mr.test.ts | 231 ++++++ apps/web/hooks/domains/gitlab/use-task-mr.ts | 87 +++ .../domains/gitlab/use-user-search.test.ts | 158 ++++ .../hooks/domains/gitlab/use-user-search.ts | 89 +++ apps/web/lib/api/domains/gitlab-api.test.ts | 173 +++++ apps/web/lib/api/domains/gitlab-api.ts | 101 +++ apps/web/lib/state/default-state.ts | 3 + .../state/slices/gitlab/gitlab-slice.test.ts | 132 ++++ .../lib/state/slices/gitlab/gitlab-slice.ts | 44 ++ apps/web/lib/state/slices/gitlab/types.ts | 23 + apps/web/lib/state/slices/index.ts | 7 + apps/web/lib/state/store.ts | 14 + apps/web/lib/types/gitlab.ts | 160 ++++ docs/specs/INDEX.md | 1 + docs/specs/gitlab-integration/plan.md | 345 +++++++++ docs/specs/gitlab-integration/spec.md | 108 +++ 58 files changed, 8048 insertions(+), 12 deletions(-) create mode 100644 apps/backend/internal/gitlab/client.go create mode 100644 apps/backend/internal/gitlab/client_helpers.go create mode 100644 apps/backend/internal/gitlab/client_helpers_test.go create mode 100644 apps/backend/internal/gitlab/connectivity.go create mode 100644 apps/backend/internal/gitlab/connectivity_test.go create mode 100644 apps/backend/internal/gitlab/constants.go create mode 100644 apps/backend/internal/gitlab/controller.go create mode 100644 apps/backend/internal/gitlab/factory.go create mode 100644 apps/backend/internal/gitlab/factory_test.go create mode 100644 apps/backend/internal/gitlab/glab_client.go create mode 100644 apps/backend/internal/gitlab/glab_client_test.go create mode 100644 apps/backend/internal/gitlab/mock_client.go create mode 100644 apps/backend/internal/gitlab/mock_client_test.go create mode 100644 apps/backend/internal/gitlab/models.go create mode 100644 apps/backend/internal/gitlab/noop_client.go create mode 100644 apps/backend/internal/gitlab/noop_client_test.go create mode 100644 apps/backend/internal/gitlab/pat_client.go create mode 100644 apps/backend/internal/gitlab/pat_client_test.go create mode 100644 apps/backend/internal/gitlab/provider.go create mode 100644 apps/backend/internal/gitlab/service.go create mode 100644 apps/backend/internal/gitlab/service_test.go create mode 100644 apps/backend/internal/gitlab/store.go create mode 100644 apps/backend/internal/gitlab/store_test.go create mode 100644 apps/web/app/gitlab/gitlab-page-client.tsx create mode 100644 apps/web/app/gitlab/page.tsx create mode 100644 apps/web/app/settings/integrations/gitlab/page.tsx create mode 100644 apps/web/components/gitlab/gitlab-settings.tsx create mode 100644 apps/web/components/gitlab/mr-topbar-button.tsx create mode 100644 apps/web/hooks/domains/gitlab/use-task-mr.test.ts create mode 100644 apps/web/hooks/domains/gitlab/use-task-mr.ts create mode 100644 apps/web/hooks/domains/gitlab/use-user-search.test.ts create mode 100644 apps/web/hooks/domains/gitlab/use-user-search.ts create mode 100644 apps/web/lib/api/domains/gitlab-api.test.ts create mode 100644 apps/web/lib/api/domains/gitlab-api.ts create mode 100644 apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts create mode 100644 apps/web/lib/state/slices/gitlab/gitlab-slice.ts create mode 100644 apps/web/lib/state/slices/gitlab/types.ts create mode 100644 apps/web/lib/types/gitlab.ts create mode 100644 docs/specs/gitlab-integration/plan.md create mode 100644 docs/specs/gitlab-integration/spec.md diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index 73a1957239..950883b56a 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -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' + +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' + +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 ` — bare `glab mr note` opens a new thread instead of replying to an existing one. diff --git a/AGENTS.md b/AGENTS.md index 78a6712181..320c8e5182 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index a71ceb2109..bd0ea36c17 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -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) diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 2697075351..59c3a894ec 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -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) diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 624c23ce64..b89210a081 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -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 diff --git a/apps/backend/internal/gitlab/client.go b/apps/backend/internal/gitlab/client.go new file mode 100644 index 0000000000..51c522d1f5 --- /dev/null +++ b/apps/backend/internal/gitlab/client.go @@ -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) +} diff --git a/apps/backend/internal/gitlab/client_helpers.go b/apps/backend/internal/gitlab/client_helpers.go new file mode 100644 index 0000000000..637b25b0b6 --- /dev/null +++ b/apps/backend/internal/gitlab/client_helpers.go @@ -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=`; 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" + } +} diff --git a/apps/backend/internal/gitlab/client_helpers_test.go b/apps/backend/internal/gitlab/client_helpers_test.go new file mode 100644 index 0000000000..f01b4dbe60 --- /dev/null +++ b/apps/backend/internal/gitlab/client_helpers_test.go @@ -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) + } +} diff --git a/apps/backend/internal/gitlab/connectivity.go b/apps/backend/internal/gitlab/connectivity.go new file mode 100644 index 0000000000..8de8eb6b34 --- /dev/null +++ b/apps/backend/internal/gitlab/connectivity.go @@ -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 +} diff --git a/apps/backend/internal/gitlab/connectivity_test.go b/apps/backend/internal/gitlab/connectivity_test.go new file mode 100644 index 0000000000..f610e7939a --- /dev/null +++ b/apps/backend/internal/gitlab/connectivity_test.go @@ -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") + } +} diff --git a/apps/backend/internal/gitlab/constants.go b/apps/backend/internal/gitlab/constants.go new file mode 100644 index 0000000000..21aec3e04f --- /dev/null +++ b/apps/backend/internal/gitlab/constants.go @@ -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" diff --git a/apps/backend/internal/gitlab/controller.go b/apps/backend/internal/gitlab/controller.go new file mode 100644 index 0000000000..343c98d493 --- /dev/null +++ b/apps/backend/internal/gitlab/controller.go @@ -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=&iid= 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 +} diff --git a/apps/backend/internal/gitlab/factory.go b/apps/backend/internal/gitlab/factory.go new file mode 100644 index 0000000000..76e5771745 --- /dev/null +++ b/apps/backend/internal/gitlab/factory.go @@ -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 +} diff --git a/apps/backend/internal/gitlab/factory_test.go b/apps/backend/internal/gitlab/factory_test.go new file mode 100644 index 0000000000..c286e34e66 --- /dev/null +++ b/apps/backend/internal/gitlab/factory_test.go @@ -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) + } +} diff --git a/apps/backend/internal/gitlab/glab_client.go b/apps/backend/internal/gitlab/glab_client.go new file mode 100644 index 0000000000..ceb43635c9 --- /dev/null +++ b/apps/backend/internal/gitlab/glab_client.go @@ -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: ". +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: " 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 != "" { + 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, "/") +} diff --git a/apps/backend/internal/gitlab/glab_client_test.go b/apps/backend/internal/gitlab/glab_client_test.go new file mode 100644 index 0000000000..4114dc115a --- /dev/null +++ b/apps/backend/internal/gitlab/glab_client_test.go @@ -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: ") + 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) + } + } +} diff --git a/apps/backend/internal/gitlab/mock_client.go b/apps/backend/internal/gitlab/mock_client.go new file mode 100644 index 0000000000..65aefd9db4 --- /dev/null +++ b/apps/backend/internal/gitlab/mock_client.go @@ -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 ¬e, 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 +} diff --git a/apps/backend/internal/gitlab/mock_client_test.go b/apps/backend/internal/gitlab/mock_client_test.go new file mode 100644 index 0000000000..30e581019c --- /dev/null +++ b/apps/backend/internal/gitlab/mock_client_test.go @@ -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") + } +} diff --git a/apps/backend/internal/gitlab/models.go b/apps/backend/internal/gitlab/models.go new file mode 100644 index 0000000000..741c982088 --- /dev/null +++ b/apps/backend/internal/gitlab/models.go @@ -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"` +} diff --git a/apps/backend/internal/gitlab/noop_client.go b/apps/backend/internal/gitlab/noop_client.go new file mode 100644 index 0000000000..164ec627a9 --- /dev/null +++ b/apps/backend/internal/gitlab/noop_client.go @@ -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 +} diff --git a/apps/backend/internal/gitlab/noop_client_test.go b/apps/backend/internal/gitlab/noop_client_test.go new file mode 100644 index 0000000000..b6f13bbea5 --- /dev/null +++ b/apps/backend/internal/gitlab/noop_client_test.go @@ -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 } diff --git a/apps/backend/internal/gitlab/pat_client.go b/apps/backend/internal/gitlab/pat_client.go new file mode 100644 index 0000000000..91dd8f0e18 --- /dev/null +++ b/apps/backend/internal/gitlab/pat_client.go @@ -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 ¬e, 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 +} diff --git a/apps/backend/internal/gitlab/pat_client_test.go b/apps/backend/internal/gitlab/pat_client_test.go new file mode 100644 index 0000000000..270c548769 --- /dev/null +++ b/apps/backend/internal/gitlab/pat_client_test.go @@ -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", `; rel="next"`, "/issues?page=2"}, + {"prev-only", `; rel="prev"`, ""}, + {"next-then-last", `; rel="next", ; 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", `; rel="next"`, ""}, + {"no-apibase-dropped", `; 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) + } + } +} diff --git a/apps/backend/internal/gitlab/provider.go b/apps/backend/internal/gitlab/provider.go new file mode 100644 index 0000000000..1c60225b92 --- /dev/null +++ b/apps/backend/internal/gitlab/provider.go @@ -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 +} diff --git a/apps/backend/internal/gitlab/service.go b/apps/backend/internal/gitlab/service.go new file mode 100644 index 0000000000..3ef33e6bb5 --- /dev/null +++ b/apps/backend/internal/gitlab/service.go @@ -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) +} diff --git a/apps/backend/internal/gitlab/service_test.go b/apps/backend/internal/gitlab/service_test.go new file mode 100644 index 0000000000..397cd1a6d6 --- /dev/null +++ b/apps/backend/internal/gitlab/service_test.go @@ -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 diff --git a/apps/backend/internal/gitlab/store.go b/apps/backend/internal/gitlab/store.go new file mode 100644 index 0000000000..e6b6a40a1b --- /dev/null +++ b/apps/backend/internal/gitlab/store.go @@ -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 +} diff --git a/apps/backend/internal/gitlab/store_test.go b/apps/backend/internal/gitlab/store_test.go new file mode 100644 index 0000000000..fd7d9073f8 --- /dev/null +++ b/apps/backend/internal/gitlab/store_test.go @@ -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") + } +} diff --git a/apps/backend/internal/repoclone/protocol.go b/apps/backend/internal/repoclone/protocol.go index 6b1be8eff5..9cceeec130 100644 --- a/apps/backend/internal/repoclone/protocol.go +++ b/apps/backend/internal/repoclone/protocol.go @@ -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 diff --git a/apps/backend/internal/repoclone/protocol_test.go b/apps/backend/internal/repoclone/protocol_test.go index 512a58d54a..30d0286737 100644 --- a/apps/backend/internal/repoclone/protocol_test.go +++ b/apps/backend/internal/repoclone/protocol_test.go @@ -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 diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx new file mode 100644 index 0000000000..14102ea927 --- /dev/null +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -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 ( + + Merged + + ); + } + if (state === "closed") { + return ( + + Closed + + ); + } + if (mr.draft) { + return ( + + Draft + + ); + } + return ( + + Open + + ); +} + +function IssueStateBadge({ issue }: { issue: Issue }) { + if (issue.state === "closed") { + return ( + + Closed + + ); + } + return ( + + Open + + ); +} + +function NotConnectedNotice() { + return ( + + + +

GitLab not connected

+

+ Connect your GitLab account to browse merge requests and issues from this page. +

+ +
+
+ ); +} + +function MRList({ filter, query }: { filter: string; query: string }) { + const { items: mrs, loading, error } = useGitLabUserMRs(filter, query); + + if (loading) { + return ( +
+ +
+ ); + } + if (error) { + return

{error}

; + } + if (mrs.length === 0) { + return ( +

No merge requests found.

+ ); + } + return ( +
    + {mrs.map((mr) => ( +
  • + +
    +
    +
    + {mr.title} + +
    +

    + {mr.project_path} !{mr.iid} · {mr.head_branch} → {mr.base_branch} +

    +
    + +
    + +
  • + ))} +
+ ); +} + +function IssueList({ filter, query }: { filter: string; query: string }) { + const { items: issues, loading, error } = useGitLabUserIssues(filter, query); + + if (loading) { + return ( +
+ +
+ ); + } + if (error) { + return

{error}

; + } + if (issues.length === 0) { + return

No issues found.

; + } + return ( +
    + {issues.map((issue) => ( +
  • + +
    +
    +
    + {issue.title} + +
    +

    + {issue.project_path} #{issue.iid} + {issue.labels.length > 0 && ` · ${issue.labels.slice(0, 3).join(", ")}`} +

    +
    + +
    + +
  • + ))} +
+ ); +} + +export function GitLabPageClient({ workspaceId: _workspaceId }: { workspaceId?: string }) { + const [status, setStatus] = useState(null); + const [statusLoading, setStatusLoading] = useState(true); + const [mrFilter, setMrFilter] = useState(MR_FILTERS[0].value); + const [issueFilter, setIssueFilter] = useState(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 ( +
+ + {!statusLoading && !connected ? ( + + ) : ( + + )} +
+ ); +} + +function PageHeader({ + status, + statusLoading, + onRefresh, +}: { + status: GitLabStatus | null; + statusLoading: boolean; + onRefresh: () => void; +}) { + return ( +
+
+ +
+

GitLab

+

+ {status?.host ?? "https://gitlab.com"} · merge requests and issues +

+
+
+ +
+ ); +} + +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 ( + <> + setSearch(e.target.value)} + className="font-mono text-sm" + /> + + + + + + Merge requests + + + + Issues + + + + + + + + + + + + + ); +} + +function FilterButtons({ + value, + onChange, + options, +}: { + value: string; + onChange: (v: string) => void; + options: readonly { value: string; label: string }[]; +}) { + return ( +
+ {options.map((f) => ( + + ))} +
+ ); +} diff --git a/apps/web/app/gitlab/page.tsx b/apps/web/app/gitlab/page.tsx new file mode 100644 index 0000000000..5bbe390865 --- /dev/null +++ b/apps/web/app/gitlab/page.tsx @@ -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 = { + workspaces: { items: workspaces, activeId: workspaceId ?? null }, + userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, + }; + + return ( + <> + + + + ); +} diff --git a/apps/web/app/settings/integrations/gitlab/page.tsx b/apps/web/app/settings/integrations/gitlab/page.tsx new file mode 100644 index 0000000000..b38aa35ce4 --- /dev/null +++ b/apps/web/app/settings/integrations/gitlab/page.tsx @@ -0,0 +1,5 @@ +import { GitLabIntegrationPage } from "@/components/gitlab/gitlab-settings"; + +export default function IntegrationsGitLabPage() { + return ; +} diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx index c6b3c121fc..4210f4f447 100644 --- a/apps/web/app/settings/integrations/page.tsx +++ b/apps/web/app/settings/integrations/page.tsx @@ -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", diff --git a/apps/web/components/gitlab/gitlab-settings.tsx b/apps/web/components/gitlab/gitlab-settings.tsx new file mode 100644 index 0000000000..b07f6dabfa --- /dev/null +++ b/apps/web/components/gitlab/gitlab-settings.tsx @@ -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 ( + + Connected + + ); + } + // 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 ( + + Unreachable + + ); + } + return ( + + Not connected + + ); +} + +// 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 ( + + + + Couldn't reach {status.host}:{" "} + {status.connection_error} + + Your token may still be valid — this looks like a network or upstream issue. + + + + ); +} + +function AuthMethodBadge({ method }: { method: GitLabStatus["auth_method"] }) { + const labels: Record = { + glab_cli: "glab CLI", + pat: "Personal access token", + none: "Not configured", + mock: "Mock (test)", + }; + return {labels[method] ?? method}; +} + +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 ( +
+ + setHost(e.target.value)} + className="font-mono text-sm" + disabled={saving} + /> + + + ); +} + +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 ( +
+ +
+ setToken(e.target.value)} + className="font-mono text-sm pr-9" + disabled={saving} + autoComplete="off" + /> + +
+ + + ); +} + +function ClearTokenButton({ onCleared }: { onCleared: () => void }) { + const [busy, setBusy] = useState(false); + const { toast } = useToast(); + return ( + + ); +} + +export function GitLabIntegrationPage() { + const [status, setStatus] = useState(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 ( + } + action={ + + } + > + + + +
+
+ + {status && } + {status?.glab_version && ( + + glab {status.glab_version} + + )} +
+ {status?.username && ( + + Logged in as {status.username} + + )} +
+ + + +
+

+ GitLab host URL. Override for self-managed instances; leave at the default for + gitlab.com. +

+ void reload()} /> +
+ + + +
+
+

+ Personal access token. Required scopes: api, read_user. + Stored encrypted in the kandev secret store. +

+ {status?.token_configured && void reload()} />} +
+ void reload()} /> +
+
+
+
+ ); +} diff --git a/apps/web/components/gitlab/mr-topbar-button.tsx b/apps/web/components/gitlab/mr-topbar-button.tsx new file mode 100644 index 0000000000..7682cf574b --- /dev/null +++ b/apps/web/components/gitlab/mr-topbar-button.tsx @@ -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 ; + if (mr.state === "closed") return ; + if (mr.pipeline_state === "failure") return ; + if (mr.approval_state === "approved" && mr.pipeline_state === "success" && !mr.draft) { + return ; + } + if (mr.approval_state === "pending") return ; + if (mr.pipeline_state === "pending") return ; + 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} + + ); +} + +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 ( + + + + + + {open > 0 &&
{open} open
} + {failed > 0 &&
{failed} failing
} + {merged > 0 &&
{merged} merged
} +
+
+ ); +} + +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 ; + return ; +}); diff --git a/apps/web/components/integrations/integrations-menu.test.ts b/apps/web/components/integrations/integrations-menu.test.ts index 119edc6993..91a9f59740 100644 --- a/apps/web/components/integrations/integrations-menu.test.ts +++ b/apps/web/components/integrations/integrations-menu.test.ts @@ -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 { 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, }), diff --git a/apps/web/components/integrations/integrations-menu.tsx b/apps/web/components/integrations/integrations-menu.tsx index 13731296a7..fa8d7be713 100644 --- a/apps/web/components/integrations/integrations-menu.tsx +++ b/apps/web/components/integrations/integrations-menu.tsx @@ -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; @@ -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, }); diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index ff71c66fa9..b9e0556361 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -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({ diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 29f6502593..d321f26bc9 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -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 }, diff --git a/apps/web/components/task/task-top-bar.test.tsx b/apps/web/components/task/task-top-bar.test.tsx index 5685e52cbf..9643627410 100644 --- a/apps/web/components/task/task-top-bar.test.tsx +++ b/apps/web/components/task/task-top-bar.test.tsx @@ -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, diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 6b053d0ea5..0cf27d60bf 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -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. */} + )} diff --git a/apps/web/hooks/domains/gitlab/use-task-mr.test.ts b/apps/web/hooks/domains/gitlab/use-task-mr.test.ts new file mode 100644 index 0000000000..2db40f817a --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-task-mr.test.ts @@ -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>(); +const listWorkspaceTaskMRsMock = vi.fn< + [string], + Promise<{ task_mrs: Record } | 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 { + 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 { + 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 }) => void = () => {}; + const firstPromise = new Promise<{ task_mrs: Record }>((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)); + }); +}); diff --git a/apps/web/hooks/domains/gitlab/use-task-mr.ts b/apps/web/hooks/domains/gitlab/use-task-mr.ts new file mode 100644 index 0000000000..11e73ede2f --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-task-mr.ts @@ -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(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; +} diff --git a/apps/web/hooks/domains/gitlab/use-user-search.test.ts b/apps/web/hooks/domains/gitlab/use-user-search.test.ts new file mode 100644 index 0000000000..8fc6fd94b9 --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-user-search.test.ts @@ -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>(); +const searchUserIssuesMock = vi.fn<[unknown], Promise>(); + +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((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([]); + }); +}); diff --git a/apps/web/hooks/domains/gitlab/use-user-search.ts b/apps/web/hooks/domains/gitlab/use-user-search.ts new file mode 100644 index 0000000000..6ab6e96dbc --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-user-search.ts @@ -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 = { + items: T[]; + loading: boolean; + error: string | null; +}; + +function initial(): SearchState { + return { items: [], loading: true, error: null }; +} + +async function fetchMRs( + filter: string, + query: string, + perPage: number, + onResult: (s: SearchState) => 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) => 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 { + const [state, setState] = useState>(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 { + const [state, setState] = useState>(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; +} diff --git a/apps/web/lib/api/domains/gitlab-api.test.ts b/apps/web/lib/api/domains/gitlab-api.test.ts new file mode 100644 index 0000000000..658bcc0643 --- /dev/null +++ b/apps/web/lib/api/domains/gitlab-api.test.ts @@ -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; + + 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; + + 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; + + 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"); + }); +}); diff --git a/apps/web/lib/api/domains/gitlab-api.ts b/apps/web/lib/api/domains/gitlab-api.ts new file mode 100644 index 0000000000..a3414a9eab --- /dev/null +++ b/apps/web/lib/api/domains/gitlab-api.ts @@ -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("/api/v1/gitlab/status", options); +} + +export async function configureGitLabToken(token: string) { + return fetchJson("/api/v1/gitlab/token", { + init: { method: "POST", body: JSON.stringify({ token }) }, + }); +} + +export async function clearGitLabToken() { + return fetchJson("/api/v1/gitlab/token", { + init: { method: "DELETE" }, + }); +} + +export async function configureGitLabHost(host: string) { + return fetchJson("/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( + `/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(`/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(`/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(`/api/v1/gitlab/user/issues?${qs.toString()}`, { + cache: "no-store", + }); +} + +export type { MR, Issue, MRSearchPage, IssueSearchPage }; diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index c607e61522..529769188d 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -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): 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, diff --git a/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts b/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts new file mode 100644 index 0000000000..e83be2a55d --- /dev/null +++ b/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts @@ -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 { + 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()(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({}); + }); +}); diff --git a/apps/web/lib/state/slices/gitlab/gitlab-slice.ts b/apps/web/lib/state/slices/gitlab/gitlab-slice.ts new file mode 100644 index 0000000000..7612df21ea --- /dev/null +++ b/apps/web/lib/state/slices/gitlab/gitlab-slice.ts @@ -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 +>[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 = {}; + }), +}); diff --git a/apps/web/lib/state/slices/gitlab/types.ts b/apps/web/lib/state/slices/gitlab/types.ts new file mode 100644 index 0000000000..a7011cf123 --- /dev/null +++ b/apps/web/lib/state/slices/gitlab/types.ts @@ -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; +}; + +export type GitLabSliceState = { + taskMRs: TaskMRsState; +}; + +export type GitLabSliceActions = { + setTaskMRs: (mrs: Record) => 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; diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 09043461a7..3824a8b413 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -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, diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 4547db362b..68a2bbdf4c 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -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) => 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) { // 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) { reviewWatches: merged.reviewWatches, issueWatches: merged.issueWatches, actionPresets: merged.actionPresets, + taskMRs: merged.taskMRs, jiraIssueWatches: merged.jiraIssueWatches, linearIssueWatches: merged.linearIssueWatches, office: merged.office, diff --git a/apps/web/lib/types/gitlab.ts b/apps/web/lib/types/gitlab.ts new file mode 100644 index 0000000000..e251c215a9 --- /dev/null +++ b/apps/web/lib/types/gitlab.ts @@ -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; +}; + +/** 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; +}; diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 561e0972fc..b868b5f5f1 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 diff --git a/docs/specs/gitlab-integration/plan.md b/docs/specs/gitlab-integration/plan.md new file mode 100644 index 0000000000..62441cd7c4 --- /dev/null +++ b/docs/specs/gitlab-integration/plan.md @@ -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}@//.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 `` and + `` 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). diff --git a/docs/specs/gitlab-integration/spec.md b/docs/specs/gitlab-integration/spec.md new file mode 100644 index 0000000000..ffc2ff2163 --- /dev/null +++ b/docs/specs/gitlab-integration/spec.md @@ -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@:/.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.