feat(gitlab): add GitLab integration with MR / discussions support (#861)
* feat(gitlab): add GitLab integration with MR / discussions support Adds a GitLab integration parallel to the GitHub one: PAT and glab CLI auth, self-managed host configuration, MR feedback (approvals + discussions + pipelines), and discussion reply / resolve so agents can address review comments. The pr skill detects the host from git remote and creates a real GitLab merge request via glab or REST. Closes #820 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(gitlab): address review feedback and tighten lint surface - Add service_test.go covering ConfigureToken / ClearToken / ConfigureHost. - Drop speculative MRWatch / TaskMR / ReviewWatch / IssueWatch / preset scaffolding that had no migrations or handlers behind it. - Consolidate /approvals into one round-trip via fetchApprovals. - Use http.DefaultClient in CheckHost (context already carries the deadline). - Rename misleading id → configured in ConfigureToken. - Introduce mrStateOpen constant so the lint goconst rule passes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): address PR review feedback (CodeQL SSRF, races, atomicity) - connectivity: parse host with url.Parse, validate http/https scheme, build probe URL via &url.URL{} so user-controlled host can't smuggle into the request — clears the CodeQL go/request-forgery alert. - pat_client: guard cached username with sync.RWMutex; IsAuthenticated now propagates non-401/403 errors instead of masking transport failures; GetMRStatus skips pipelines query when HeadSHA / HeadBranch are both empty; parseNextLink drops cross-host links instead of returning bare absolute URLs that get() would double-prefix. - service: findTokenSecret returns (bool, string, error) and all callers propagate the secrets.List error so a backend outage isn't mistaken for "token absent"; ConfigureHost persists to hostStore before mutating in-memory host + client + authMethod under one lock. - repoclone: SSH clone URL falls back to ssh:// form when the host has a port (scp-style git@host:path can't carry one). - gitlab/client_helpers: namespace fallback uses LastIndex("/") so subgroup paths (acme/team/squad/repo) are preserved. - pr skill / plan doc: drop hardcoded --target-branch main, derive host from git remote instead of $CI_SERVER_URL, build curl payload with jq -n; fix /pipelines/:id/jobs → :pipeline_id typo. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): preserve host base path in CheckHost; broaden validation; test - connectivity: extract validateHost so the parse / scheme / hostname / userinfo / opaque-URL checks each have a single home. Preserve the configured base path (TrimRight slash) so a host like https://gitlab.acme.corp/gitlab probes /gitlab/api/v4/version instead of dropping the prefix. - connectivity_test: cover validateHost (default, https, http, trailing slash, subpath, missing scheme, ftp, empty host, userinfo) and CheckHost end-to-end (version endpoint, base-path preservation, scheme rejection, unreachable host). - pr skill: drop the broken `glab repo view --json defaultBranch` branch — glab doesn't accept --json, so the lookup silently fell back to "main" every time. Fetch default_branch via the GitLab REST API directly using the PAT we already have. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): override filter defaults, refresh auth uncached, mock pipelines - appendFilter: Del before re-Add so user-supplied filter keys actually override caller defaults (e.g. state=closed swaps the seeded state=opened instead of appending a second value GitLab silently ignored). - IsAuthenticated: probe /user directly instead of routing through the username cache; a token revoked upstream now surfaces as not-connected immediately. Cache is opportunistically refreshed on success. - MockClient.GetMRFeedback: include pipeline state in HasIssues, matching the real PATClient behavior so test flows can't miss a broken MR. - pr skill: document `glab mr note create --reply <id>` for replies (bare `glab mr note` opens a new thread); fix host-extraction block to handle ssh://host:2222/team/repo.git without leaking the port into PROJECT. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): guard mock pipelines on empty MR head; preserve HTTPS port in pr skill - MockClient.GetMRFeedback now mirrors PATClient: skip the pipelines fetch when HeadSHA and HeadBranch are both empty. The mock'\''s ListPipelines ignores its branch argument and returns every pipeline seeded under the project, so without this guard a brand-new MR would inherit a sibling MR'\''s failing pipeline and flip HasIssues to true. Tests cover both the empty-head and head-present cases. - pr skill: derive HOST scheme-aware so https://host:8443 keeps the port (HTTPS API endpoint), ssh://host:2222 strips it (different transport), and scp-style git@host stays portless. Avoids silently re-routing self-managed API calls to the default 443. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): export SeedPipelines; mock GetMRStatus parity; drain bodies; test cache hit - MockClient: add exported SeedPipelines(project, iid, []) alongside the other Seed* methods; mirror PATClient.GetMRStatus by populating PipelineState / jobs counts via summarizePipelines, guarded on a non-empty head ref. Brings mock parity with the real client so any future test seeding pipelines sees the same MRStatus shape. - PATClient HTTP plumbing: drain resp.Body via io.Copy(io.Discard, ...) when result == nil in both getWithTotal and doWrite, so HTTP/1.1 can reuse the connection instead of opening a new TCP socket per call. - TestPATClient_GetAuthenticatedUser: assert calls == 1 after the second GetAuthenticatedUser hit so the cache contract is actually verified (a non-caching implementation would have passed the prior version). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): surface connection errors, align MR pipeline guards, fix mock seed key - service.GetStatus now captures the IsAuthenticated error into a new Status.ConnectionError field (empty on healthy / 401-403, populated on network / 5xx / parse failures). Frontend GitLabStatus + settings page render a distinct "Unreachable" badge + alert so users don't delete a still-valid token during a transient GitLab outage. - PATClient.GetMRFeedback now guards on HeadSHA || HeadBranch, matching GetMRStatus and MockClient.GetMRFeedback (which had been updated in an earlier round). The three call sites were inconsistent — a mock test with HeadBranch-but-no-SHA would pass while the real client silently dropped pipelines. - MockClient.pipelines is now keyed by project alone (was map[mockMRKey][]Pipeline). SeedPipelines takes (project, []) so successive seeds for the same project deterministically overwrite, instead of leaving multiple entries that ListPipelines would resolve in Go's unspecified map-iteration order. - Tests: TestService_GetStatus_{PopulatesConnectionErrorOn5xx, ClearsConnectionErrorOn401} cover the new error surface; TestMockClient_SeedPipelines_OverwritesByProject locks the key change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): tighten validateHost; init Status scopes; close token-host race - validateHost: drop the parsed.Opaque != "" branch — Go only sets Opaque for opaque URIs, which have an empty Host the prior check already rejects. Narrow the userinfo error to mention the actual user:password@host shape so the message matches what users see. - service.GetStatus: initialize RequiredScopes: []string{} on the nil-client defensive branch so the JSON payload keeps the always-an-array contract declared on the TypeScript side. The branch is effectively unreachable (every Provide path returns at least a NoopClient) but the empty-slice fallback costs nothing. - service.ConfigureToken: build the installed client inside the write lock using the up-to-date s.host instead of the pre-probe snapshot. A concurrent ConfigureHost between RUnlock and Lock used to leave s.client pointing at the prior host while s.host advertised the new one — every subsequent API call would silently target the wrong instance until the next reconfigure. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): drop MR-commit stats fields; rename AuthorName; log post-lock host - MRCommitInfo: drop Additions / Deletions / FilesChanged. GitLab's /merge_requests/:iid/commits endpoint doesn't return a stats object, so those fields were always 0 — better to omit them than feed callers silently-wrong numbers. Rename AuthorUsername to AuthorName since the API returns a display name, not a login handle. Doc-comment records the per-commit endpoint to use if a future revision wants real stats. - ListMRCommits: drop the Stats anonymous struct from the JSON decode target now that the destination fields are gone. - service.ConfigureToken: capture installedHost inside the write lock and log that value. The previous log used the pre-validation host snapshot, so a concurrent ConfigureHost between snapshot and lock would leave the log entry referring to a stale value while the client itself was correctly built against the new host. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): encode subgroup paths; sentinel-route errors; mock approval parity - pat_client.SearchGroupProjects now encodes the group path through a new groupRef helper that %2F-substitutes slashes (mirrors projectRef). Subgroup paths like "acme/team" used to land at /groups/acme/team/projects, which GitLab routed to the parent group only and returned 404 for. Subgroups now work end-to-end. - service.ErrInvalidToken sentinel + controller errors.Is routing: token validation failures now wrap a package-level sentinel and the HTTP layer switches on it via errors.Is, replacing the fragile strings.Contains prefix check that would silently fall through to 500 if the message ever changed. Coverage: TestService_ConfigureToken_WrapsErrInvalidToken. - MockClient gains seedable approvals: SeedApprovals(project, iid, list, required) backs a dedicated approvals + requiredApprovals map. ListMRApprovals, GetMRFeedback, and GetMRStatus all consume them so ApprovalState / ApprovalCount / RequiredApprovals match what the real PATClient.GetMRStatus computes. Coverage: TestMockClient_GetMRStatus_UsesSeededApprovals. - ClearToken: same host-snapshot race ConfigureToken was fixed for last round — NewClient now runs under the write lock against s.host rather than a pre-acquired snapshot. - CheckHost: drain resp.Body before close so http.DefaultClient's keep-alive pool can reuse the connection for the next probe. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): full-path projectRef round-trip; unlock during glab probe - splitFullReference now returns (namespace, fullPath) where fullPath is the namespace-qualified project path ("group/sub/project") instead of the last segment alone. Callers can pass MR.ProjectPath straight to projectRef for round-trip API calls (GetMR, GetMRFeedback, etc.) — matches GitLab's own path_with_namespace convention. Existing test cases updated. - ClearToken: build the fallback client OUTSIDE the write lock so NewClient's glab subprocess (bounded by glabAuthTimeout = 10s) doesn't freeze every concurrent GetStatus / GetMRFeedback / Client() RLock reader for the duration. Brief WLock now only wraps the field swap. Tradeoff with the concurrent-ConfigureHost race is documented in-code: narrow stale-host window vs. 10s of blocked readers, and the next reconfigure resolves it. - New regression test TestPATClient_SearchGroupProjects_EncodesSubgroupSlash asserts the request URL is /groups/acme%2Fteam/projects so a future refactor that swapped groupRef back to a bare url.PathEscape would fail visibly instead of silently 404ing subgroup lookups. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): equality-guard ClearToken's client swap against ConfigureHost race ClearToken builds the fallback client outside the write lock to avoid blocking RLock readers for up to 10s while glab auth-probes. The swap-only critical section now compares s.host against the pre-probe snapshot before assigning — if a concurrent ConfigureHost moved the host underneath us, we leave its (newer, correctly-targeted) client alone instead of overwriting with our stale-host build. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): add GitLab to the settings integrations sidebar The /settings/integrations/gitlab page existed and the index card listed GitLab alongside the other integrations, but the settings sidebar nav (IntegrationsNavGroup) only listed GitHub / Jira / Linear / Slack. The page was unreachable through the standard sidebar. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(gitlab): task-MR topbar surface + DB-backed task↔MR store Phase 1 of the GitLab UI build-out: brings the MR side of the task topbar to parity with the existing GitHub PR button. Backend - gitlab_task_mrs table + Store wiring (UpsertTaskMR, ListTaskMRsByTask, ListTaskMRsByWorkspaceID, DeleteTaskMR), uniqued on (task_id, repository_id, project_path, mr_iid) for multi-repo. - service.SyncTaskMR folds a live GetMRStatus probe into an upsert; ListTaskMRsByTask / ListTaskMRsByWorkspace expose the rows. - Three HTTP endpoints: GET /workspaces/:id/task-mrs, GET /tasks/:taskID/mrs, POST /tasks/:taskID/mrs/sync. - initGitLabService now takes a *db.Pool and constructs gitlab.Store. Failures non-fatal: integration keeps working without the table. Frontend - TaskMR type + listWorkspaceTaskMRs / listTaskMRs / syncTaskMR API. - lib/state/slices/gitlab slice (taskMRs.byTaskId) wired through default-state, store.ts AppState, and slices/index exports. - hooks/domains/gitlab/use-task-mr.ts: useWorkspaceMRs (fetch-on- workspace-switch) and useTaskMRs (per-task selector). - components/gitlab/mr-topbar-button.tsx — single/multi MR variants, status icons mirroring pr-task-icon priority order. - task-top-bar.tsx renders PRTopbarButton and MRTopbarButton side-by- side; each returns null when empty, so the union handles GitHub-only, GitLab-only, and multi-repo tasks without an explicit switch. - kanban-board.tsx hydrates useWorkspaceMRs alongside useWorkspacePRs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(gitlab): add /gitlab page for browsing user MRs and issues Adds the GitLab counterpart to /github so users can browse their own merge requests and issues from inside kandev, parallel to the existing GitHub "My GitHub" page. Backend - /api/v1/gitlab/user/mrs and /user/issues — paginated wrappers around the existing SearchMRsPaged / ListIssuesPaged client methods. paginationFromQuery clamps page/per_page the same way as the client. Frontend - /gitlab SSR page + client with MR/Issue tabs, scope filters (assigned / created / review-requested), and a free-text search box that falls back to raw GitLab query-string for power users. - searchUserMRs / searchUserIssues API helpers + MR / Issue / *SearchPage types in lib/types/gitlab. - useGitLabAvailable hook (probes /status + refreshes on window focus) feeds the integrations menu so the GitLab link only shows when a token is configured or auth is green. Tests - task-top-bar.test mocks the new MRTopbarButton so renders no longer pick up useAppStore without a StateProvider. - integrations-menu.test wires the new useGitLabAvailable mock and gitlabReady prop into mockAvailability / getAvailableIntegrationLinks fixtures. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): stable empty array in useTaskMRs to avoid infinite re-render useTaskMRs returned `state.taskMRs.byTaskId[taskId] ?? []` — that fresh literal made the zustand selector produce a new reference on every render when the task had no MRs, triggering an infinite re-render loop in MRTopbarButton that crashed the task page with a Next.js client-side exception. The page never mounted session-chat, so Docker E2E tests that drive `/t/:id` (slow-bootstrap progress, external-stop resume, environment reset) all timed out waiting for the chat panel. Mirror the GitHub side's `EMPTY_PRS` pattern: hoist a module-level `EMPTY_MRS = []` and return it for the empty case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): address PR review — cache header, in-flight cancel, tests - Top-level `cache: "no-store"` in 5 sites (use-task-mr.ts, gitlab-api.ts, gitlab-page-client.tsx). The earlier `init: { cache: "no-store" }` placement was silently overwritten by fetchJson with undefined, so probes hit the browser cache instead. - useWorkspaceMRs now invalidates in-flight responses and calls resetTaskMRs when the workspace becomes null, so the previous workspace's MRs don't linger after sign-out / workspace switch. - Drop unpopulated UnresolvedThreads/DiscussionCount from TaskMR (model, schema, TS type). SyncTaskMR's source MRStatus never populated them and no surface reads them; the dropped columns are skipped via an explicit projection in store.go so existing dev DBs keep binding. - Extract MRList/IssueList inline fetches in /gitlab into useGitLabUserMRs / useGitLabUserIssues — matches CLAUDE.md "never fetch in components". - Tests for store (UpsertTaskMR, composite key, workspace-JOIN isolation, ordering, Delete), service (SyncTaskMR mock round-trip + field mapping + store-unset error, ListTaskMRsByWorkspace JOIN isolation, ListTaskMRsByTask), pat_client (ListIssuesPaged X-Total), gitlab-api (5 new fns), gitlab-slice (setTaskMR composite key), and the use-task-mr / use-user-search hooks. - pr SKILL.md: skip note for steps 5–6 on GitLab remotes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): only wrap 401/403 probe errors as ErrInvalidToken Network failures, 5xx, and DNS errors during `ConfigureToken` previously surfaced to the user as HTTP 400 "invalid token: …" — during a GitLab outage a user with a valid token might delete it assuming it was rejected. Now only `*APIError` with 401/403 is mapped to `ErrInvalidToken`; other errors propagate unwrapped so the controller routes them to "GitLab unreachable" instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,13 @@ description: Commit, push, and create a PR. Default is ready-for-review with aut
|
||||
|
||||
# PR
|
||||
|
||||
> **GitHub tool selection:** This skill uses `gh` CLI commands by default. If `gh` is unavailable or fails, use any available GitHub tools in the environment (e.g. MCP GitHub tools) to create, edit, and view the PR. The goal is the same — the tool may differ.
|
||||
> **Host detection:** This skill works on both GitHub and GitLab repositories. Detect the host before step 4 by inspecting `git remote get-url origin`:
|
||||
> - URL contains `github.com` (or any host you have configured for GitHub) → use the **GitHub flow** below.
|
||||
> - URL contains `gitlab` (e.g. `gitlab.com`, `gitlab.acme.corp`) → use the **GitLab flow** at the bottom of this file.
|
||||
> - For self-managed hosts, the user's repository configuration determines the host.
|
||||
>
|
||||
> **GitHub tool selection:** The GitHub flow uses `gh` CLI by default. If `gh` is unavailable or fails, use any available GitHub tools in the environment (e.g. MCP GitHub tools).
|
||||
> **GitLab tool selection:** The GitLab flow prefers `glab` CLI when available; otherwise it shells `curl` against the REST v4 API using `$GITLAB_TOKEN` (which the agent runtime injects from the user's secrets store).
|
||||
|
||||
## Available skills
|
||||
|
||||
@@ -65,3 +71,85 @@ description: Commit, push, and create a PR. Default is ready-for-review with aut
|
||||
- Tell the user to drag and drop the image files from `.pr-assets/` into the PR description on GitHub for the images to render
|
||||
|
||||
7. **Return the PR URL** when done.
|
||||
|
||||
## GitLab flow (Merge Requests)
|
||||
|
||||
When `git remote get-url origin` points at a GitLab host, the steps are the same up through **Push** (1–3). For step 4, create a Merge Request instead of a PR. **Skip steps 5 and 6** — `/pr-fixup` is wired to GitHub CI / CodeRabbit and `gh pr edit` only works against GitHub. The GitLab equivalent is to manage the MR directly via `glab` or the REST API (see "review comments" note at the bottom). After creating the MR, return the MR URL and stop.
|
||||
|
||||
**MR title** still follows Conventional Commits — the squash-merge commit message is built from it the same way.
|
||||
|
||||
**MR description** uses the same template as the PR body above (Summary, Validation, etc.).
|
||||
|
||||
Prefer the `glab` CLI when it is on the agent's `PATH`:
|
||||
|
||||
Don't hardcode `--target-branch`: many projects ship from `master`, `develop`, or a custom default. Omit the flag so `glab` resolves the project's default branch via the API, or pass an explicit value only if the user / spec already specified one.
|
||||
|
||||
```bash
|
||||
glab mr create [--draft] \
|
||||
--title "type: description" \
|
||||
--description "$(cat <<'EOF'
|
||||
<filled template>
|
||||
EOF
|
||||
)" \
|
||||
--remove-source-branch \
|
||||
--yes
|
||||
```
|
||||
|
||||
If `glab` is unavailable but `$GITLAB_TOKEN` is set, fall back to the REST API. Derive the host from the git remote — `$CI_SERVER_URL` is only set inside GitLab runners and silently falling back to `gitlab.com` from a developer's machine would target the wrong instance. Construct the JSON body with `jq` so multi-line descriptions and embedded quotes can't break the payload.
|
||||
|
||||
```bash
|
||||
REMOTE_URL="$(git remote get-url origin)" # any of: git@host:path.git | ssh://git@host[:port]/path.git | https://host[:port]/path.git
|
||||
# Classify by scheme so we can keep an https:// port (real API endpoint)
|
||||
# while dropping any ssh:// port (irrelevant to the HTTPS API).
|
||||
case "$REMOTE_URL" in
|
||||
ssh://*) URL="${REMOTE_URL#ssh://}"; FORM=ssh ;;
|
||||
http://*|https://*) URL="${REMOTE_URL#*://}"; FORM=http ;;
|
||||
*) URL="$REMOTE_URL"; FORM=scp ;;
|
||||
esac
|
||||
URL="${URL#*@}" # strip optional user@
|
||||
case "$FORM" in
|
||||
scp)
|
||||
# scp-style "git@host:path" — no port possible.
|
||||
HOST_ONLY="${URL%%:*}"
|
||||
HOST="https://${HOST_ONLY}"
|
||||
PROJECT_PATH="${URL#*:}"
|
||||
;;
|
||||
ssh)
|
||||
# ssh:// — port (if any) is the SSH port, not the HTTPS API port.
|
||||
HOST_PORT="${URL%%/*}"
|
||||
HOST="https://${HOST_PORT%%:*}"
|
||||
PROJECT_PATH="${URL#*/}"
|
||||
;;
|
||||
http)
|
||||
# https://host[:port]/path — preserve the port; it IS the API endpoint.
|
||||
HOST_PORT="${URL%%/*}"
|
||||
HOST="https://${HOST_PORT}"
|
||||
PROJECT_PATH="${URL#*/}"
|
||||
;;
|
||||
esac
|
||||
PROJECT="${PROJECT_PATH%.git}" # team/repo
|
||||
SOURCE_BRANCH="$(git branch --show-current)"
|
||||
PROJECT_ENC="$(printf '%s' "$PROJECT" | jq -sRr @uri)"
|
||||
# Default branch via the GitLab API itself, not glab (avoids version drift
|
||||
# on glab's flag surface). Fall back to "main" only if the lookup fails.
|
||||
TARGET_BRANCH="$(curl --fail -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"$HOST/api/v4/projects/$PROJECT_ENC" | jq -r '.default_branch // "main"')"
|
||||
|
||||
PAYLOAD="$(jq -n \
|
||||
--arg source "$SOURCE_BRANCH" \
|
||||
--arg target "$TARGET_BRANCH" \
|
||||
--arg title "type: description" \
|
||||
--arg description "$(cat <<'EOF'
|
||||
<filled template>
|
||||
EOF
|
||||
)" \
|
||||
'{source_branch: $source, target_branch: $target, title: $title, description: $description, remove_source_branch: true}')"
|
||||
|
||||
curl --fail -X POST \
|
||||
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$PAYLOAD" \
|
||||
"$HOST/api/v4/projects/$PROJECT_ENC/merge_requests"
|
||||
```
|
||||
|
||||
To address review comments on a GitLab MR, use the **discussions** API rather than individual review comments — discussions are GitLab's threading primitive. List with `GET /projects/:id/merge_requests/:iid/discussions`, reply with `POST /projects/:id/merge_requests/:iid/discussions/:discussion_id/notes`, and resolve a thread with `PUT /projects/:id/merge_requests/:iid/discussions/:discussion_id?resolved=true`. The `glab` equivalent for replies is `glab mr note create --reply <discussion_id>` — bare `glab mr note` opens a new thread instead of replying to an existing one.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client defines the interface for interacting with the GitLab API.
|
||||
//
|
||||
// Implementations: pat_client.go (REST v4 over HTTP), glab_client.go
|
||||
// (shells out to the glab CLI), mock_client.go (in-memory, gated by
|
||||
// KANDEV_MOCK_GITLAB=true), and noop_client.go (null-object fallback).
|
||||
//
|
||||
// projectPath everywhere is the namespace/path slug, e.g. "group/project"
|
||||
// or "group/subgroup/project". The MR IID (per-project sequential ID) is
|
||||
// the user-visible number.
|
||||
type Client interface {
|
||||
// IsAuthenticated reports whether the client can talk to GitLab.
|
||||
IsAuthenticated(ctx context.Context) (bool, error)
|
||||
|
||||
// GetAuthenticatedUser returns the username of the authenticated user.
|
||||
GetAuthenticatedUser(ctx context.Context) (string, error)
|
||||
|
||||
// Host returns the GitLab host this client is configured against.
|
||||
Host() string
|
||||
|
||||
// GetMR retrieves a single merge request by its per-project IID.
|
||||
GetMR(ctx context.Context, projectPath string, iid int) (*MR, error)
|
||||
|
||||
// FindMRByBranch finds an open MR for the given source branch.
|
||||
FindMRByBranch(ctx context.Context, projectPath, branch string) (*MR, error)
|
||||
|
||||
// ListAuthoredMRs lists open MRs authored by the authenticated user
|
||||
// for a project.
|
||||
ListAuthoredMRs(ctx context.Context, projectPath string) ([]*MR, error)
|
||||
|
||||
// ListReviewRequestedMRs lists open MRs where the user is a reviewer.
|
||||
// filter is an optional additional GitLab API filter (e.g.
|
||||
// "project_id=123" or "milestone=v1"); customQuery, when non-empty,
|
||||
// replaces the entire generated query.
|
||||
ListReviewRequestedMRs(ctx context.Context, filter, customQuery string) ([]*MR, error)
|
||||
|
||||
// ListUserGroups returns the GitLab groups the authenticated user
|
||||
// belongs to (analogous to GitHubOrg).
|
||||
ListUserGroups(ctx context.Context) ([]Group, error)
|
||||
|
||||
// SearchGroupProjects searches projects in a group, optionally
|
||||
// filtered by a query string.
|
||||
SearchGroupProjects(ctx context.Context, group, query string, limit int) ([]Project, error)
|
||||
|
||||
// ListMRApprovals lists approvals on a merge request.
|
||||
ListMRApprovals(ctx context.Context, projectPath string, iid int) ([]MRApproval, error)
|
||||
|
||||
// ListMRDiscussions lists discussions (review threads) on an MR.
|
||||
// If since is non-nil, only discussions updated after that time are
|
||||
// returned.
|
||||
ListMRDiscussions(ctx context.Context, projectPath string, iid int, since *time.Time) ([]MRDiscussion, error)
|
||||
|
||||
// CreateMRDiscussionNote posts a reply note in an existing discussion.
|
||||
CreateMRDiscussionNote(ctx context.Context, projectPath string, iid int, discussionID, body string) (*MRNote, error)
|
||||
|
||||
// ResolveMRDiscussion marks a discussion as resolved.
|
||||
ResolveMRDiscussion(ctx context.Context, projectPath string, iid int, discussionID string) error
|
||||
|
||||
// ListPipelines lists pipelines for a given git ref (branch or SHA).
|
||||
ListPipelines(ctx context.Context, projectPath, ref string) ([]Pipeline, error)
|
||||
|
||||
// GetMRFeedback fetches aggregated feedback (approvals, discussions,
|
||||
// pipelines) for an MR.
|
||||
GetMRFeedback(ctx context.Context, projectPath string, iid int) (*MRFeedback, error)
|
||||
|
||||
// GetMRStatus fetches lightweight MR state (used by the poller).
|
||||
GetMRStatus(ctx context.Context, projectPath string, iid int) (*MRStatus, error)
|
||||
|
||||
// ListMRFiles lists files changed in a merge request.
|
||||
ListMRFiles(ctx context.Context, projectPath string, iid int) ([]MRFile, error)
|
||||
|
||||
// ListMRCommits lists commits in a merge request.
|
||||
ListMRCommits(ctx context.Context, projectPath string, iid int) ([]MRCommitInfo, error)
|
||||
|
||||
// SubmitMRApproval approves an MR. To revoke an approval, call
|
||||
// SubmitMRUnapproval.
|
||||
SubmitMRApproval(ctx context.Context, projectPath string, iid int) error
|
||||
|
||||
// SubmitMRUnapproval revokes the authenticated user's approval of an MR.
|
||||
SubmitMRUnapproval(ctx context.Context, projectPath string, iid int) error
|
||||
|
||||
// CreateMR opens a new merge request. Used by the agent `pr` skill.
|
||||
CreateMR(ctx context.Context, projectPath, sourceBranch, targetBranch, title, description string, draft bool) (*MR, error)
|
||||
|
||||
// ListProjectBranches lists branches for a project.
|
||||
ListProjectBranches(ctx context.Context, projectPath string) ([]RepoBranch, error)
|
||||
|
||||
// ListIssues searches for open issues. filter is an optional
|
||||
// additional API filter; customQuery, when non-empty, replaces the
|
||||
// entire generated query.
|
||||
ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error)
|
||||
|
||||
// SearchMRs searches for MRs matching the given query.
|
||||
SearchMRs(ctx context.Context, filter, customQuery string) ([]*MR, error)
|
||||
|
||||
// SearchMRsPaged is the paginated variant of SearchMRs. page is
|
||||
// 1-indexed; perPage is clamped to GitLab's 1..100 range.
|
||||
SearchMRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*MRSearchPage, error)
|
||||
|
||||
// ListIssuesPaged is the paginated variant of ListIssues.
|
||||
ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error)
|
||||
|
||||
// GetIssueState returns the state of a single issue ("opened" or "closed").
|
||||
GetIssueState(ctx context.Context, projectPath string, iid int) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rawMR is the JSON shape of a GitLab merge request as returned by the
|
||||
// REST v4 API.
|
||||
type rawMR struct {
|
||||
ID int64 `json:"id"`
|
||||
IID int `json:"iid"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State string `json:"state"` // opened, closed, merged, locked
|
||||
WebURL string `json:"web_url"`
|
||||
Draft bool `json:"draft"`
|
||||
WorkInProgress bool `json:"work_in_progress"`
|
||||
MergeStatus string `json:"merge_status"`
|
||||
HasConflicts bool `json:"has_conflicts"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
SHA string `json:"sha"`
|
||||
References struct {
|
||||
Full string `json:"full"`
|
||||
} `json:"references"`
|
||||
Author rawUser `json:"author"`
|
||||
Reviewers []rawUser `json:"reviewers"`
|
||||
Assignees []rawUser `json:"assignees"`
|
||||
ChangesCount string `json:"changes_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MergedAt *time.Time `json:"merged_at"`
|
||||
ClosedAt *time.Time `json:"closed_at"`
|
||||
}
|
||||
|
||||
type rawUser struct {
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Bot bool `json:"bot"`
|
||||
}
|
||||
|
||||
type rawProject struct {
|
||||
ID int64 `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
PathWithNamespace string `json:"path_with_namespace"`
|
||||
Visibility string `json:"visibility"`
|
||||
Namespace struct {
|
||||
FullPath string `json:"full_path"`
|
||||
} `json:"namespace"`
|
||||
}
|
||||
|
||||
type rawIssue struct {
|
||||
ID int64 `json:"id"`
|
||||
IID int `json:"iid"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State string `json:"state"`
|
||||
WebURL string `json:"web_url"`
|
||||
Author rawUser `json:"author"`
|
||||
Labels []string `json:"labels"`
|
||||
Assignees []rawUser `json:"assignees"`
|
||||
References struct {
|
||||
Full string `json:"full"`
|
||||
} `json:"references"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ClosedAt *time.Time `json:"closed_at"`
|
||||
}
|
||||
|
||||
type rawDiscussion struct {
|
||||
ID string `json:"id"`
|
||||
IndividualNote bool `json:"individual_note"`
|
||||
Notes []rawNote `json:"notes"`
|
||||
}
|
||||
|
||||
type rawNote struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
Type string `json:"type"`
|
||||
System bool `json:"system"`
|
||||
Resolvable bool `json:"resolvable"`
|
||||
Resolved bool `json:"resolved"`
|
||||
Author rawUser `json:"author"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Position *struct {
|
||||
NewPath string `json:"new_path"`
|
||||
OldPath string `json:"old_path"`
|
||||
NewLine int `json:"new_line"`
|
||||
OldLine int `json:"old_line"`
|
||||
} `json:"position"`
|
||||
}
|
||||
|
||||
type rawPipeline struct {
|
||||
ID int64 `json:"id"`
|
||||
IID int `json:"iid"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
Ref string `json:"ref"`
|
||||
SHA string `json:"sha"`
|
||||
WebURL string `json:"web_url"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
func convertRawMR(raw *rawMR) *MR {
|
||||
state := normalizeMRState(raw.State)
|
||||
namespace, projectPath := splitFullReference(raw.References.Full)
|
||||
mr := &MR{
|
||||
ID: raw.ID,
|
||||
IID: raw.IID,
|
||||
ProjectID: raw.ProjectID,
|
||||
Title: raw.Title,
|
||||
URL: raw.WebURL,
|
||||
WebURL: raw.WebURL,
|
||||
State: state,
|
||||
HeadBranch: raw.SourceBranch,
|
||||
HeadSHA: raw.SHA,
|
||||
BaseBranch: raw.TargetBranch,
|
||||
AuthorUsername: raw.Author.Username,
|
||||
ProjectNamespace: namespace,
|
||||
ProjectPath: projectPath,
|
||||
Body: raw.Description,
|
||||
Draft: raw.Draft || raw.WorkInProgress,
|
||||
MergeStatus: raw.MergeStatus,
|
||||
HasConflicts: raw.HasConflicts,
|
||||
Reviewers: convertReviewers(raw.Reviewers),
|
||||
Assignees: convertReviewers(raw.Assignees),
|
||||
CreatedAt: raw.CreatedAt,
|
||||
UpdatedAt: raw.UpdatedAt,
|
||||
MergedAt: raw.MergedAt,
|
||||
ClosedAt: raw.ClosedAt,
|
||||
}
|
||||
return mr
|
||||
}
|
||||
|
||||
func convertRawMRSlice(raw []rawMR) []*MR {
|
||||
out := make([]*MR, len(raw))
|
||||
for i := range raw {
|
||||
out[i] = convertRawMR(&raw[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertReviewers(raw []rawUser) []MRReviewer {
|
||||
out := make([]MRReviewer, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
if r.Username == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, MRReviewer{
|
||||
Username: r.Username,
|
||||
Name: r.Name,
|
||||
Type: "user",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertRawIssue(raw *rawIssue) *Issue {
|
||||
namespace, projectPath := splitFullReference(raw.References.Full)
|
||||
assignees := make([]string, 0, len(raw.Assignees))
|
||||
for _, a := range raw.Assignees {
|
||||
if a.Username != "" {
|
||||
assignees = append(assignees, a.Username)
|
||||
}
|
||||
}
|
||||
return &Issue{
|
||||
ID: raw.ID,
|
||||
IID: raw.IID,
|
||||
ProjectID: raw.ProjectID,
|
||||
Title: raw.Title,
|
||||
Body: raw.Description,
|
||||
URL: raw.WebURL,
|
||||
WebURL: raw.WebURL,
|
||||
State: raw.State,
|
||||
AuthorUsername: raw.Author.Username,
|
||||
ProjectNamespace: namespace,
|
||||
ProjectPath: projectPath,
|
||||
Labels: append([]string(nil), raw.Labels...),
|
||||
Assignees: assignees,
|
||||
CreatedAt: raw.CreatedAt,
|
||||
UpdatedAt: raw.UpdatedAt,
|
||||
ClosedAt: raw.ClosedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func convertRawProject(raw *rawProject) Project {
|
||||
namespace := raw.Namespace.FullPath
|
||||
if namespace == "" {
|
||||
// GitLab subgroups can be arbitrarily nested (acme/team/squad/repo),
|
||||
// so the namespace is everything up to the final "/" — not just the
|
||||
// first segment.
|
||||
if idx := strings.LastIndex(raw.PathWithNamespace, "/"); idx > 0 {
|
||||
namespace = raw.PathWithNamespace[:idx]
|
||||
}
|
||||
}
|
||||
return Project{
|
||||
ID: raw.ID,
|
||||
PathWithNamespace: raw.PathWithNamespace,
|
||||
Namespace: namespace,
|
||||
Path: raw.Path,
|
||||
Name: raw.Name,
|
||||
Visibility: raw.Visibility,
|
||||
}
|
||||
}
|
||||
|
||||
func convertRawDiscussion(raw *rawDiscussion) MRDiscussion {
|
||||
d := MRDiscussion{
|
||||
ID: raw.ID,
|
||||
Notes: make([]MRNote, 0, len(raw.Notes)),
|
||||
}
|
||||
for i := range raw.Notes {
|
||||
note := convertRawNote(&raw.Notes[i])
|
||||
d.Notes = append(d.Notes, note)
|
||||
if i == 0 {
|
||||
d.Resolvable = raw.Notes[i].Resolvable
|
||||
d.Resolved = raw.Notes[i].Resolved
|
||||
d.CreatedAt = raw.Notes[i].CreatedAt
|
||||
d.UpdatedAt = raw.Notes[i].UpdatedAt
|
||||
if raw.Notes[i].Position != nil {
|
||||
d.Path = raw.Notes[i].Position.NewPath
|
||||
d.Line = raw.Notes[i].Position.NewLine
|
||||
d.OldLine = raw.Notes[i].Position.OldLine
|
||||
}
|
||||
} else if note.UpdatedAt.After(d.UpdatedAt) {
|
||||
d.UpdatedAt = note.UpdatedAt
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func convertRawNote(raw *rawNote) MRNote {
|
||||
return MRNote{
|
||||
ID: raw.ID,
|
||||
Author: raw.Author.Username,
|
||||
AuthorAvatar: raw.Author.AvatarURL,
|
||||
AuthorIsBot: raw.Author.Bot,
|
||||
Body: raw.Body,
|
||||
Type: raw.Type,
|
||||
System: raw.System,
|
||||
CreatedAt: raw.CreatedAt,
|
||||
UpdatedAt: raw.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func convertRawPipeline(raw *rawPipeline) Pipeline {
|
||||
return Pipeline{
|
||||
ID: raw.ID,
|
||||
IID: raw.IID,
|
||||
Status: raw.Status,
|
||||
Source: raw.Source,
|
||||
Ref: raw.Ref,
|
||||
SHA: raw.SHA,
|
||||
WebURL: raw.WebURL,
|
||||
StartedAt: raw.StartedAt,
|
||||
FinishedAt: raw.FinishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// mrStateOpen is the normalized "open" state value shared with the GitHub
|
||||
// integration vocabulary. GitLab's API returns "opened"; we expose "open".
|
||||
const mrStateOpen = "open"
|
||||
|
||||
// normalizeMRState converts GitLab's "opened" to "open" and leaves the rest
|
||||
// alone so the UI shares the GitHub vocabulary.
|
||||
func normalizeMRState(state string) string {
|
||||
switch state {
|
||||
case "opened":
|
||||
return mrStateOpen
|
||||
case "merged", "closed", "locked":
|
||||
return state
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// splitFullReference parses GitLab's "namespace/path!iid" or
|
||||
// "namespace/path#iid" form into (namespace, projectPath). It is best-effort:
|
||||
// when the reference does not match it returns ("", "").
|
||||
// splitFullReference parses GitLab's full-reference strings (e.g.
|
||||
// "group/sub/project!42" for an MR or "group/project#10" for an issue) into
|
||||
// (namespace, projectPath). projectPath is the *full* path-with-namespace
|
||||
// — "group/sub/project" — so callers can round-trip it back into API URLs
|
||||
// via projectRef without having to recombine namespace + name themselves.
|
||||
// namespace is everything before the final "/".
|
||||
func splitFullReference(full string) (namespace, projectPath string) {
|
||||
for _, sep := range []string{"!", "#"} {
|
||||
if idx := strings.Index(full, sep); idx > 0 {
|
||||
full = full[:idx]
|
||||
break
|
||||
}
|
||||
}
|
||||
last := strings.LastIndex(full, "/")
|
||||
if last <= 0 {
|
||||
return "", ""
|
||||
}
|
||||
return full[:last], full
|
||||
}
|
||||
|
||||
func hasOpenDiscussions(discussions []MRDiscussion) bool {
|
||||
for _, d := range discussions {
|
||||
if d.Resolvable && !d.Resolved {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pipelineFailing(pipelines []Pipeline) bool {
|
||||
state, _, _ := summarizePipelines(pipelines)
|
||||
return state == "failure"
|
||||
}
|
||||
|
||||
// Computed status strings shared by pipeline + approval summarizers.
|
||||
const statusPending = "pending"
|
||||
|
||||
// summarizePipelines reduces a list of pipeline runs (most-recent-first per
|
||||
// the GitLab API) to a single state plus job counts. Only the most recent
|
||||
// pipeline matters for the rolled-up status.
|
||||
func summarizePipelines(pipelines []Pipeline) (state string, jobsTotal, jobsPassing int) {
|
||||
if len(pipelines) == 0 {
|
||||
return "", 0, 0
|
||||
}
|
||||
latest := pipelines[0]
|
||||
jobsTotal = latest.JobsTotal
|
||||
jobsPassing = latest.JobsPassing
|
||||
switch latest.Status {
|
||||
case "success":
|
||||
state = "success"
|
||||
case "failed", "canceled":
|
||||
state = "failure"
|
||||
case "skipped":
|
||||
state = ""
|
||||
default:
|
||||
state = statusPending
|
||||
}
|
||||
return state, jobsTotal, jobsPassing
|
||||
}
|
||||
|
||||
func summarizeApprovals(have, required int) string {
|
||||
if required == 0 {
|
||||
if have > 0 {
|
||||
return "approved"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if have >= required {
|
||||
return "approved"
|
||||
}
|
||||
return statusPending
|
||||
}
|
||||
|
||||
// --- Search query builders ---
|
||||
|
||||
// buildReviewMRQuery builds a query string for "MRs needing my review".
|
||||
// GitLab's /merge_requests endpoint scopes to the authenticated user when
|
||||
// `scope=assigned_to_me` or `reviewer_username=<me>`; we pass
|
||||
// `reviewer_username=` resolution to the caller via filter (e.g.
|
||||
// "reviewer_username=octocat"). state defaults to "opened".
|
||||
func buildReviewMRQuery(filter, customQuery string) string {
|
||||
if customQuery != "" {
|
||||
return customQuery
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("state", "opened")
|
||||
values.Set("scope", "all")
|
||||
values.Set("per_page", "50")
|
||||
if filter != "" {
|
||||
appendFilter(values, filter)
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func buildMRSearchQuery(filter, customQuery string) string {
|
||||
if customQuery != "" {
|
||||
return customQuery
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("state", "opened")
|
||||
values.Set("scope", "all")
|
||||
if filter != "" {
|
||||
appendFilter(values, filter)
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func buildIssueSearchQuery(filter, customQuery string) string {
|
||||
if customQuery != "" {
|
||||
return customQuery
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("state", "opened")
|
||||
values.Set("scope", "all")
|
||||
if filter != "" {
|
||||
appendFilter(values, filter)
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
// appendFilter parses a `key=value&key2=value2` filter and merges it into
|
||||
// values. User-supplied keys override defaults set by the caller (so passing
|
||||
// "state=closed" actually swaps the default "opened" rather than appending
|
||||
// a second value). Unparseable filters are ignored — callers that need
|
||||
// stricter validation should use customQuery instead.
|
||||
func appendFilter(values url.Values, filter string) {
|
||||
parsed, err := url.ParseQuery(filter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for k, vs := range parsed {
|
||||
values.Del(k)
|
||||
for _, v := range vs {
|
||||
values.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// countDiffLines returns (additions, deletions) by counting lines starting
|
||||
// with "+" or "-" (excluding the diff header lines that start with
|
||||
// "+++"/"---"). Best-effort; matches the GitLab UI's own counting.
|
||||
func countDiffLines(diff string) (int, int) {
|
||||
additions, deletions := 0, 0
|
||||
for _, line := range strings.Split(diff, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
|
||||
continue
|
||||
case strings.HasPrefix(line, "+"):
|
||||
additions++
|
||||
case strings.HasPrefix(line, "-"):
|
||||
deletions++
|
||||
}
|
||||
}
|
||||
return additions, deletions
|
||||
}
|
||||
|
||||
func diffStatus(newFile, deletedFile, renamedFile bool) string {
|
||||
switch {
|
||||
case newFile:
|
||||
return "added"
|
||||
case deletedFile:
|
||||
return "deleted"
|
||||
case renamedFile:
|
||||
return "renamed"
|
||||
default:
|
||||
return "modified"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// User-supplied filter keys must override defaults seeded by the caller.
|
||||
// The previous implementation used values.Add(), which kept the default
|
||||
// alongside the user value (e.g. state=opened&state=closed) and GitLab
|
||||
// silently honoured the first one.
|
||||
func TestAppendFilter_UserKeysOverrideDefaults(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("state", "opened")
|
||||
values.Set("scope", "all")
|
||||
appendFilter(values, "state=closed&labels=bug")
|
||||
if got := values.Get("state"); got != "closed" {
|
||||
t.Errorf("state = %q, want closed (user filter must override default)", got)
|
||||
}
|
||||
if got := values["state"]; len(got) != 1 {
|
||||
t.Errorf("state values = %v, want length 1 (defaults must be cleared before re-adding)", got)
|
||||
}
|
||||
if got := values.Get("labels"); got != "bug" {
|
||||
t.Errorf("labels = %q, want bug", got)
|
||||
}
|
||||
if got := values.Get("scope"); got != "all" {
|
||||
t.Errorf("scope = %q, want all (untouched keys keep their default)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendFilter_MultiValueOverride(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("labels", "default")
|
||||
appendFilter(values, "labels=a&labels=b")
|
||||
got := values["labels"]
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Errorf("labels = %v, want [a b] (default cleared then both user values appended)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendFilter_UnparseableIgnored(t *testing.T) {
|
||||
values := url.Values{}
|
||||
values.Set("state", "opened")
|
||||
appendFilter(values, "%bad%")
|
||||
if got := values.Get("state"); got != "opened" {
|
||||
t.Errorf("state = %q, want opened (unparseable filter must not nuke defaults)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// connectivityTimeout bounds the host-reachability probe.
|
||||
const connectivityTimeout = 5 * time.Second
|
||||
|
||||
// validateHost parses and validates a GitLab base URL, returning the parsed
|
||||
// URL with only the fields we'll use copied across. Splitting validation
|
||||
// from request construction makes both the unit tests and the SSRF surface
|
||||
// tractable: anything that escapes this function has a known-good scheme
|
||||
// and a non-empty host.
|
||||
func validateHost(host string) (*url.URL, error) {
|
||||
if host == "" {
|
||||
host = DefaultHost
|
||||
}
|
||||
parsed, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, errors.New("host must use http or https scheme")
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return nil, errors.New("host is missing a hostname")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, errors.New("host must not contain userinfo (user:password@host is not allowed)")
|
||||
}
|
||||
return &url.URL{
|
||||
Scheme: parsed.Scheme,
|
||||
Host: parsed.Host,
|
||||
Path: strings.TrimRight(parsed.Path, "/"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckHost reports whether the given GitLab host is reachable. It issues
|
||||
// an unauthenticated GET to /api/v4/version and treats any HTTP response
|
||||
// (including 401) as "reachable" — only network-level errors count as a
|
||||
// failure. Used by the settings page when the user enters a self-managed
|
||||
// host URL, before they configure a token.
|
||||
//
|
||||
// The host is parsed and its scheme verified to be http/https before the
|
||||
// request is built. The probe URL is composed via net/url rather than string
|
||||
// concatenation so user input cannot smuggle in a different host or path —
|
||||
// this is what makes the call safe against SSRF.
|
||||
func CheckHost(ctx context.Context, host string) error {
|
||||
base, err := validateHost(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
probe := *base
|
||||
probe.Path = base.Path + apiPathPrefix + "/version"
|
||||
cctx, cancel := context.WithTimeout(ctx, connectivityTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, http.MethodGet, probe.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Drain so http.DefaultClient's transport can reuse the connection on
|
||||
// the next probe — same pattern as PATClient.getWithTotal / doWrite.
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantErr string // substring; "" = success
|
||||
wantPath string // expected base.Path after validation
|
||||
}{
|
||||
{"default-when-empty", "", "", ""},
|
||||
{"https-bare", "https://gitlab.example.com", "", ""},
|
||||
{"http-allowed", "http://gitlab.local", "", ""},
|
||||
{"trailing-slash-trimmed", "https://gitlab.example.com/", "", ""},
|
||||
{"subpath-preserved", "https://gitlab.acme.corp/gitlab", "", "/gitlab"},
|
||||
{"subpath-trailing-slash-trimmed", "https://gitlab.acme.corp/gitlab/", "", "/gitlab"},
|
||||
{"missing-scheme", "gitlab.example.com", "scheme", ""},
|
||||
{"ftp-scheme-rejected", "ftp://gitlab.example.com", "scheme", ""},
|
||||
{"empty-host", "https://", "hostname", ""},
|
||||
{"userinfo-rejected", "https://user:pw@gitlab.example.com", "userinfo", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := validateHost(tc.in)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("err = %v, want substring %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if got.Path != tc.wantPath {
|
||||
t.Errorf("Path = %q, want %q", got.Path, tc.wantPath)
|
||||
}
|
||||
if got.Scheme != "http" && got.Scheme != "https" {
|
||||
t.Errorf("scheme escaped validation: %q", got.Scheme)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHost_ProbesVersionEndpoint(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
if r.URL.Path != "/api/v4/version" {
|
||||
t.Errorf("path = %q, want /api/v4/version", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized) // 401 still counts as reachable
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := CheckHost(context.Background(), srv.URL); err != nil {
|
||||
t.Fatalf("err = %v, want nil (401 is a reachable signal)", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("server was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHost_PreservesBasePath(t *testing.T) {
|
||||
called := false
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/gitlab/api/v4/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
_, _ = w.Write([]byte(`{"version":"16.0.0"}`))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
if err := CheckHost(context.Background(), srv.URL+"/gitlab"); err != nil {
|
||||
t.Fatalf("err = %v, want nil for host with /gitlab base path", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("server was not called at /gitlab/api/v4/version — base path was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHost_RejectsBadScheme(t *testing.T) {
|
||||
if err := CheckHost(context.Background(), "ftp://gitlab.example.com"); err == nil {
|
||||
t.Fatal("expected scheme rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHost_UnreachableHostReturnsError(t *testing.T) {
|
||||
// 192.0.2.0/24 is reserved for documentation (RFC 5737); connecting
|
||||
// to it deterministically fails.
|
||||
err := CheckHost(context.Background(), "http://192.0.2.1:1")
|
||||
if err == nil {
|
||||
t.Fatal("expected network error")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,269 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// Controller handles HTTP endpoints for GitLab integration.
|
||||
type Controller struct {
|
||||
service *Service
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewController creates a new GitLab controller.
|
||||
func NewController(svc *Service, log *logger.Logger) *Controller {
|
||||
return &Controller{service: svc, logger: log}
|
||||
}
|
||||
|
||||
// RegisterHTTPRoutes registers the v1 HTTP surface.
|
||||
func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
||||
api := router.Group("/api/v1/gitlab")
|
||||
api.GET("/status", c.httpGetStatus)
|
||||
api.POST("/token", c.httpConfigureToken)
|
||||
api.DELETE("/token", c.httpClearToken)
|
||||
api.POST("/host", c.httpConfigureHost)
|
||||
|
||||
api.GET("/mrs/feedback", c.httpGetMRFeedback)
|
||||
api.POST("/mrs/discussions/notes", c.httpCreateDiscussionNote)
|
||||
api.POST("/mrs/discussions/resolve", c.httpResolveDiscussion)
|
||||
|
||||
api.GET("/workspaces/:workspaceID/task-mrs", c.httpListWorkspaceTaskMRs)
|
||||
api.GET("/tasks/:taskID/mrs", c.httpListTaskMRs)
|
||||
api.POST("/tasks/:taskID/mrs/sync", c.httpSyncTaskMR)
|
||||
|
||||
api.GET("/user/mrs", c.httpSearchUserMRs)
|
||||
api.GET("/user/issues", c.httpSearchUserIssues)
|
||||
}
|
||||
|
||||
// RegisterRoutes is the package-level entrypoint mirroring github.RegisterRoutes.
|
||||
func RegisterRoutes(router *gin.Engine, svc *Service, log *logger.Logger) {
|
||||
NewController(svc, log).RegisterHTTPRoutes(router)
|
||||
}
|
||||
|
||||
func (c *Controller) httpGetStatus(ctx *gin.Context) {
|
||||
status, err := c.service.GetStatus(ctx.Request.Context())
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (c *Controller) httpConfigureToken(ctx *gin.Context) {
|
||||
var req ConfigureTokenRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: token is required"})
|
||||
return
|
||||
}
|
||||
if err := c.service.ConfigureToken(ctx.Request.Context(), req.Token); err != nil {
|
||||
// errors.Is against the package-level sentinel — durable across
|
||||
// future rewording / wrapping of the ConfigureToken error message.
|
||||
if errors.Is(err, ErrInvalidToken) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"configured": true})
|
||||
}
|
||||
|
||||
func (c *Controller) httpClearToken(ctx *gin.Context) {
|
||||
if err := c.service.ClearToken(ctx.Request.Context()); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"cleared": true})
|
||||
}
|
||||
|
||||
func (c *Controller) httpConfigureHost(ctx *gin.Context) {
|
||||
var req ConfigureHostRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: host is required"})
|
||||
return
|
||||
}
|
||||
if err := c.service.ConfigureHost(ctx.Request.Context(), req.Host); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"configured": true, "host": c.service.Host()})
|
||||
}
|
||||
|
||||
func (c *Controller) httpGetMRFeedback(ctx *gin.Context) {
|
||||
projectPath, iid, err := parseProjectAndIID(ctx)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
feedback, err := c.service.GetMRFeedback(ctx.Request.Context(), projectPath, iid)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, feedback)
|
||||
}
|
||||
|
||||
func (c *Controller) httpCreateDiscussionNote(ctx *gin.Context) {
|
||||
var req struct {
|
||||
Project string `json:"project" binding:"required"`
|
||||
IID int `json:"iid" binding:"required"`
|
||||
DiscussionID string `json:"discussion_id" binding:"required"`
|
||||
Body string `json:"body" binding:"required"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
note, err := c.service.CreateMRDiscussionNote(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID, req.Body)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, note)
|
||||
}
|
||||
|
||||
func (c *Controller) httpResolveDiscussion(ctx *gin.Context) {
|
||||
var req struct {
|
||||
Project string `json:"project" binding:"required"`
|
||||
IID int `json:"iid" binding:"required"`
|
||||
DiscussionID string `json:"discussion_id" binding:"required"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if err := c.service.ResolveMRDiscussion(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"resolved": true})
|
||||
}
|
||||
|
||||
// parseProjectAndIID reads ?project=<path>&iid=<n> query params and validates
|
||||
// them. project is the namespace/path slug (URL-decoded by Gin); iid must be
|
||||
// a positive integer.
|
||||
func parseProjectAndIID(ctx *gin.Context) (string, int, error) {
|
||||
project := ctx.Query("project")
|
||||
if project == "" {
|
||||
return "", 0, errors.New("project query param required")
|
||||
}
|
||||
iidStr := ctx.Query("iid")
|
||||
if iidStr == "" {
|
||||
return "", 0, errors.New("iid query param required")
|
||||
}
|
||||
iid, err := strconv.Atoi(iidStr)
|
||||
if err != nil || iid <= 0 {
|
||||
return "", 0, errors.New("iid must be a positive integer")
|
||||
}
|
||||
return project, iid, nil
|
||||
}
|
||||
|
||||
// SyncTaskMRRequest is the JSON body for POST /tasks/:taskID/mrs/sync.
|
||||
// project_path is "namespace/path"; iid is the MR's per-project sequential id;
|
||||
// repository_id is the kandev repository UUID (empty for single-repo tasks).
|
||||
type SyncTaskMRRequest struct {
|
||||
ProjectPath string `json:"project_path" binding:"required"`
|
||||
IID int `json:"iid" binding:"required"`
|
||||
RepositoryID string `json:"repository_id"`
|
||||
}
|
||||
|
||||
func (c *Controller) httpListWorkspaceTaskMRs(ctx *gin.Context) {
|
||||
wsID := ctx.Param("workspaceID")
|
||||
if wsID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspaceID required"})
|
||||
return
|
||||
}
|
||||
taskMRs, err := c.service.ListTaskMRsByWorkspace(ctx.Request.Context(), wsID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, TaskMRsResponse{TaskMRs: taskMRs})
|
||||
}
|
||||
|
||||
func (c *Controller) httpListTaskMRs(ctx *gin.Context) {
|
||||
taskID := ctx.Param("taskID")
|
||||
if taskID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "taskID required"})
|
||||
return
|
||||
}
|
||||
mrs, err := c.service.ListTaskMRsByTask(ctx.Request.Context(), taskID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"task_mrs": mrs})
|
||||
}
|
||||
|
||||
func (c *Controller) httpSyncTaskMR(ctx *gin.Context) {
|
||||
taskID := ctx.Param("taskID")
|
||||
if taskID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "taskID required"})
|
||||
return
|
||||
}
|
||||
var req SyncTaskMRRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: project_path and iid required"})
|
||||
return
|
||||
}
|
||||
row, err := c.service.SyncTaskMR(ctx.Request.Context(), taskID, req.RepositoryID, req.ProjectPath, req.IID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, row)
|
||||
}
|
||||
|
||||
// httpSearchUserMRs surfaces the configured user's MR queue. filter is one of
|
||||
// "assigned", "authored", "review_requested" (matching the GitLab "scope"
|
||||
// query param); custom_query passes through verbatim for power users.
|
||||
func (c *Controller) httpSearchUserMRs(ctx *gin.Context) {
|
||||
page, perPage := paginationFromQuery(ctx)
|
||||
result, err := c.service.Client().SearchMRsPaged(
|
||||
ctx.Request.Context(),
|
||||
ctx.Query("filter"),
|
||||
ctx.Query("custom_query"),
|
||||
page, perPage,
|
||||
)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// httpSearchUserIssues surfaces the configured user's issue queue.
|
||||
func (c *Controller) httpSearchUserIssues(ctx *gin.Context) {
|
||||
page, perPage := paginationFromQuery(ctx)
|
||||
result, err := c.service.Client().ListIssuesPaged(
|
||||
ctx.Request.Context(),
|
||||
ctx.Query("filter"),
|
||||
ctx.Query("custom_query"),
|
||||
page, perPage,
|
||||
)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// paginationFromQuery reads ?page=&per_page= with the same clamps SearchMRsPaged
|
||||
// applies internally — surfaced here so 400s on bad input are uniform.
|
||||
func paginationFromQuery(ctx *gin.Context) (int, int) {
|
||||
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
perPage, _ := strconv.Atoi(ctx.DefaultQuery("per_page", "50"))
|
||||
if perPage <= 0 {
|
||||
perPage = 50
|
||||
}
|
||||
return page, perPage
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// SecretProvider is the interface the factory uses to look up a GitLab PAT.
|
||||
type SecretProvider interface {
|
||||
List(ctx context.Context) ([]*SecretListItem, error)
|
||||
Reveal(ctx context.Context, id string) (string, error)
|
||||
}
|
||||
|
||||
// SecretListItem mirrors secrets.SecretListItem to avoid a direct import cycle.
|
||||
type SecretListItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HasValue bool `json:"has_value"`
|
||||
}
|
||||
|
||||
// NewClient builds a GitLab client using the best available auth method.
|
||||
// Selection order: mock env var → glab CLI (if authenticated for host) →
|
||||
// PAT (env var GITLAB_TOKEN, then secrets store name GITLAB_TOKEN /
|
||||
// gitlab_token) → noop.
|
||||
//
|
||||
// host configures the GitLab base URL; pass "" for the public default.
|
||||
func NewClient(ctx context.Context, host string, secrets SecretProvider, log *logger.Logger) (Client, string, error) {
|
||||
if host == "" {
|
||||
host = DefaultHost
|
||||
}
|
||||
|
||||
if os.Getenv("KANDEV_MOCK_GITLAB") == "true" {
|
||||
log.Info("using mock client for GitLab integration")
|
||||
return NewMockClient(host), "mock", nil
|
||||
}
|
||||
|
||||
if GLabAvailable() {
|
||||
client, err := NewGLabClient(ctx, host)
|
||||
if err == nil {
|
||||
log.Info("using glab CLI for GitLab integration", zap.String("host", host))
|
||||
return client, AuthMethodGLab, nil
|
||||
}
|
||||
log.Debug("glab CLI available but not authenticated", zap.Error(err))
|
||||
}
|
||||
|
||||
if token := os.Getenv("GITLAB_TOKEN"); token != "" {
|
||||
log.Info("using GITLAB_TOKEN from environment for GitLab integration")
|
||||
return NewPATClient(host, token), AuthMethodPAT, nil
|
||||
}
|
||||
|
||||
if secrets != nil {
|
||||
token, err := findPAT(ctx, secrets)
|
||||
if err == nil && token != "" {
|
||||
log.Info("using PAT from secrets store for GitLab integration")
|
||||
return NewPATClient(host, token), AuthMethodPAT, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Debug("failed to find GitLab PAT in secrets", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return NewNoopClient(host), AuthMethodNone, nil
|
||||
}
|
||||
|
||||
// findPAT looks for a secret named "GITLAB_TOKEN" or "gitlab_token".
|
||||
func findPAT(ctx context.Context, secrets SecretProvider) (string, error) {
|
||||
items, err := secrets.List(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list secrets: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if !item.HasValue {
|
||||
continue
|
||||
}
|
||||
if item.Name == "GITLAB_TOKEN" || item.Name == "gitlab_token" {
|
||||
return secrets.Reveal(ctx, item.ID)
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// newTestLogger builds a quiet logger for tests that exercise the factory.
|
||||
func newTestLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
return log
|
||||
}
|
||||
|
||||
type stubSecrets struct {
|
||||
items []*SecretListItem
|
||||
tokens map[string]string
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (s *stubSecrets) List(context.Context) ([]*SecretListItem, error) {
|
||||
if s.listErr != nil {
|
||||
return nil, s.listErr
|
||||
}
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func (s *stubSecrets) Reveal(_ context.Context, id string) (string, error) {
|
||||
return s.tokens[id], nil
|
||||
}
|
||||
|
||||
func TestNewClient_MockEnvShortcircuits(t *testing.T) {
|
||||
t.Setenv("KANDEV_MOCK_GITLAB", "true")
|
||||
log := newTestLogger(t)
|
||||
c, method, err := NewClient(context.Background(), "", nil, log)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, ok := c.(*MockClient); !ok {
|
||||
t.Fatalf("got %T, want *MockClient", c)
|
||||
}
|
||||
if method != "mock" {
|
||||
t.Errorf("method = %q, want mock", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_EnvTokenBeatsSecrets(t *testing.T) {
|
||||
t.Setenv("KANDEV_MOCK_GITLAB", "")
|
||||
t.Setenv("GITLAB_TOKEN", "from-env")
|
||||
log := newTestLogger(t)
|
||||
|
||||
secrets := &stubSecrets{
|
||||
items: []*SecretListItem{{ID: "s1", Name: "GITLAB_TOKEN", HasValue: true}},
|
||||
tokens: map[string]string{"s1": "from-secret"},
|
||||
}
|
||||
c, method, err := NewClient(context.Background(), "", secrets, log)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
pat, ok := c.(*PATClient)
|
||||
if !ok {
|
||||
// glab might be installed on the test runner — accept GLab as well
|
||||
// only when the env-var path is unreachable. The branch under test
|
||||
// here is env-var, so explicitly assert PATClient.
|
||||
if _, isGLab := c.(*GLabClient); isGLab {
|
||||
t.Skip("glab CLI present on test host; env-var branch is shadowed")
|
||||
}
|
||||
t.Fatalf("got %T, want *PATClient", c)
|
||||
}
|
||||
if pat.token != "from-env" {
|
||||
t.Errorf("token = %q, want from-env", pat.token)
|
||||
}
|
||||
if method != AuthMethodPAT {
|
||||
t.Errorf("method = %q, want pat", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_FallsBackToSecrets(t *testing.T) {
|
||||
t.Setenv("KANDEV_MOCK_GITLAB", "")
|
||||
t.Setenv("GITLAB_TOKEN", "")
|
||||
log := newTestLogger(t)
|
||||
|
||||
secrets := &stubSecrets{
|
||||
items: []*SecretListItem{{ID: "s1", Name: "GITLAB_TOKEN", HasValue: true}},
|
||||
tokens: map[string]string{"s1": "from-secret"},
|
||||
}
|
||||
c, _, err := NewClient(context.Background(), "https://gitlab.example.com", secrets, log)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, ok := c.(*PATClient); !ok {
|
||||
if _, isGLab := c.(*GLabClient); isGLab {
|
||||
t.Skip("glab CLI present on test host; secret-store branch is shadowed")
|
||||
}
|
||||
t.Fatalf("got %T, want *PATClient", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_NoopWhenUnconfigured(t *testing.T) {
|
||||
t.Setenv("KANDEV_MOCK_GITLAB", "")
|
||||
t.Setenv("GITLAB_TOKEN", "")
|
||||
log := newTestLogger(t)
|
||||
|
||||
c, method, err := NewClient(context.Background(), "", nil, log)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, ok := c.(*NoopClient); !ok {
|
||||
if _, isGLab := c.(*GLabClient); isGLab {
|
||||
t.Skip("glab CLI present on test host; noop branch is shadowed")
|
||||
}
|
||||
t.Fatalf("got %T, want *NoopClient", c)
|
||||
}
|
||||
if method != AuthMethodNone {
|
||||
t.Errorf("method = %q, want none", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_AcceptsLowercaseSecretName(t *testing.T) {
|
||||
t.Setenv("KANDEV_MOCK_GITLAB", "")
|
||||
t.Setenv("GITLAB_TOKEN", "")
|
||||
log := newTestLogger(t)
|
||||
|
||||
secrets := &stubSecrets{
|
||||
items: []*SecretListItem{{ID: "s2", Name: "gitlab_token", HasValue: true}},
|
||||
tokens: map[string]string{"s2": "lc"},
|
||||
}
|
||||
c, _, err := NewClient(context.Background(), "", secrets, log)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, ok := c.(*PATClient); !ok {
|
||||
if _, isGLab := c.(*GLabClient); isGLab {
|
||||
t.Skip("glab CLI present on test host; secret-store branch is shadowed")
|
||||
}
|
||||
t.Fatalf("got %T, want *PATClient", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_IgnoresSecretsWithoutValue(t *testing.T) {
|
||||
t.Setenv("KANDEV_MOCK_GITLAB", "")
|
||||
t.Setenv("GITLAB_TOKEN", "")
|
||||
log := newTestLogger(t)
|
||||
|
||||
secrets := &stubSecrets{
|
||||
items: []*SecretListItem{{ID: "s3", Name: "GITLAB_TOKEN", HasValue: false}},
|
||||
}
|
||||
c, _, err := NewClient(context.Background(), "", secrets, log)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, ok := c.(*NoopClient); !ok {
|
||||
if _, isGLab := c.(*GLabClient); isGLab {
|
||||
t.Skip("glab CLI present on test host; noop branch is shadowed")
|
||||
}
|
||||
t.Fatalf("got %T, want *NoopClient (HasValue=false should be skipped)", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// glabAuthTimeout bounds glab subprocess execution.
|
||||
const glabAuthTimeout = 10 * time.Second
|
||||
|
||||
// GLabClient implements Client by piggy-backing on the user's glab CLI
|
||||
// configuration. It discovers the host and token via `glab auth status`,
|
||||
// then delegates every API call to an embedded PATClient.
|
||||
//
|
||||
// Versus a pure shell-out client, this approach is simpler (no per-command
|
||||
// JSON parsing) and stays consistent with the REST surface that
|
||||
// PATClient exercises in tests.
|
||||
type GLabClient struct {
|
||||
*PATClient
|
||||
version string
|
||||
}
|
||||
|
||||
// GLabAvailable checks if the glab CLI is installed and on PATH.
|
||||
func GLabAvailable() bool {
|
||||
_, err := exec.LookPath("glab")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// NewGLabClient discovers glab's configured host and token for the given
|
||||
// targetHost (or the default if empty), and returns a Client that signs
|
||||
// requests with that token. Returns an error when glab is not available
|
||||
// or not authenticated for targetHost.
|
||||
func NewGLabClient(ctx context.Context, targetHost string) (*GLabClient, error) {
|
||||
if !GLabAvailable() {
|
||||
return nil, errors.New("glab CLI not installed")
|
||||
}
|
||||
host := targetHost
|
||||
if host == "" {
|
||||
host = DefaultHost
|
||||
}
|
||||
hostname := stripScheme(host)
|
||||
|
||||
token, err := glabReadToken(ctx, hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("glab not authenticated for %s", hostname)
|
||||
}
|
||||
pat := NewPATClient(host, token)
|
||||
return &GLabClient{
|
||||
PATClient: pat,
|
||||
version: glabVersion(ctx),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Version reports the glab CLI version (best effort, "" if unavailable).
|
||||
func (c *GLabClient) Version() string { return c.version }
|
||||
|
||||
// RunAuthDiagnostics executes `glab auth status` and captures the raw
|
||||
// output for troubleshooting. Mirrors GitHub's RunAuthDiagnostics.
|
||||
func (c *GLabClient) RunAuthDiagnostics(ctx context.Context) *AuthDiagnostics {
|
||||
cctx, cancel := context.WithTimeout(ctx, glabAuthTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "glab", "auth", "status", "--hostname", stripScheme(c.Host()))
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
exitCode := 0
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = -1
|
||||
}
|
||||
}
|
||||
output := stderr.String()
|
||||
if output == "" {
|
||||
output = stdout.String()
|
||||
}
|
||||
return &AuthDiagnostics{
|
||||
Command: "glab auth status --hostname " + stripScheme(c.Host()),
|
||||
Output: output,
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
}
|
||||
|
||||
// glabReadToken extracts the token glab uses for the given hostname. glab's
|
||||
// `auth status -t` prints the token to stderr; this function captures both
|
||||
// streams and parses the line "Token: <value>".
|
||||
func glabReadToken(ctx context.Context, hostname string) (string, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, glabAuthTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "glab", "auth", "status", "--hostname", hostname, "-t")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Even when glab exits non-zero we may still have a token printed —
|
||||
// e.g. a different account on the same host triggers a warning. Try
|
||||
// to parse before giving up.
|
||||
token := parseGlabToken(stdout.String() + "\n" + stderr.String())
|
||||
if token != "" {
|
||||
return token, nil
|
||||
}
|
||||
return "", fmt.Errorf("glab auth status: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return parseGlabToken(stdout.String() + "\n" + stderr.String()), nil
|
||||
}
|
||||
|
||||
// parseGlabToken finds a "Token: <value>" line in the combined output of
|
||||
// `glab auth status -t` and returns the token (or "" if none).
|
||||
func parseGlabToken(output string) string {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
idx := strings.Index(strings.ToLower(line), "token:")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
token := strings.TrimSpace(line[idx+len("token:"):])
|
||||
// glab sometimes prefixes lines with ANSI / arrows; strip leading
|
||||
// non-alphanumerics until we hit token characters.
|
||||
token = strings.TrimLeft(token, " \t-→>")
|
||||
if token != "" && token != "<no token>" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// glabVersion runs `glab --version` and extracts the semver string.
|
||||
// Returns "" on any failure.
|
||||
func glabVersion(ctx context.Context) string {
|
||||
cctx, cancel := context.WithTimeout(ctx, glabAuthTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(cctx, "glab", "--version").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, field := range strings.Fields(string(out)) {
|
||||
if strings.Count(field, ".") >= 2 {
|
||||
return strings.TrimSpace(field)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stripScheme(host string) string {
|
||||
host = strings.TrimPrefix(host, "https://")
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
return strings.TrimRight(host, "/")
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package gitlab
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseGlabToken_ExtractsValue(t *testing.T) {
|
||||
output := `Logged in to gitlab.com as alice (oauth_token)
|
||||
✓ Token: glpat-AAAAA-BBBBB
|
||||
✓ Token scopes: api`
|
||||
got := parseGlabToken(output)
|
||||
if got != "glpat-AAAAA-BBBBB" {
|
||||
t.Errorf("got %q, want glpat-AAAAA-BBBBB", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlabToken_LowercaseLabel(t *testing.T) {
|
||||
got := parseGlabToken("token: glpat-xyz")
|
||||
if got != "glpat-xyz" {
|
||||
t.Errorf("got %q, want glpat-xyz", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlabToken_NoToken(t *testing.T) {
|
||||
got := parseGlabToken("Token: <no token>")
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlabToken_Empty(t *testing.T) {
|
||||
if got := parseGlabToken(""); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripScheme(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://gitlab.com": "gitlab.com",
|
||||
"http://gitlab.acme.corp": "gitlab.acme.corp",
|
||||
"https://gitlab.com/": "gitlab.com",
|
||||
"gitlab.example.com": "gitlab.example.com",
|
||||
"https://gitlab.example.com/": "gitlab.example.com",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := stripScheme(in); got != want {
|
||||
t.Errorf("stripScheme(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoClient is returned by NoopClient methods that cannot provide
|
||||
// meaningful data.
|
||||
var ErrNoClient = errors.New("gitlab client not configured")
|
||||
|
||||
// NoopClient is a GitLab client that returns empty results for all
|
||||
// operations. Used when GitLab integration is not configured.
|
||||
type NoopClient struct {
|
||||
host string
|
||||
}
|
||||
|
||||
// NewNoopClient builds a NoopClient that reports the given host (purely
|
||||
// informational; no requests are issued).
|
||||
func NewNoopClient(host string) *NoopClient {
|
||||
if host == "" {
|
||||
host = DefaultHost
|
||||
}
|
||||
return &NoopClient{host: host}
|
||||
}
|
||||
|
||||
func (c *NoopClient) IsAuthenticated(context.Context) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (c *NoopClient) GetAuthenticatedUser(context.Context) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (c *NoopClient) Host() string { return c.host }
|
||||
|
||||
func (c *NoopClient) GetMR(context.Context, string, int) (*MR, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) FindMRByBranch(context.Context, string, string) (*MR, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListAuthoredMRs(context.Context, string) ([]*MR, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListReviewRequestedMRs(context.Context, string, string) ([]*MR, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListUserGroups(context.Context) ([]Group, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) SearchGroupProjects(context.Context, string, string, int) ([]Project, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListMRApprovals(context.Context, string, int) ([]MRApproval, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListMRDiscussions(context.Context, string, int, *time.Time) ([]MRDiscussion, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) CreateMRDiscussionNote(context.Context, string, int, string, string) (*MRNote, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ResolveMRDiscussion(context.Context, string, int, string) error {
|
||||
return ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListPipelines(context.Context, string, string) ([]Pipeline, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) GetMRFeedback(context.Context, string, int) (*MRFeedback, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) GetMRStatus(context.Context, string, int) (*MRStatus, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListMRFiles(context.Context, string, int) ([]MRFile, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListMRCommits(context.Context, string, int) ([]MRCommitInfo, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) SubmitMRApproval(context.Context, string, int) error {
|
||||
return ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) SubmitMRUnapproval(context.Context, string, int) error {
|
||||
return ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) CreateMR(context.Context, string, string, string, string, string, bool) (*MR, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListProjectBranches(context.Context, string) ([]RepoBranch, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) SearchMRs(context.Context, string, string) ([]*MR, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) SearchMRsPaged(context.Context, string, string, int, int) (*MRSearchPage, error) {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
|
||||
func (c *NoopClient) GetIssueState(context.Context, string, int) (string, error) {
|
||||
return "", ErrNoClient
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNoopClient_SatisfiesClient(t *testing.T) {
|
||||
var _ Client = (*NoopClient)(nil)
|
||||
}
|
||||
|
||||
func TestNoopClient_IsAuthenticated_ReportsUnauthenticated(t *testing.T) {
|
||||
c := NewNoopClient("")
|
||||
ok, err := c.IsAuthenticated(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsAuthenticated err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("IsAuthenticated = true, want false for noop client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopClient_Host_FallsBackToDefault(t *testing.T) {
|
||||
c := NewNoopClient("")
|
||||
if got := c.Host(); got != DefaultHost {
|
||||
t.Fatalf("Host = %q, want %q", got, DefaultHost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopClient_Host_HonoursOverride(t *testing.T) {
|
||||
c := NewNoopClient("https://gitlab.acme.corp")
|
||||
if got := c.Host(); got != "https://gitlab.acme.corp" {
|
||||
t.Fatalf("Host = %q, want override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopClient_DataMethods_ReturnErrNoClient(t *testing.T) {
|
||||
c := NewNoopClient("")
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"GetMR", mustErr2(c.GetMR(ctx, "g/p", 1))},
|
||||
{"FindMRByBranch", mustErr2(c.FindMRByBranch(ctx, "g/p", "feat"))},
|
||||
{"ListAuthoredMRs", mustErr2(c.ListAuthoredMRs(ctx, "g/p"))},
|
||||
{"ListReviewRequestedMRs", mustErr2(c.ListReviewRequestedMRs(ctx, "", ""))},
|
||||
{"ListMRApprovals", mustErr2(c.ListMRApprovals(ctx, "g/p", 1))},
|
||||
{"ListMRDiscussions", mustErr2(c.ListMRDiscussions(ctx, "g/p", 1, nil))},
|
||||
{"CreateMRDiscussionNote", mustErr2(c.CreateMRDiscussionNote(ctx, "g/p", 1, "d", "body"))},
|
||||
{"ResolveMRDiscussion", c.ResolveMRDiscussion(ctx, "g/p", 1, "d")},
|
||||
{"ListPipelines", mustErr2(c.ListPipelines(ctx, "g/p", "main"))},
|
||||
{"GetMRFeedback", mustErr2(c.GetMRFeedback(ctx, "g/p", 1))},
|
||||
{"GetMRStatus", mustErr2(c.GetMRStatus(ctx, "g/p", 1))},
|
||||
{"SubmitMRApproval", c.SubmitMRApproval(ctx, "g/p", 1)},
|
||||
{"SubmitMRUnapproval", c.SubmitMRUnapproval(ctx, "g/p", 1)},
|
||||
{"CreateMR", mustErr2(c.CreateMR(ctx, "g/p", "src", "dst", "t", "b", false))},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if !errors.Is(tc.err, ErrNoClient) {
|
||||
t.Fatalf("err = %v, want ErrNoClient", tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mustErr2 extracts the error from a (T, error) pair.
|
||||
func mustErr2[T any](_ T, err error) error { return err }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestServer wires a handler under /api/v4/* and returns the host URL.
|
||||
func newTestServer(t *testing.T, handler http.Handler) (host string, teardown func()) {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/v4/", http.StripPrefix("/api/v4", handler))
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv.URL, srv.Close
|
||||
}
|
||||
|
||||
func TestPATClient_GetAuthenticatedUser(t *testing.T) {
|
||||
calls := 0
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.URL.Path != "/user" {
|
||||
t.Errorf("path = %q, want /user", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("PRIVATE-TOKEN") != "tok" {
|
||||
t.Errorf("PRIVATE-TOKEN header = %q, want tok", r.Header.Get("PRIVATE-TOKEN"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"username": "alice"})
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
user, err := c.GetAuthenticatedUser(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if user != "alice" {
|
||||
t.Fatalf("user = %q, want alice", user)
|
||||
}
|
||||
// Cached on second call — the server must not be hit again. A non-
|
||||
// caching implementation would still pass an err-only assertion, so
|
||||
// the request counter is what actually proves the cache.
|
||||
if _, err := c.GetAuthenticatedUser(context.Background()); err != nil {
|
||||
t.Fatalf("cached call err = %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("server called %d times, want 1 (second call must hit cache)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_GetAuthenticatedUser_AuthFailure(t *testing.T) {
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"message":"401 Unauthorized"}`))
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "bad")
|
||||
_, err := c.GetAuthenticatedUser(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("err = %v, want *APIError", err)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", apiErr.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_GetMR(t *testing.T) {
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// EscapedPath preserves %2F; r.URL.Path is the decoded form.
|
||||
if got, want := r.URL.EscapedPath(), "/projects/group%2Fproject/merge_requests/42"; got != want {
|
||||
t.Errorf("escaped path = %q, want %q (URL-encoded slash)", got, want)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rawMR{
|
||||
ID: 1, IID: 42, ProjectID: 7,
|
||||
Title: "Add feature", State: "opened",
|
||||
SourceBranch: "feat/x", TargetBranch: "main",
|
||||
SHA: "abc123",
|
||||
Author: rawUser{Username: "alice"},
|
||||
References: struct {
|
||||
Full string `json:"full"`
|
||||
}{Full: "group/project!42"},
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
})
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
mr, err := c.GetMR(context.Background(), "group/project", 42)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if mr.IID != 42 {
|
||||
t.Errorf("iid = %d, want 42", mr.IID)
|
||||
}
|
||||
if mr.State != "open" {
|
||||
t.Errorf("state = %q, want open (normalized from opened)", mr.State)
|
||||
}
|
||||
if mr.HeadBranch != "feat/x" || mr.BaseBranch != "main" {
|
||||
t.Errorf("branches = %q/%q, want feat/x/main", mr.HeadBranch, mr.BaseBranch)
|
||||
}
|
||||
if mr.AuthorUsername != "alice" {
|
||||
t.Errorf("author = %q, want alice", mr.AuthorUsername)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_FindMRByBranch_Empty(t *testing.T) {
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
mr, err := c.FindMRByBranch(context.Background(), "g/p", "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if mr != nil {
|
||||
t.Fatalf("mr = %+v, want nil", mr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_CreateMR_DraftPrefix(t *testing.T) {
|
||||
var receivedBody map[string]any
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
_ = json.NewEncoder(w).Encode(rawMR{IID: 7, Title: "Draft: feat", State: "opened"})
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
mr, err := c.CreateMR(context.Background(), "g/p", "feat", "main", "feat", "body", true)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if mr.IID != 7 {
|
||||
t.Errorf("iid = %d, want 7", mr.IID)
|
||||
}
|
||||
if got, want := receivedBody["title"], "Draft: feat"; got != want {
|
||||
t.Errorf("title sent = %v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_CreateMR_NoDoubleDraftPrefix(t *testing.T) {
|
||||
var receivedBody map[string]any
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
|
||||
_ = json.NewEncoder(w).Encode(rawMR{IID: 8, State: "opened"})
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
if _, err := c.CreateMR(context.Background(), "g/p", "feat", "main", "Draft: already", "", true); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if got := receivedBody["title"].(string); strings.Count(got, "Draft:") != 1 {
|
||||
t.Errorf("title = %q, expected single Draft: prefix", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_ResolveDiscussion_UsesPUT(t *testing.T) {
|
||||
called := false
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
if r.Method != http.MethodPut {
|
||||
t.Errorf("method = %s, want PUT", r.Method)
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "resolved=true") {
|
||||
t.Errorf("query = %q, want resolved=true", r.URL.RawQuery)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
if err := c.ResolveMRDiscussion(context.Background(), "g/p", 1, "abc123"); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("server was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_ListMRDiscussions_ConvertsThread(t *testing.T) {
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{
|
||||
"id": "thread-1",
|
||||
"notes": [
|
||||
{"id":1,"body":"first","resolvable":true,"resolved":false,"author":{"username":"alice"},"created_at":"2026-05-01T00:00:00Z","updated_at":"2026-05-01T00:00:00Z"},
|
||||
{"id":2,"body":"reply","author":{"username":"bob"},"created_at":"2026-05-02T00:00:00Z","updated_at":"2026-05-02T00:00:00Z"}
|
||||
]
|
||||
}]`))
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
d, err := c.ListMRDiscussions(context.Background(), "g/p", 1, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(d) != 1 {
|
||||
t.Fatalf("got %d discussions, want 1", len(d))
|
||||
}
|
||||
if !d[0].Resolvable {
|
||||
t.Error("resolvable = false, want true (taken from first note)")
|
||||
}
|
||||
if d[0].Resolved {
|
||||
t.Error("resolved = true, want false")
|
||||
}
|
||||
if len(d[0].Notes) != 2 {
|
||||
t.Fatalf("notes = %d, want 2", len(d[0].Notes))
|
||||
}
|
||||
wantUpdate := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)
|
||||
if !d[0].UpdatedAt.Equal(wantUpdate) {
|
||||
t.Errorf("updated_at = %v, want %v (latest reply)", d[0].UpdatedAt, wantUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_ListMRDiscussions_FiltersBySince(t *testing.T) {
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":"old","notes":[{"id":1,"created_at":"2026-04-01T00:00:00Z","updated_at":"2026-04-01T00:00:00Z"}]},
|
||||
{"id":"new","notes":[{"id":2,"created_at":"2026-05-15T00:00:00Z","updated_at":"2026-05-15T00:00:00Z"}]}
|
||||
]`))
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
since := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
c := NewPATClient(host, "tok")
|
||||
d, err := c.ListMRDiscussions(context.Background(), "g/p", 1, &since)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(d) != 1 || d[0].ID != "new" {
|
||||
t.Fatalf("got %#v, want only the 'new' discussion", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_SearchMRsPaged_HonoursTotalHeader(t *testing.T) {
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Total", "153")
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page == 1 {
|
||||
_, _ = w.Write([]byte(`[{"iid":1,"state":"opened","author":{"username":"a"},"references":{"full":"g/p!1"}}]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
page, err := c.SearchMRsPaged(context.Background(), "", "", 1, 25)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if page.TotalCount != 153 {
|
||||
t.Errorf("total = %d, want 153", page.TotalCount)
|
||||
}
|
||||
if len(page.MRs) != 1 {
|
||||
t.Errorf("mrs = %d, want 1", len(page.MRs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_ListIssuesPaged_HonoursTotalHeader(t *testing.T) {
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// X-Total drives pagination; the body returns a small slice so the
|
||||
// test verifies header + body wire up independently.
|
||||
w.Header().Set("X-Total", "87")
|
||||
if r.URL.Path != "/issues" {
|
||||
// newTestServer strips the /api/v4 prefix before dispatch.
|
||||
t.Errorf("path = %q, want /issues", r.URL.Path)
|
||||
}
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
perPage := r.URL.Query().Get("per_page")
|
||||
if perPage != "20" {
|
||||
t.Errorf("per_page = %q, want 20 — paging args must reach the server", perPage)
|
||||
}
|
||||
if page == 2 {
|
||||
_, _ = w.Write([]byte(`[{"iid":42,"state":"opened","author":{"username":"alice"},"references":{"full":"g/p#42"}}]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
page, err := c.ListIssuesPaged(context.Background(), "", "", 2, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if page.TotalCount != 87 {
|
||||
t.Errorf("total = %d, want 87 (X-Total header)", page.TotalCount)
|
||||
}
|
||||
if page.Page != 2 || page.PerPage != 20 {
|
||||
t.Errorf("page/perPage = %d/%d, want 2/20 (echoed back)", page.Page, page.PerPage)
|
||||
}
|
||||
if len(page.Issues) != 1 || page.Issues[0].IID != 42 {
|
||||
t.Errorf("issues = %+v, want one issue with IID 42", page.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNextLink(t *testing.T) {
|
||||
apiBase := "https://gitlab.example.com/api/v4"
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"next", `<https://gitlab.example.com/api/v4/issues?page=2>; rel="next"`, "/issues?page=2"},
|
||||
{"prev-only", `<https://gitlab.example.com/api/v4/issues?page=2>; rel="prev"`, ""},
|
||||
{"next-then-last", `<https://gitlab.example.com/api/v4/issues?page=3>; rel="next", <https://gitlab.example.com/api/v4/issues?page=10>; rel="last"`, "/issues?page=3"},
|
||||
// Cross-host or wrong-base URLs are dropped rather than returned
|
||||
// raw — passing a bare absolute URL back to get() would double-
|
||||
// prefix it with host + apiPathPrefix.
|
||||
{"foreign-host-dropped", `<https://other.example.com/api/v4/issues?page=2>; rel="next"`, ""},
|
||||
{"no-apibase-dropped", `</issues?page=2>; rel="next"`, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parseNextLink(tc.header, apiBase)
|
||||
if got != tc.want {
|
||||
t.Errorf("parseNextLink(%q) = %q, want %q", tc.header, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_IsAuthenticated_PropagatesNonAuthErrors(t *testing.T) {
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
ok, err := c.IsAuthenticated(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response (transport failure must not be reported as 'not authenticated')")
|
||||
}
|
||||
if ok {
|
||||
t.Error("authenticated = true on 500, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATClient_IsAuthenticated_401IsClean(t *testing.T) {
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "bad")
|
||||
ok, err := c.IsAuthenticated(context.Background())
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil for 401 (it's a clean 'not authenticated' signal)", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("authenticated = true on 401, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: if a token gets revoked upstream, IsAuthenticated must surface
|
||||
// the failure even though the username was previously cached. The previous
|
||||
// implementation called GetAuthenticatedUser, which short-circuited on the
|
||||
// cache and made revoked tokens appear connected forever.
|
||||
func TestPATClient_IsAuthenticated_DoesNotTrustStaleCache(t *testing.T) {
|
||||
calls := 0
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
_, _ = w.Write([]byte(`{"username":"alice"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
// Warm the cache.
|
||||
if _, err := c.GetAuthenticatedUser(context.Background()); err != nil {
|
||||
t.Fatalf("warmup err = %v", err)
|
||||
}
|
||||
ok, err := c.IsAuthenticated(context.Background())
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil for 401", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("authenticated = true after upstream revoked token, want false (cache must not mask revocation)")
|
||||
}
|
||||
}
|
||||
|
||||
// SearchGroupProjects must %2F-encode slashes in subgroup paths so the
|
||||
// resulting URL is /groups/acme%2Fteam/projects, not /groups/acme/team/projects
|
||||
// (which GitLab routes to the parent only and returns 404 for). Without this
|
||||
// regression test a future refactor that switched groupRef back to a bare
|
||||
// url.PathEscape would silently break subgroup lookups.
|
||||
func TestPATClient_SearchGroupProjects_EncodesSubgroupSlash(t *testing.T) {
|
||||
var receivedPath string
|
||||
host, stop := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// EscapedPath() preserves %2F; r.URL.Path is the decoded form.
|
||||
receivedPath = r.URL.EscapedPath()
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer stop()
|
||||
|
||||
c := NewPATClient(host, "tok")
|
||||
if _, err := c.SearchGroupProjects(context.Background(), "acme/team", "", 5); err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
// newTestServer mounts handlers under /api/v4/* via StripPrefix, so the
|
||||
// handler sees the path without the /api/v4 prefix.
|
||||
want := "/groups/acme%2Fteam/projects"
|
||||
if receivedPath != want {
|
||||
t.Errorf("escaped path = %q, want %q (subgroup slash must be %%2F-encoded)", receivedPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMRState(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"opened": "open",
|
||||
"closed": "closed",
|
||||
"merged": "merged",
|
||||
"locked": "locked",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeMRState(in); got != want {
|
||||
t.Errorf("normalizeMRState(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFullReference(t *testing.T) {
|
||||
// projectPath is the full namespace-qualified path so callers can pass
|
||||
// it straight to projectRef. namespace is everything before the final
|
||||
// "/" — same shape as Project.Namespace.
|
||||
cases := []struct {
|
||||
in string
|
||||
wantNamespace string
|
||||
wantPath string
|
||||
}{
|
||||
{"group/project!42", "group", "group/project"},
|
||||
{"group/sub/project!7", "group/sub", "group/sub/project"},
|
||||
{"group/project#10", "group", "group/project"},
|
||||
{"noslash", "", ""},
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ns, p := splitFullReference(tc.in)
|
||||
if ns != tc.wantNamespace || p != tc.wantPath {
|
||||
t.Errorf("splitFullReference(%q) = (%q, %q), want (%q, %q)", tc.in, ns, p, tc.wantNamespace, tc.wantPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizePipelines(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input []Pipeline
|
||||
state string
|
||||
expectsTotal bool
|
||||
}{
|
||||
{"empty", nil, "", false},
|
||||
{"success", []Pipeline{{Status: "success", JobsTotal: 3, JobsPassing: 3}}, "success", true},
|
||||
{"failed", []Pipeline{{Status: "failed"}}, "failure", false},
|
||||
{"running", []Pipeline{{Status: "running"}}, "pending", false},
|
||||
{"skipped-suppressed", []Pipeline{{Status: "skipped"}}, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
state, total, _ := summarizePipelines(tc.input)
|
||||
if state != tc.state {
|
||||
t.Errorf("state = %q, want %q", state, tc.state)
|
||||
}
|
||||
if tc.expectsTotal && total == 0 {
|
||||
t.Error("expected jobs total > 0")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeApprovals(t *testing.T) {
|
||||
cases := []struct {
|
||||
have, required int
|
||||
want string
|
||||
}{
|
||||
{0, 0, ""},
|
||||
{1, 0, "approved"},
|
||||
{2, 2, "approved"},
|
||||
{1, 2, "pending"},
|
||||
{0, 1, "pending"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := summarizeApprovals(tc.have, tc.required); got != tc.want {
|
||||
t.Errorf("summarizeApprovals(%d,%d) = %q, want %q", tc.have, tc.required, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountDiffLines(t *testing.T) {
|
||||
diff := "--- a/foo.go\n+++ b/foo.go\n@@ -1,3 +1,3 @@\n-old\n+new1\n+new2\n unchanged\n"
|
||||
add, del := countDiffLines(diff)
|
||||
if add != 2 {
|
||||
t.Errorf("additions = %d, want 2", add)
|
||||
}
|
||||
if del != 1 {
|
||||
t.Errorf("deletions = %d, want 1", del)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampSearchPage(t *testing.T) {
|
||||
cases := []struct{ inP, inPP, wantP, wantPP int }{
|
||||
{0, 0, 1, defaultPageSize},
|
||||
{-3, 5, 1, 5},
|
||||
{2, 250, 2, maxPageSize},
|
||||
{5, 30, 5, 30},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
p, pp := clampSearchPage(tc.inP, tc.inPP)
|
||||
if p != tc.wantP || pp != tc.wantPP {
|
||||
t.Errorf("clamp(%d,%d) = (%d,%d), want (%d,%d)", tc.inP, tc.inPP, p, pp, tc.wantP, tc.wantPP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
// secretNameToken is the canonical secret-store name for the GitLab PAT.
|
||||
const secretNameToken = "GITLAB_TOKEN"
|
||||
|
||||
// ErrInvalidToken is returned by ConfigureToken when the supplied token is
|
||||
// empty or fails the /user probe. The controller layer uses errors.Is to
|
||||
// route this to HTTP 400 instead of 500 — a sentinel is more durable than
|
||||
// string-matching against the error message, which would silently break
|
||||
// the status mapping if the message wording changes.
|
||||
var ErrInvalidToken = errors.New("invalid token")
|
||||
|
||||
// SecretManager handles secret create/update/delete for the token.
|
||||
type SecretManager interface {
|
||||
Create(ctx context.Context, name, value string) (id string, err error)
|
||||
Update(ctx context.Context, id, value string) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// HostStore persists the configured GitLab host.
|
||||
type HostStore interface {
|
||||
GetHost(ctx context.Context) (string, error)
|
||||
SetHost(ctx context.Context, host string) error
|
||||
}
|
||||
|
||||
// Service coordinates GitLab integration operations. v1 surface is
|
||||
// deliberately small: status, token configure/clear, host configure, MR
|
||||
// feedback fetch, and MR discussion reply/resolve. Watches, presets, and
|
||||
// stats are intentionally deferred to a follow-up.
|
||||
type Service struct {
|
||||
mu sync.RWMutex
|
||||
host string
|
||||
client Client
|
||||
authMethod string
|
||||
secrets SecretProvider
|
||||
secretManager SecretManager
|
||||
hostStore HostStore
|
||||
store *Store
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// SetStore wires the task↔MR persistence layer. Optional — when nil the
|
||||
// task-mr endpoints return empty results and SyncTaskMR is a no-op.
|
||||
func (s *Service) SetStore(store *Store) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.store = store
|
||||
}
|
||||
|
||||
// NewService builds a Service from an already-resolved client. Callers
|
||||
// typically use Provide() instead of constructing this directly.
|
||||
func NewService(host string, client Client, authMethod string, secrets SecretProvider, log *logger.Logger) *Service {
|
||||
if host == "" {
|
||||
host = DefaultHost
|
||||
}
|
||||
return &Service{
|
||||
host: host,
|
||||
client: client,
|
||||
authMethod: authMethod,
|
||||
secrets: secrets,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSecretManager wires the secret-write dependency.
|
||||
func (s *Service) SetSecretManager(m SecretManager) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.secretManager = m
|
||||
}
|
||||
|
||||
// SetHostStore wires the host-persistence dependency.
|
||||
func (s *Service) SetHostStore(h HostStore) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.hostStore = h
|
||||
}
|
||||
|
||||
// Client returns the current underlying Client (may be a NoopClient).
|
||||
func (s *Service) Client() Client {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.client
|
||||
}
|
||||
|
||||
// Host returns the configured GitLab base URL.
|
||||
func (s *Service) Host() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.host
|
||||
}
|
||||
|
||||
// GetStatus returns the connection status surfaced to the frontend.
|
||||
func (s *Service) GetStatus(ctx context.Context) (*Status, error) {
|
||||
s.mu.RLock()
|
||||
client := s.client
|
||||
host := s.host
|
||||
authMethod := s.authMethod
|
||||
s.mu.RUnlock()
|
||||
|
||||
tokenConfigured, tokenSecretID, err := s.findTokenSecret(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("look up token secret: %w", err)
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
// Defensive — every Provide/NewClient path returns at least a
|
||||
// NoopClient, so in practice this branch is unreachable. Keep
|
||||
// RequiredScopes as an empty slice so the JSON contract matches
|
||||
// the always-an-array shape declared on the TypeScript side.
|
||||
return &Status{
|
||||
AuthMethod: AuthMethodNone,
|
||||
Host: host,
|
||||
TokenConfigured: tokenConfigured,
|
||||
TokenSecretID: tokenSecretID,
|
||||
RequiredScopes: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
authenticated, authErr := client.IsAuthenticated(ctx)
|
||||
username := ""
|
||||
if authenticated {
|
||||
username, _ = client.GetAuthenticatedUser(ctx)
|
||||
}
|
||||
|
||||
status := &Status{
|
||||
Authenticated: authenticated,
|
||||
Username: username,
|
||||
AuthMethod: authMethod,
|
||||
Host: host,
|
||||
TokenConfigured: tokenConfigured,
|
||||
TokenSecretID: tokenSecretID,
|
||||
RequiredScopes: []string{"api", "read_user"},
|
||||
}
|
||||
if authErr != nil {
|
||||
// IsAuthenticated returns (false, nil) for 401/403 — that's a
|
||||
// known "bad token" signal. Anything reaching here is a transport
|
||||
// failure (network, 5xx, parse error) the user needs to see as
|
||||
// "GitLab unreachable" rather than "not connected", so they don't
|
||||
// delete a valid token during a transient outage.
|
||||
status.ConnectionError = authErr.Error()
|
||||
}
|
||||
if g, ok := client.(*GLabClient); ok {
|
||||
status.GLabVersion = g.Version()
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// ConfigureToken stores a new PAT in the secret manager and rebuilds the
|
||||
// client. Validates the token by calling /user before persisting.
|
||||
func (s *Service) ConfigureToken(ctx context.Context, token string) error {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return fmt.Errorf("%w: empty", ErrInvalidToken)
|
||||
}
|
||||
if s.secretManager == nil {
|
||||
return errors.New("secret manager not configured")
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
host := s.host
|
||||
s.mu.RUnlock()
|
||||
|
||||
probe := NewPATClient(host, token)
|
||||
if _, probeErr := probe.GetAuthenticatedUser(ctx); probeErr != nil {
|
||||
// Only 401 / 403 mean "the token is bad". Network failures, 5xx,
|
||||
// DNS errors etc. propagate without the ErrInvalidToken wrap so the
|
||||
// controller surfaces them as "GitLab unreachable" instead of
|
||||
// "invalid token" — otherwise during a GitLab outage a user might
|
||||
// delete their (valid) token assuming it had been rejected.
|
||||
var apiErr *APIError
|
||||
if errors.As(probeErr, &apiErr) && (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidToken, probeErr)
|
||||
}
|
||||
return fmt.Errorf("probe token: %w", probeErr)
|
||||
}
|
||||
|
||||
configured, secretID, err := s.findTokenSecret(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("look up token secret: %w", err)
|
||||
}
|
||||
switch {
|
||||
case configured && secretID != "":
|
||||
if err := s.secretManager.Update(ctx, secretID, token); err != nil {
|
||||
return fmt.Errorf("update token: %w", err)
|
||||
}
|
||||
default:
|
||||
if _, err := s.secretManager.Create(ctx, secretNameToken, token); err != nil {
|
||||
return fmt.Errorf("create token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the installed client inside the write lock using the *current*
|
||||
// s.host — if ConfigureHost ran between our snapshot above and now we'd
|
||||
// otherwise install a client pointing at the previous host, leaving
|
||||
// s.host and s.client desynced until the next reconfigure. The token
|
||||
// was validated against `probe`; here we just construct a fresh client
|
||||
// at the up-to-date host.
|
||||
s.mu.Lock()
|
||||
s.client = NewPATClient(s.host, token)
|
||||
s.authMethod = AuthMethodPAT
|
||||
installedHost := s.host
|
||||
s.mu.Unlock()
|
||||
// Log the host the installed client actually targets, not the
|
||||
// pre-validation snapshot — a concurrent ConfigureHost between the
|
||||
// two would otherwise leave the log entry referring to a stale value.
|
||||
s.logger.Info("GitLab token configured", zap.String("host", installedHost))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearToken removes the stored PAT and falls back to noop / glab.
|
||||
func (s *Service) ClearToken(ctx context.Context) error {
|
||||
if s.secretManager == nil {
|
||||
return errors.New("secret manager not configured")
|
||||
}
|
||||
configured, secretID, err := s.findTokenSecret(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("look up token secret: %w", err)
|
||||
}
|
||||
if !configured || secretID == "" {
|
||||
return nil
|
||||
}
|
||||
if err := s.secretManager.Delete(ctx, secretID); err != nil {
|
||||
return fmt.Errorf("delete token: %w", err)
|
||||
}
|
||||
|
||||
// Build the fallback client outside the write lock — NewClient may
|
||||
// shell out to `glab auth status` (up to glabAuthTimeout = 10s), and
|
||||
// holding the write lock for that long would freeze every concurrent
|
||||
// GetStatus / GetMRFeedback / Client() call. Pre-build against a
|
||||
// snapshot of s.host, then swap inside a short critical section.
|
||||
// Guard the swap with a host-equality check so a concurrent
|
||||
// ConfigureHost that already installed a newer client for a new
|
||||
// host doesn't get clobbered by our stale-host build.
|
||||
s.mu.RLock()
|
||||
hostSnap := s.host
|
||||
s.mu.RUnlock()
|
||||
client, authMethod, _ := NewClient(ctx, hostSnap, s.secrets, s.logger)
|
||||
s.mu.Lock()
|
||||
if s.host == hostSnap {
|
||||
s.client = client
|
||||
s.authMethod = authMethod
|
||||
}
|
||||
// else: ConfigureHost moved s.host while we were probing — leave
|
||||
// its (newer, correctly-targeted) client in place. The next
|
||||
// reconfigure or auth-health poller will converge if needed.
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigureHost persists a new GitLab host URL and rebuilds the client.
|
||||
// The host is normalized by stripping trailing slashes; an empty string
|
||||
// resets to DefaultHost.
|
||||
func (s *Service) ConfigureHost(ctx context.Context, host string) error {
|
||||
host = strings.TrimRight(strings.TrimSpace(host), "/")
|
||||
if host == "" {
|
||||
host = DefaultHost
|
||||
}
|
||||
if !strings.HasPrefix(host, "https://") && !strings.HasPrefix(host, "http://") {
|
||||
return errors.New("host must include scheme (https:// or http://)")
|
||||
}
|
||||
if err := CheckHost(ctx, host); err != nil {
|
||||
return fmt.Errorf("host unreachable: %w", err)
|
||||
}
|
||||
|
||||
// Persist before mutating the in-memory state so a failed write
|
||||
// doesn't leave the running service pointing at a host that wasn't
|
||||
// committed to disk.
|
||||
if s.hostStore != nil {
|
||||
if err := s.hostStore.SetHost(ctx, host); err != nil {
|
||||
return fmt.Errorf("persist host: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
client, authMethod, _ := NewClient(ctx, host, s.secrets, s.logger)
|
||||
s.mu.Lock()
|
||||
s.host = host
|
||||
s.client = client
|
||||
s.authMethod = authMethod
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMRFeedback proxies to the underlying client.
|
||||
func (s *Service) GetMRFeedback(ctx context.Context, projectPath string, iid int) (*MRFeedback, error) {
|
||||
return s.Client().GetMRFeedback(ctx, projectPath, iid)
|
||||
}
|
||||
|
||||
// CreateMRDiscussionNote proxies to the underlying client.
|
||||
func (s *Service) CreateMRDiscussionNote(ctx context.Context, projectPath string, iid int, discussionID, body string) (*MRNote, error) {
|
||||
return s.Client().CreateMRDiscussionNote(ctx, projectPath, iid, discussionID, body)
|
||||
}
|
||||
|
||||
// ResolveMRDiscussion proxies to the underlying client.
|
||||
func (s *Service) ResolveMRDiscussion(ctx context.Context, projectPath string, iid int, discussionID string) error {
|
||||
return s.Client().ResolveMRDiscussion(ctx, projectPath, iid, discussionID)
|
||||
}
|
||||
|
||||
// findTokenSecret reports whether a GitLab token is stored in the secret
|
||||
// store. Returns (configured, secretID, error). A nil secrets provider is
|
||||
// treated as "not configured" without error; a List failure is returned so
|
||||
// callers don't mistake a backend outage for "token absent" (which would
|
||||
// hide a still-present secret from ClearToken / GetStatus).
|
||||
func (s *Service) findTokenSecret(ctx context.Context) (bool, string, error) {
|
||||
if s.secrets == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
items, err := s.secrets.List(ctx)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("list secrets: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if !item.HasValue {
|
||||
continue
|
||||
}
|
||||
if item.Name == secretNameToken || item.Name == "gitlab_token" {
|
||||
return true, item.ID, nil
|
||||
}
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// --- Task ↔ MR association ---
|
||||
|
||||
// SyncTaskMR fetches the MR's current state from GitLab and upserts the
|
||||
// task↔MR row. Called from the orchestrator when an agent creates an MR via
|
||||
// the `pr` skill, and from the topbar's manual refresh. Returns the upserted
|
||||
// row so the caller can broadcast it on the WS bus.
|
||||
//
|
||||
// repositoryID is the task's repository UUID (empty for single-repo tasks).
|
||||
// projectPath is the GitLab namespace/path. iid is the MR's per-project id.
|
||||
func (s *Service) SyncTaskMR(ctx context.Context, taskID, repositoryID, projectPath string, iid int) (*TaskMR, error) {
|
||||
s.mu.RLock()
|
||||
client := s.client
|
||||
store := s.store
|
||||
host := s.host
|
||||
s.mu.RUnlock()
|
||||
if store == nil {
|
||||
return nil, errors.New("gitlab store not configured")
|
||||
}
|
||||
if client == nil {
|
||||
return nil, ErrNoClient
|
||||
}
|
||||
status, err := client.GetMRStatus(ctx, projectPath, iid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch MR status: %w", err)
|
||||
}
|
||||
if status == nil || status.MR == nil {
|
||||
return nil, fmt.Errorf("MR !%d not found in %s", iid, projectPath)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
mr := status.MR
|
||||
row := &TaskMR{
|
||||
TaskID: taskID,
|
||||
RepositoryID: repositoryID,
|
||||
Host: host,
|
||||
ProjectPath: projectPath,
|
||||
MRIID: mr.IID,
|
||||
MRURL: mr.WebURL,
|
||||
MRTitle: mr.Title,
|
||||
HeadBranch: mr.HeadBranch,
|
||||
BaseBranch: mr.BaseBranch,
|
||||
AuthorUsername: mr.AuthorUsername,
|
||||
State: mr.State,
|
||||
ApprovalState: status.ApprovalState,
|
||||
PipelineState: status.PipelineState,
|
||||
MergeStatus: status.MergeStatus,
|
||||
Draft: mr.Draft,
|
||||
ApprovalCount: status.ApprovalCount,
|
||||
RequiredApprovals: status.RequiredApprovals,
|
||||
PipelineJobsTotal: status.PipelineJobsTotal,
|
||||
PipelineJobsPass: status.PipelineJobsPassing,
|
||||
CreatedAt: mr.CreatedAt,
|
||||
MergedAt: mr.MergedAt,
|
||||
ClosedAt: mr.ClosedAt,
|
||||
LastSyncedAt: &now,
|
||||
}
|
||||
if err := store.UpsertTaskMR(ctx, row); err != nil {
|
||||
return nil, fmt.Errorf("upsert task MR: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ListTaskMRsByWorkspace surfaces all MR associations under a workspace,
|
||||
// grouped by task ID. Returns an empty map when no store is configured.
|
||||
func (s *Service) ListTaskMRsByWorkspace(ctx context.Context, workspaceID string) (map[string][]*TaskMR, error) {
|
||||
s.mu.RLock()
|
||||
store := s.store
|
||||
s.mu.RUnlock()
|
||||
if store == nil {
|
||||
return map[string][]*TaskMR{}, nil
|
||||
}
|
||||
return store.ListTaskMRsByWorkspaceID(ctx, workspaceID)
|
||||
}
|
||||
|
||||
// ListTaskMRsByTask returns the MR(s) linked to a single task.
|
||||
func (s *Service) ListTaskMRsByTask(ctx context.Context, taskID string) ([]*TaskMR, error) {
|
||||
s.mu.RLock()
|
||||
store := s.store
|
||||
s.mu.RUnlock()
|
||||
if store == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return store.ListTaskMRsByTask(ctx, taskID)
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeSecretManager is an in-memory SecretManager + SecretProvider used to
|
||||
// drive ConfigureToken / ClearToken without touching the real secret store.
|
||||
type fakeSecretManager struct {
|
||||
items []*SecretListItem
|
||||
values map[string]string
|
||||
createErr error
|
||||
updateErr error
|
||||
deleteErr error
|
||||
createCalls int
|
||||
updateCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func newFakeSecretManager() *fakeSecretManager {
|
||||
return &fakeSecretManager{values: map[string]string{}}
|
||||
}
|
||||
|
||||
func (f *fakeSecretManager) List(context.Context) ([]*SecretListItem, error) {
|
||||
return f.items, nil
|
||||
}
|
||||
|
||||
func (f *fakeSecretManager) Reveal(_ context.Context, id string) (string, error) {
|
||||
return f.values[id], nil
|
||||
}
|
||||
|
||||
func (f *fakeSecretManager) Create(_ context.Context, name, value string) (string, error) {
|
||||
f.createCalls++
|
||||
if f.createErr != nil {
|
||||
return "", f.createErr
|
||||
}
|
||||
id := "id-" + name
|
||||
f.items = append(f.items, &SecretListItem{ID: id, Name: name, HasValue: true})
|
||||
f.values[id] = value
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (f *fakeSecretManager) Update(_ context.Context, id, value string) error {
|
||||
f.updateCalls++
|
||||
if f.updateErr != nil {
|
||||
return f.updateErr
|
||||
}
|
||||
f.values[id] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSecretManager) Delete(_ context.Context, id string) error {
|
||||
f.deleteCalls++
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
delete(f.values, id)
|
||||
out := f.items[:0]
|
||||
for _, it := range f.items {
|
||||
if it.ID != id {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
f.items = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// userHandler returns an http.Handler that responds to /api/v4/user as if it
|
||||
// were a real GitLab. accept controls whether a request is treated as
|
||||
// authorized.
|
||||
func userHandler(accept func(token string) bool) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !accept(r.Header.Get("PRIVATE-TOKEN")) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"username":"alice"}`))
|
||||
})
|
||||
mux.HandleFunc("/api/v4/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"version":"16.0.0"}`))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func newServiceFixture(t *testing.T, handler http.Handler) (*Service, *httptest.Server, *fakeSecretManager) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
log := newTestLogger(t)
|
||||
mgr := newFakeSecretManager()
|
||||
svc := NewService(srv.URL, NewNoopClient(srv.URL), AuthMethodNone, mgr, log)
|
||||
svc.SetSecretManager(mgr)
|
||||
return svc, srv, mgr
|
||||
}
|
||||
|
||||
func TestService_ConfigureToken_ValidTokenCreatesSecretAndSwapsClient(t *testing.T) {
|
||||
svc, _, mgr := newServiceFixture(t, userHandler(func(token string) bool {
|
||||
return token == "good-token"
|
||||
}))
|
||||
|
||||
if err := svc.ConfigureToken(context.Background(), "good-token"); err != nil {
|
||||
t.Fatalf("ConfigureToken err = %v", err)
|
||||
}
|
||||
if mgr.createCalls != 1 {
|
||||
t.Errorf("create calls = %d, want 1", mgr.createCalls)
|
||||
}
|
||||
if mgr.updateCalls != 0 {
|
||||
t.Errorf("update calls = %d, want 0 on first configure", mgr.updateCalls)
|
||||
}
|
||||
if _, ok := svc.Client().(*PATClient); !ok {
|
||||
t.Errorf("client = %T, want *PATClient after configure", svc.Client())
|
||||
}
|
||||
status, _ := svc.GetStatus(context.Background())
|
||||
if status.AuthMethod != AuthMethodPAT {
|
||||
t.Errorf("auth_method = %q, want pat", status.AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ConfigureToken_InvalidTokenReturnsErrorAndKeepsNoop(t *testing.T) {
|
||||
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return false }))
|
||||
|
||||
err := svc.ConfigureToken(context.Background(), "bad-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid token")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid token") {
|
||||
t.Errorf("err = %v, want 'invalid token' prefix", err)
|
||||
}
|
||||
if mgr.createCalls != 0 {
|
||||
t.Errorf("create calls = %d, want 0 — secret must not be written on invalid token", mgr.createCalls)
|
||||
}
|
||||
if _, ok := svc.Client().(*NoopClient); !ok {
|
||||
t.Errorf("client = %T, want *NoopClient (unchanged after failure)", svc.Client())
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ConfigureToken_PreExistingTokenCallsUpdateNotCreate(t *testing.T) {
|
||||
svc, _, mgr := newServiceFixture(t, userHandler(func(token string) bool {
|
||||
return token == "new-token"
|
||||
}))
|
||||
mgr.items = []*SecretListItem{{ID: "id-GITLAB_TOKEN", Name: "GITLAB_TOKEN", HasValue: true}}
|
||||
mgr.values["id-GITLAB_TOKEN"] = "old-token"
|
||||
|
||||
if err := svc.ConfigureToken(context.Background(), "new-token"); err != nil {
|
||||
t.Fatalf("ConfigureToken err = %v", err)
|
||||
}
|
||||
if mgr.updateCalls != 1 {
|
||||
t.Errorf("update calls = %d, want 1", mgr.updateCalls)
|
||||
}
|
||||
if mgr.createCalls != 0 {
|
||||
t.Errorf("create calls = %d, want 0 when secret exists", mgr.createCalls)
|
||||
}
|
||||
if mgr.values["id-GITLAB_TOKEN"] != "new-token" {
|
||||
t.Errorf("stored token = %q, want new-token", mgr.values["id-GITLAB_TOKEN"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ConfigureToken_RejectsEmpty(t *testing.T) {
|
||||
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
err := svc.ConfigureToken(context.Background(), " ")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty token")
|
||||
}
|
||||
if mgr.createCalls != 0 {
|
||||
t.Errorf("create calls = %d, want 0", mgr.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ConfigureToken_ErrorsWhenSecretManagerMissing(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
svc := NewService("", NewNoopClient(""), AuthMethodNone, nil, log)
|
||||
err := svc.ConfigureToken(context.Background(), "anything")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when secret manager not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ClearToken_NoSecretIsNoOp(t *testing.T) {
|
||||
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
t.Setenv("GITLAB_TOKEN", "") // make sure factory falls through to noop
|
||||
if err := svc.ClearToken(context.Background()); err != nil {
|
||||
t.Fatalf("ClearToken err = %v on empty store, want nil", err)
|
||||
}
|
||||
if mgr.deleteCalls != 0 {
|
||||
t.Errorf("delete calls = %d, want 0", mgr.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ClearToken_DeletesSecretAndRebuildsClient(t *testing.T) {
|
||||
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
t.Setenv("GITLAB_TOKEN", "")
|
||||
mgr.items = []*SecretListItem{{ID: "id-GITLAB_TOKEN", Name: "GITLAB_TOKEN", HasValue: true}}
|
||||
mgr.values["id-GITLAB_TOKEN"] = "old"
|
||||
|
||||
if err := svc.ClearToken(context.Background()); err != nil {
|
||||
t.Fatalf("ClearToken err = %v", err)
|
||||
}
|
||||
if mgr.deleteCalls != 1 {
|
||||
t.Errorf("delete calls = %d, want 1", mgr.deleteCalls)
|
||||
}
|
||||
// After clearing the only PAT, the factory should fall back to noop
|
||||
// (glab CLI may or may not be on the test host, so accept both).
|
||||
if _, isPAT := svc.Client().(*PATClient); isPAT {
|
||||
t.Errorf("client = *PATClient after clear, want non-PAT fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ConfigureHost_NormalisesAndPersists(t *testing.T) {
|
||||
svc, srv, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
|
||||
if err := svc.ConfigureHost(context.Background(), srv.URL+"/"); err != nil {
|
||||
t.Fatalf("ConfigureHost err = %v", err)
|
||||
}
|
||||
if svc.Host() != srv.URL {
|
||||
t.Errorf("host = %q, want %q (trailing slash trimmed)", svc.Host(), srv.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ConfigureHost_RejectsMissingScheme(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
|
||||
err := svc.ConfigureHost(context.Background(), "gitlab.example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for host without scheme")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "scheme") {
|
||||
t.Errorf("err = %v, want mention of scheme", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ConfigureHost_UnreachableHostReturnsError(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
|
||||
// 192.0.2.0/24 is reserved by RFC 5737 for documentation; connecting
|
||||
// to it deterministically fails (no route / no listener).
|
||||
err := svc.ConfigureHost(context.Background(), "http://192.0.2.1:1/")
|
||||
if err == nil {
|
||||
t.Fatal("expected unreachable error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unreachable") {
|
||||
t.Errorf("err = %v, want 'unreachable' in message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetStatus_ReportsAuthenticatedAfterConfigure(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(token string) bool {
|
||||
return token == "ok"
|
||||
}))
|
||||
if err := svc.ConfigureToken(context.Background(), "ok"); err != nil {
|
||||
t.Fatalf("ConfigureToken err = %v", err)
|
||||
}
|
||||
status, err := svc.GetStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatus err = %v", err)
|
||||
}
|
||||
if !status.Authenticated {
|
||||
t.Error("status.Authenticated = false, want true")
|
||||
}
|
||||
if status.Username != "alice" {
|
||||
t.Errorf("status.Username = %q, want alice", status.Username)
|
||||
}
|
||||
if !status.TokenConfigured {
|
||||
t.Error("status.TokenConfigured = false, want true")
|
||||
}
|
||||
if status.ConnectionError != "" {
|
||||
t.Errorf("status.ConnectionError = %q, want empty on healthy probe", status.ConnectionError)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: a 5xx (or any non-401/403 error) from the IsAuthenticated probe
|
||||
// must surface as ConnectionError so the UI can render "unreachable" instead
|
||||
// of "not connected" — the prior code dropped the error and made transient
|
||||
// outages look like a bad token.
|
||||
func TestService_GetStatus_PopulatesConnectionErrorOn5xx(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
svc, _, _ := newServiceFixture(t, mux)
|
||||
// Force the service to actually call the upstream by swapping in a
|
||||
// PATClient pointed at our fake-failing server.
|
||||
svc.mu.Lock()
|
||||
svc.client = NewPATClient(svc.host, "tok")
|
||||
svc.authMethod = AuthMethodPAT
|
||||
svc.mu.Unlock()
|
||||
|
||||
status, err := svc.GetStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatus err = %v", err)
|
||||
}
|
||||
if status.Authenticated {
|
||||
t.Error("Authenticated = true on 5xx, want false")
|
||||
}
|
||||
if status.ConnectionError == "" {
|
||||
t.Error("ConnectionError empty on 5xx, want populated (a transport failure must distinguish itself from 'not connected')")
|
||||
}
|
||||
}
|
||||
|
||||
// Counterpart: a 401 is a clean "bad token" signal — IsAuthenticated returns
|
||||
// (false, nil) — and must leave ConnectionError empty so the UI doesn't
|
||||
// render an "unreachable" warning over a legitimately-rejected token.
|
||||
func TestService_GetStatus_ClearsConnectionErrorOn401(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
svc, _, _ := newServiceFixture(t, mux)
|
||||
svc.mu.Lock()
|
||||
svc.client = NewPATClient(svc.host, "bad")
|
||||
svc.authMethod = AuthMethodPAT
|
||||
svc.mu.Unlock()
|
||||
|
||||
status, err := svc.GetStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatus err = %v", err)
|
||||
}
|
||||
if status.Authenticated {
|
||||
t.Error("Authenticated = true on 401, want false")
|
||||
}
|
||||
if status.ConnectionError != "" {
|
||||
t.Errorf("ConnectionError = %q on 401, want empty (401 is a clean not-authenticated signal, not a transport failure)", status.ConnectionError)
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureToken must wrap ErrInvalidToken so the controller can route the
|
||||
// failure to HTTP 400 via errors.Is, instead of string-matching on the
|
||||
// error message. Covers both the empty-token path and the failed-probe path.
|
||||
func TestService_ConfigureToken_WrapsErrInvalidToken(t *testing.T) {
|
||||
t.Run("empty token", func(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
svc := NewService("", NewNoopClient(""), AuthMethodNone, nil, log)
|
||||
svc.SetSecretManager(newFakeSecretManager())
|
||||
err := svc.ConfigureToken(context.Background(), " ")
|
||||
if !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("err = %v, want errors.Is ErrInvalidToken", err)
|
||||
}
|
||||
})
|
||||
t.Run("rejected by probe", func(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return false }))
|
||||
err := svc.ConfigureToken(context.Background(), "bad-token")
|
||||
if !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("err = %v, want errors.Is ErrInvalidToken", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Regression: a 5xx (or any non-401/403) error from the probe must NOT be
|
||||
// wrapped as ErrInvalidToken — that would surface as HTTP 400 "invalid token"
|
||||
// to a user during a GitLab outage and risk them deleting a valid token.
|
||||
func TestService_ConfigureToken_DoesNotWrapTransportErrorAsErrInvalidToken(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
svc, _, _ := newServiceFixture(t, mux)
|
||||
|
||||
err := svc.ConfigureToken(context.Background(), "valid-but-server-is-down")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when probe hits 5xx")
|
||||
}
|
||||
if errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("err = %v, must NOT be ErrInvalidToken on 5xx (would mislead user during an outage)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task ↔ MR association ---
|
||||
|
||||
// withTaskMRStore wires a temporary GitLab Store onto the service so the
|
||||
// task-MR methods exercise real SQLite. Returns the store for direct
|
||||
// inspection.
|
||||
func withTaskMRStore(t *testing.T, svc *Service) *Store {
|
||||
t.Helper()
|
||||
store := newTestStore(t)
|
||||
svc.SetStore(store)
|
||||
return store
|
||||
}
|
||||
|
||||
// swapToMock replaces the service's client with an in-memory MockClient that
|
||||
// tests can pre-seed with MRs. Returns the mock for further seeding.
|
||||
func swapToMock(t *testing.T, svc *Service) *MockClient {
|
||||
t.Helper()
|
||||
mock := NewMockClient(svc.Host())
|
||||
svc.mu.Lock()
|
||||
svc.client = mock
|
||||
svc.mu.Unlock()
|
||||
return mock
|
||||
}
|
||||
|
||||
func TestService_SyncTaskMR_UpsertsFromMockClient(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
store := withTaskMRStore(t, svc)
|
||||
mock := swapToMock(t, svc)
|
||||
seedTask(t, store, "task-1", "ws-1")
|
||||
|
||||
mock.SeedMR("acme/api", &MR{
|
||||
IID: 42,
|
||||
Title: "feat: thing",
|
||||
HeadBranch: "feat/x",
|
||||
BaseBranch: "main",
|
||||
State: mrStateOpen,
|
||||
WebURL: "https://gitlab.example/acme/api/-/merge_requests/42",
|
||||
HeadSHA: "abc123",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
|
||||
row, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 42)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncTaskMR err = %v", err)
|
||||
}
|
||||
if row.MRTitle != "feat: thing" || row.HeadBranch != "feat/x" || row.BaseBranch != "main" {
|
||||
t.Errorf("unexpected upserted row: %+v", row)
|
||||
}
|
||||
if row.LastSyncedAt == nil {
|
||||
t.Error("LastSyncedAt not stamped")
|
||||
}
|
||||
|
||||
// Round-trip through the store: the row must be readable by ID.
|
||||
stored, _ := store.ListTaskMRsByTask(context.Background(), "task-1")
|
||||
if len(stored) != 1 || stored[0].MRTitle != "feat: thing" {
|
||||
t.Fatalf("store roundtrip failed: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SyncTaskMR_PreservesPipelineJobsMapping(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
withTaskMRStore(t, svc)
|
||||
mock := swapToMock(t, svc)
|
||||
|
||||
mock.SeedMR("acme/api", &MR{IID: 7, HeadSHA: "deadbeef", HeadBranch: "f", State: mrStateOpen})
|
||||
// Two finished pipelines: one all-pass, one with a failure.
|
||||
finished := time.Now().UTC()
|
||||
mock.SeedPipelines("acme/api", []Pipeline{
|
||||
{ID: 1, Status: "success", SHA: "deadbeef", JobsTotal: 3, JobsPassing: 3, FinishedAt: &finished},
|
||||
})
|
||||
|
||||
row, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 7)
|
||||
if err != nil {
|
||||
t.Fatalf("sync err = %v", err)
|
||||
}
|
||||
if row.PipelineJobsTotal != 3 || row.PipelineJobsPass != 3 {
|
||||
t.Errorf("pipeline jobs mapping wrong: total=%d pass=%d (want 3/3) — field-name regression",
|
||||
row.PipelineJobsTotal, row.PipelineJobsPass)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SyncTaskMR_ReturnsErrWhenMRNotFound(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
withTaskMRStore(t, svc)
|
||||
swapToMock(t, svc)
|
||||
|
||||
_, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 999)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unseeded MR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SyncTaskMR_ErrorWhenStoreUnset(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
swapToMock(t, svc) // no SetStore — store remains nil
|
||||
_, err := svc.SyncTaskMR(context.Background(), "task", "", "p", 1)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when store is nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "store") {
|
||||
t.Errorf("err = %v, want mention of store", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ListTaskMRsByWorkspace_EmptyWhenStoreUnset(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
got, err := svc.ListTaskMRsByWorkspace(context.Background(), "ws-1")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %d rows, want 0 when store is nil", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ListTaskMRsByWorkspace_ScopesByWorkspaceJoin(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
store := withTaskMRStore(t, svc)
|
||||
mock := swapToMock(t, svc)
|
||||
seedTask(t, store, "task-a", "ws-1")
|
||||
seedTask(t, store, "task-b", "ws-2")
|
||||
|
||||
mock.SeedMR("p1", &MR{IID: 1, HeadBranch: "x", BaseBranch: "main", State: mrStateOpen, CreatedAt: time.Now().UTC()})
|
||||
mock.SeedMR("p2", &MR{IID: 2, HeadBranch: "y", BaseBranch: "main", State: mrStateOpen, CreatedAt: time.Now().UTC()})
|
||||
if _, err := svc.SyncTaskMR(context.Background(), "task-a", "", "p1", 1); err != nil {
|
||||
t.Fatalf("sync a: %v", err)
|
||||
}
|
||||
if _, err := svc.SyncTaskMR(context.Background(), "task-b", "", "p2", 2); err != nil {
|
||||
t.Fatalf("sync b: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.ListTaskMRsByWorkspace(context.Background(), "ws-1")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(got) != 1 || len(got["task-a"]) != 1 {
|
||||
t.Fatalf("ws-1 result = %+v, want task-a with one MR", got)
|
||||
}
|
||||
if _, leaked := got["task-b"]; leaked {
|
||||
t.Error("ws-2's MR leaked into ws-1 result — workspace JOIN broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ListTaskMRsByTask_DelegatesToStore(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
store := withTaskMRStore(t, svc)
|
||||
mock := swapToMock(t, svc)
|
||||
seedTask(t, store, "task-1", "ws-1")
|
||||
|
||||
mock.SeedMR("acme/api", &MR{IID: 1, HeadBranch: "x", BaseBranch: "main", State: mrStateOpen, CreatedAt: time.Now().UTC()})
|
||||
if _, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 1); err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.ListTaskMRsByTask(context.Background(), "task-1")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ProjectPath != "acme/api" {
|
||||
t.Errorf("got = %+v, want acme/api", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ListTaskMRsByTask_NilWhenStoreUnset(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
|
||||
got, err := svc.ListTaskMRsByTask(context.Background(), "task-1")
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("got %v, want nil when store unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the package-level errors used by the tests are real values, not
|
||||
// just docstrings — guards against accidental rename.
|
||||
var _ error = ErrNoClient
|
||||
var _ error = ErrInvalidToken
|
||||
var _ = errors.New
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -34,25 +34,41 @@ func DetectGitProtocol() string {
|
||||
// For HTTPS: https://github.com/{owner}/{name}.git
|
||||
// Returns an error if the provider is not supported.
|
||||
func CloneURL(provider, owner, name, protocol string) (string, error) {
|
||||
host, err := providerHost(provider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return CloneURLWithHost(provider, "", owner, name, protocol)
|
||||
}
|
||||
|
||||
// CloneURLWithHost is like CloneURL but accepts an explicit host. When host
|
||||
// is non-empty (and stripped of scheme/trailing-slash), it overrides the
|
||||
// provider's default — used for self-managed GitLab. When host is empty,
|
||||
// behaves exactly like CloneURL.
|
||||
func CloneURLWithHost(provider, host, owner, name, protocol string) (string, error) {
|
||||
resolvedHost := strings.TrimRight(host, "/")
|
||||
resolvedHost = strings.TrimPrefix(strings.TrimPrefix(resolvedHost, "https://"), "http://")
|
||||
if resolvedHost == "" {
|
||||
var err error
|
||||
resolvedHost, err = providerHost(provider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if protocol == ProtocolSSH {
|
||||
return fmt.Sprintf("git@%s:%s/%s.git", host, owner, name), nil
|
||||
// scp-style "git@host:path" can't carry a port — when the host has
|
||||
// one (gitlab.acme.corp:2222) fall back to the ssh:// URL form,
|
||||
// which git understands and accepts a port natively.
|
||||
if strings.Contains(resolvedHost, ":") {
|
||||
return fmt.Sprintf("ssh://git@%s/%s/%s.git", resolvedHost, owner, name), nil
|
||||
}
|
||||
return fmt.Sprintf("git@%s:%s/%s.git", resolvedHost, owner, name), nil
|
||||
}
|
||||
return fmt.Sprintf("https://%s/%s/%s.git", host, owner, name), nil
|
||||
return fmt.Sprintf("https://%s/%s/%s.git", resolvedHost, owner, name), nil
|
||||
}
|
||||
|
||||
// providerHost maps a provider name to its git host.
|
||||
// Only "github" (and the empty string) are currently supported.
|
||||
// "gitlab" and "bitbucket" entries are placeholders for future support.
|
||||
func providerHost(provider string) (string, error) {
|
||||
switch strings.ToLower(provider) {
|
||||
case "github", "":
|
||||
return "github.com", nil
|
||||
case "gitlab":
|
||||
// TODO: GitLab support is not yet implemented
|
||||
return "gitlab.com", nil
|
||||
case "bitbucket":
|
||||
// TODO: Bitbucket support is not yet implemented
|
||||
|
||||
@@ -63,6 +63,57 @@ func TestCloneURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneURLWithHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
host string
|
||||
owner string
|
||||
repo string
|
||||
protocol string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"gitlab self-managed SSH",
|
||||
"gitlab", "https://gitlab.acme.corp", "team", "service", ProtocolSSH,
|
||||
"git@gitlab.acme.corp:team/service.git",
|
||||
},
|
||||
{
|
||||
"gitlab self-managed HTTPS",
|
||||
"gitlab", "https://gitlab.acme.corp/", "team", "service", ProtocolHTTPS,
|
||||
"https://gitlab.acme.corp/team/service.git",
|
||||
},
|
||||
{
|
||||
"empty host falls back to provider default",
|
||||
"gitlab", "", "team", "service", ProtocolSSH,
|
||||
"git@gitlab.com:team/service.git",
|
||||
},
|
||||
{
|
||||
"http scheme honored",
|
||||
"gitlab", "http://gitlab.local", "team", "service", ProtocolHTTPS,
|
||||
"https://gitlab.local/team/service.git",
|
||||
},
|
||||
{
|
||||
// scp-style "git@host:path" can't carry a port; ssh:// URL
|
||||
// form is the only correct shape when one is present.
|
||||
"self-managed SSH with port falls back to ssh:// URL",
|
||||
"gitlab", "https://gitlab.acme.corp:2222", "team", "service", ProtocolSSH,
|
||||
"ssh://git@gitlab.acme.corp:2222/team/service.git",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := CloneURLWithHost(tt.provider, tt.host, tt.owner, tt.repo, tt.protocol)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
provider string
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
IconBrandGitlab,
|
||||
IconBug,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconExternalLink,
|
||||
IconGitMerge,
|
||||
IconRefresh,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@kandev/ui/tabs";
|
||||
import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api";
|
||||
import { useGitLabUserIssues, useGitLabUserMRs } from "@/hooks/domains/gitlab/use-user-search";
|
||||
import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab";
|
||||
|
||||
// Filter values map to GitLab's "scope" query param semantics.
|
||||
const MR_FILTERS = [
|
||||
{ value: "assigned_to_me", label: "Assigned" },
|
||||
{ value: "created_by_me", label: "Authored" },
|
||||
{ value: "review_requested", label: "Review requested" },
|
||||
] as const;
|
||||
|
||||
const ISSUE_FILTERS = [
|
||||
{ value: "assigned_to_me", label: "Assigned" },
|
||||
{ value: "created_by_me", label: "Created" },
|
||||
] as const;
|
||||
|
||||
function MRStateBadge({ mr }: { mr: MR }) {
|
||||
const state = mr.state === "opened" ? "open" : mr.state;
|
||||
if (state === "merged") {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 bg-purple-500/10 text-purple-500">
|
||||
<IconCheck className="h-3 w-3" /> Merged
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (state === "closed") {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<IconX className="h-3 w-3" /> Closed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (mr.draft) {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-muted-foreground">
|
||||
<IconClock className="h-3 w-3" /> Draft
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-emerald-500">
|
||||
<IconGitMerge className="h-3 w-3" /> Open
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueStateBadge({ issue }: { issue: Issue }) {
|
||||
if (issue.state === "closed") {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<IconCheck className="h-3 w-3" /> Closed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-emerald-500">
|
||||
<IconBug className="h-3 w-3" /> Open
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function NotConnectedNotice() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center space-y-2">
|
||||
<IconBrandGitlab className="h-10 w-10 mx-auto text-muted-foreground" />
|
||||
<p className="text-sm font-medium">GitLab not connected</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect your GitLab account to browse merge requests and issues from this page.
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline" className="cursor-pointer mt-2">
|
||||
<Link href="/settings/integrations/gitlab">Open settings</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MRList({ filter, query }: { filter: string; query: string }) {
|
||||
const { items: mrs, loading, error } = useGitLabUserMRs(filter, query);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive py-4">{error}</p>;
|
||||
}
|
||||
if (mrs.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">No merge requests found.</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{mrs.map((mr) => (
|
||||
<li key={`${mr.project_path}-${mr.iid}`}>
|
||||
<Link
|
||||
href={mr.web_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-3 rounded-md border hover:border-primary/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium truncate">
|
||||
<span className="truncate">{mr.title}</span>
|
||||
<IconExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 truncate">
|
||||
{mr.project_path} !{mr.iid} · {mr.head_branch} → {mr.base_branch}
|
||||
</p>
|
||||
</div>
|
||||
<MRStateBadge mr={mr} />
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueList({ filter, query }: { filter: string; query: string }) {
|
||||
const { items: issues, loading, error } = useGitLabUserIssues(filter, query);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive py-4">{error}</p>;
|
||||
}
|
||||
if (issues.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground py-6 text-center">No issues found.</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{issues.map((issue) => (
|
||||
<li key={`${issue.project_path}-${issue.iid}`}>
|
||||
<Link
|
||||
href={issue.web_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-3 rounded-md border hover:border-primary/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium truncate">
|
||||
<span className="truncate">{issue.title}</span>
|
||||
<IconExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 truncate">
|
||||
{issue.project_path} #{issue.iid}
|
||||
{issue.labels.length > 0 && ` · ${issue.labels.slice(0, 3).join(", ")}`}
|
||||
</p>
|
||||
</div>
|
||||
<IssueStateBadge issue={issue} />
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitLabPageClient({ workspaceId: _workspaceId }: { workspaceId?: string }) {
|
||||
const [status, setStatus] = useState<GitLabStatus | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
const [mrFilter, setMrFilter] = useState<string>(MR_FILTERS[0].value);
|
||||
const [issueFilter, setIssueFilter] = useState<string>(ISSUE_FILTERS[0].value);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(search), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const reloadStatus = useCallback(() => {
|
||||
setStatusLoading(true);
|
||||
fetchGitLabStatus({ cache: "no-store" })
|
||||
.then(setStatus)
|
||||
.catch(() => setStatus(null))
|
||||
.finally(() => setStatusLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchGitLabStatus({ cache: "no-store" })
|
||||
.then((s) => {
|
||||
if (!cancelled) setStatus(s);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setStatusLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pass debounced search through as the custom_query. GitLab understands
|
||||
// free-text search via the `search` URL param; mapping the raw text to it
|
||||
// keeps the surface simple — power users can still type the param-style
|
||||
// string like "labels=bug&author_username=me" and the backend forwards
|
||||
// it as customQuery.
|
||||
const customQuery = useMemo(() => {
|
||||
const q = debounced.trim();
|
||||
if (!q) return "";
|
||||
if (q.includes("=")) return q; // assume raw key=value
|
||||
return `search=${encodeURIComponent(q)}`;
|
||||
}, [debounced]);
|
||||
|
||||
const connected = status?.authenticated || status?.token_configured;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4 md:p-6">
|
||||
<PageHeader status={status} statusLoading={statusLoading} onRefresh={reloadStatus} />
|
||||
{!statusLoading && !connected ? (
|
||||
<NotConnectedNotice />
|
||||
) : (
|
||||
<BrowseTabs
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
mrFilter={mrFilter}
|
||||
setMrFilter={setMrFilter}
|
||||
issueFilter={issueFilter}
|
||||
setIssueFilter={setIssueFilter}
|
||||
customQuery={customQuery}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader({
|
||||
status,
|
||||
statusLoading,
|
||||
onRefresh,
|
||||
}: {
|
||||
status: GitLabStatus | null;
|
||||
statusLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<IconBrandGitlab className="h-5 w-5 text-orange-500" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">GitLab</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status?.host ?? "https://gitlab.com"} · merge requests and issues
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={statusLoading}
|
||||
className="gap-1 cursor-pointer"
|
||||
>
|
||||
<IconRefresh className="h-3 w-3" />
|
||||
Refresh
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowseTabs({
|
||||
search,
|
||||
setSearch,
|
||||
mrFilter,
|
||||
setMrFilter,
|
||||
issueFilter,
|
||||
setIssueFilter,
|
||||
customQuery,
|
||||
}: {
|
||||
search: string;
|
||||
setSearch: (v: string) => void;
|
||||
mrFilter: string;
|
||||
setMrFilter: (v: string) => void;
|
||||
issueFilter: string;
|
||||
setIssueFilter: (v: string) => void;
|
||||
customQuery: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Search (free text, or paste GitLab query like labels=bug&state=opened)"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="mrs">
|
||||
<TabsList>
|
||||
<TabsTrigger value="mrs" className="gap-1.5">
|
||||
<IconGitMerge className="h-3.5 w-3.5" />
|
||||
Merge requests
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="issues" className="gap-1.5">
|
||||
<IconBug className="h-3.5 w-3.5" />
|
||||
Issues
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="mrs" className="space-y-3">
|
||||
<FilterButtons
|
||||
value={mrFilter}
|
||||
onChange={setMrFilter}
|
||||
options={MR_FILTERS as readonly { value: string; label: string }[]}
|
||||
/>
|
||||
<MRList filter={mrFilter} query={customQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="issues" className="space-y-3">
|
||||
<FilterButtons
|
||||
value={issueFilter}
|
||||
onChange={setIssueFilter}
|
||||
options={ISSUE_FILTERS as readonly { value: string; label: string }[]}
|
||||
/>
|
||||
<IssueList filter={issueFilter} query={customQuery} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterButtons({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: readonly { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{options.map((f) => (
|
||||
<Button
|
||||
key={f.value}
|
||||
size="sm"
|
||||
variant={value === f.value ? "default" : "outline"}
|
||||
onClick={() => onChange(f.value)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { listWorkspacesAction } from "@/app/actions/workspaces";
|
||||
import { fetchUserSettings } from "@/lib/api";
|
||||
import { StateHydrator } from "@/components/state-hydrator";
|
||||
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
|
||||
import { GitLabPageClient } from "./gitlab-page-client";
|
||||
import type { Workspace, UserSettingsResponse } from "@/lib/types/http";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
|
||||
// Minimal SSR entrypoint for /gitlab. v1 surface: list the current user's
|
||||
// open MRs + issues and let them click through to GitLab. The browse-and-
|
||||
// trigger flow (review/issue watchers, presets) is parallel to /github and
|
||||
// will land alongside the watchers backend in a follow-up.
|
||||
export default async function GitLabPage() {
|
||||
let workspaces: Workspace[] = [];
|
||||
let workspaceId: string | undefined;
|
||||
let userSettingsResponse: UserSettingsResponse | null = null;
|
||||
|
||||
try {
|
||||
const [workspacesResponse, settingsResponse] = await Promise.all([
|
||||
listWorkspacesAction(),
|
||||
fetchUserSettings({ cache: "no-store" }).catch(() => null),
|
||||
]);
|
||||
workspaces = workspacesResponse.workspaces;
|
||||
userSettingsResponse = settingsResponse;
|
||||
workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to load GitLab page data:", error);
|
||||
}
|
||||
|
||||
const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse);
|
||||
|
||||
const initialState: Partial<AppState> = {
|
||||
workspaces: { items: workspaces, activeId: workspaceId ?? null },
|
||||
userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StateHydrator initialState={initialState} />
|
||||
<GitLabPageClient workspaceId={workspaceId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GitLabIntegrationPage } from "@/components/gitlab/gitlab-settings";
|
||||
|
||||
export default function IntegrationsGitLabPage() {
|
||||
return <GitLabIntegrationPage />;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { IconBrandGithub, IconBrandSlack, IconHexagon, IconTicket } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconBrandGitlab,
|
||||
IconBrandSlack,
|
||||
IconHexagon,
|
||||
IconTicket,
|
||||
} from "@tabler/icons-react";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
|
||||
const INTEGRATIONS = [
|
||||
@@ -9,6 +15,12 @@ const INTEGRATIONS = [
|
||||
description: "PR review queues, issue watchers, and OAuth credentials.",
|
||||
Icon: IconBrandGithub,
|
||||
},
|
||||
{
|
||||
href: "/settings/integrations/gitlab",
|
||||
label: "GitLab",
|
||||
description: "Merge request creation, discussion replies, and self-managed hosts.",
|
||||
Icon: IconBrandGitlab,
|
||||
},
|
||||
{
|
||||
href: "/settings/integrations/jira",
|
||||
label: "Jira",
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandGitlab,
|
||||
IconCheck,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconKey,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
IconWorld,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { Alert, AlertDescription } from "@kandev/ui/alert";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { SettingsSection } from "@/components/settings/settings-section";
|
||||
import {
|
||||
clearGitLabToken,
|
||||
configureGitLabHost,
|
||||
configureGitLabToken,
|
||||
fetchGitLabStatus,
|
||||
} from "@/lib/api/domains/gitlab-api";
|
||||
import type { GitLabStatus } from "@/lib/types/gitlab";
|
||||
|
||||
const DEFAULT_HOST = "https://gitlab.com";
|
||||
|
||||
function StatusBadge({ status }: { status: GitLabStatus | null }) {
|
||||
if (!status) return null;
|
||||
if (status.authenticated) {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<IconCheck className="h-3 w-3" /> Connected
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// A non-empty connection_error means the probe failed for transport reasons
|
||||
// (network / 5xx / parse) — distinct from "no token configured", which has
|
||||
// an empty connection_error and authenticated=false.
|
||||
if (status.connection_error) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/60 text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<IconAlertTriangle className="h-3 w-3" /> Unreachable
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<IconX className="h-3 w-3" /> Not connected
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ConnectionErrorAlert renders the per-host transport failure separately from
|
||||
// the "bad token" path so users see "GitLab is currently unreachable" instead
|
||||
// of "your token is broken" during an outage. Hidden when the probe succeeded
|
||||
// or when no token is configured (nothing to probe).
|
||||
function ConnectionErrorAlert({ status }: { status: GitLabStatus | null }) {
|
||||
if (!status?.connection_error) return null;
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Couldn't reach <code className="font-mono text-xs">{status.host}</code>:{" "}
|
||||
{status.connection_error}
|
||||
<span className="block text-xs opacity-80 mt-1">
|
||||
Your token may still be valid — this looks like a network or upstream issue.
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthMethodBadge({ method }: { method: GitLabStatus["auth_method"] }) {
|
||||
const labels: Record<GitLabStatus["auth_method"], string> = {
|
||||
glab_cli: "glab CLI",
|
||||
pat: "Personal access token",
|
||||
none: "Not configured",
|
||||
mock: "Mock (test)",
|
||||
};
|
||||
return <Badge variant="outline">{labels[method] ?? method}</Badge>;
|
||||
}
|
||||
|
||||
function HostForm({ initial, onSaved }: { initial: string; onSaved: () => void }) {
|
||||
const [host, setHost] = useState(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
setHost(initial);
|
||||
}, [initial]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!host.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await configureGitLabHost(host.trim());
|
||||
toast({ description: "GitLab host updated", variant: "success" });
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
toast({
|
||||
description: err instanceof Error ? err.message : "Failed to update host",
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[host, toast, onSaved],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 items-center">
|
||||
<IconWorld className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
type="url"
|
||||
placeholder={DEFAULT_HOST}
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button type="submit" disabled={saving || !host.trim()} className="cursor-pointer">
|
||||
{saving ? <Spinner className="h-3 w-3" /> : "Save host"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenForm({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [token, setToken] = useState("");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await configureGitLabToken(token.trim());
|
||||
toast({ description: "GitLab token configured", variant: "success" });
|
||||
setToken("");
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast({
|
||||
description: err instanceof Error ? err.message : "Failed to save token",
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[token, toast, onSuccess],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 items-center">
|
||||
<IconKey className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder="glpat-xxxxxxxxxxxxxxxxxxxx"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className="font-mono text-sm pr-9"
|
||||
disabled={saving}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
>
|
||||
{showToken ? <IconEyeOff className="h-4 w-4" /> : <IconEye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving || !token.trim()} className="cursor-pointer">
|
||||
{saving ? <Spinner className="h-3 w-3" /> : "Save token"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearTokenButton({ onCleared }: { onCleared: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { toast } = useToast();
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await clearGitLabToken();
|
||||
toast({ description: "GitLab token cleared" });
|
||||
onCleared();
|
||||
} catch (err) {
|
||||
toast({
|
||||
description: err instanceof Error ? err.message : "Failed to clear token",
|
||||
variant: "error",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
className="gap-1 cursor-pointer"
|
||||
>
|
||||
{busy ? <Spinner className="h-3 w-3" /> : <IconTrash className="h-3 w-3" />}
|
||||
Clear token
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitLabIntegrationPage() {
|
||||
const [status, setStatus] = useState<GitLabStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await fetchGitLabStatus({ cache: "no-store" });
|
||||
setStatus(next);
|
||||
} catch {
|
||||
setStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="GitLab"
|
||||
description="Connect a GitLab account so kandev can open merge requests, read review discussions, and reply to / resolve them on your behalf."
|
||||
icon={<IconBrandGitlab className="h-4 w-4" />}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
className="gap-1 cursor-pointer"
|
||||
>
|
||||
<IconRefresh className="h-3 w-3" />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="space-y-4 py-4">
|
||||
<ConnectionErrorAlert status={status} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={status} />
|
||||
{status && <AuthMethodBadge method={status.auth_method} />}
|
||||
{status?.glab_version && (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
glab {status.glab_version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{status?.username && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Logged in as <span className="font-medium">{status.username}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitLab host URL. Override for self-managed instances; leave at the default for
|
||||
gitlab.com.
|
||||
</p>
|
||||
<HostForm initial={status?.host ?? DEFAULT_HOST} onSaved={() => void reload()} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Personal access token. Required scopes: <code>api</code>, <code>read_user</code>.
|
||||
Stored encrypted in the kandev secret store.
|
||||
</p>
|
||||
{status?.token_configured && <ClearTokenButton onCleared={() => void reload()} />}
|
||||
</div>
|
||||
<TokenForm onSuccess={() => void reload()} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { IconBrandGitlab, IconCheck, IconClock, IconGitMerge, IconX } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useTaskMRs } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
/**
|
||||
* Icon + colour for an MR's combined state. Mirrors github's pr-task-icon
|
||||
* priority order so a merged MR reads the same as a merged PR: terminal
|
||||
* states first, then pipeline failures / changes-requested, then ready-to-
|
||||
* merge, then awaiting-something, then pipeline-running.
|
||||
*/
|
||||
function MRStatusIcon({ mr }: { mr: TaskMR }) {
|
||||
if (mr.state === "merged") return <IconCheck className="h-3 w-3 text-purple-500" />;
|
||||
if (mr.state === "closed") return <IconX className="h-3 w-3 text-muted-foreground" />;
|
||||
if (mr.pipeline_state === "failure") return <IconX className="h-3 w-3 text-red-500" />;
|
||||
if (mr.approval_state === "approved" && mr.pipeline_state === "success" && !mr.draft) {
|
||||
return <IconCheck className="h-3 w-3 text-emerald-400" />;
|
||||
}
|
||||
if (mr.approval_state === "pending") return <IconClock className="h-3 w-3 text-sky-400" />;
|
||||
if (mr.pipeline_state === "pending") return <IconClock className="h-3 w-3 text-yellow-500" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function statusTextColor(mr: TaskMR): string {
|
||||
if (mr.state === "merged") return "text-purple-500";
|
||||
if (mr.state === "closed") return "text-muted-foreground";
|
||||
if (mr.pipeline_state === "failure") return "text-red-500";
|
||||
if (mr.approval_state === "approved") return "text-emerald-400";
|
||||
if (mr.approval_state === "pending") return "text-sky-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function MRSingleButton({ mr }: { mr: TaskMR }) {
|
||||
const tooltip = `${mr.project_path} !${mr.mr_iid} — ${mr.mr_title}`;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
data-testid="mr-topbar-button"
|
||||
data-mr-iid={mr.mr_iid}
|
||||
data-mr-state={mr.state}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="cursor-pointer gap-1.5 px-2"
|
||||
>
|
||||
<Link href={mr.mr_url} target="_blank" rel="noopener noreferrer">
|
||||
<IconGitMerge className={`h-4 w-4 ${statusTextColor(mr)}`} />
|
||||
<span className="text-xs font-medium">!{mr.mr_iid}</span>
|
||||
<MRStatusIcon mr={mr} />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MRMultiButton({ mrs }: { mrs: TaskMR[] }) {
|
||||
// 2+ MRs collapse into a single GitLab icon with a count. Detail panel
|
||||
// / dropdown for fan-out lands with phase 4.
|
||||
const failed = mrs.filter((m) => m.pipeline_state === "failure").length;
|
||||
const merged = mrs.filter((m) => m.state === "merged").length;
|
||||
const open = mrs.filter((m) => m.state === "open" || m.state === "opened").length;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="cursor-pointer gap-1.5 px-2">
|
||||
<IconBrandGitlab className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-xs font-medium">{mrs.length} MRs</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{open > 0 && <div>{open} open</div>}
|
||||
{failed > 0 && <div className="text-red-500">{failed} failing</div>}
|
||||
{merged > 0 && <div className="text-purple-500">{merged} merged</div>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const MRTopbarButton = memo(function MRTopbarButton() {
|
||||
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
|
||||
const mrs = useTaskMRs(activeTaskId);
|
||||
|
||||
if (mrs.length === 0) return null;
|
||||
if (mrs.length === 1) return <MRSingleButton mr={mrs[0]} />;
|
||||
return <MRMultiButton mrs={mrs} />;
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import type { GitHubStatus } from "@/lib/types/github";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
|
||||
const useGitHubStatusMock = vi.hoisted(() => vi.fn());
|
||||
const useGitLabAvailableMock = vi.hoisted(() => vi.fn());
|
||||
const useJiraAvailableMock = vi.hoisted(() => vi.fn());
|
||||
const useLinearAvailableMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -18,6 +19,10 @@ vi.mock("@/hooks/domains/github/use-github-status", () => ({
|
||||
useGitHubStatus: useGitHubStatusMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
|
||||
useGitLabAvailable: useGitLabAvailableMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({
|
||||
useJiraAvailable: useJiraAvailableMock,
|
||||
}));
|
||||
@@ -39,10 +44,12 @@ function status(overrides: Partial<GitHubStatus>): GitHubStatus {
|
||||
|
||||
function mockAvailability({
|
||||
githubReady,
|
||||
gitlabReady = false,
|
||||
jiraAvailable,
|
||||
linearAvailable,
|
||||
}: {
|
||||
githubReady: boolean;
|
||||
gitlabReady?: boolean;
|
||||
jiraAvailable: boolean;
|
||||
linearAvailable: boolean;
|
||||
}) {
|
||||
@@ -50,6 +57,7 @@ function mockAvailability({
|
||||
status: githubReady ? status({ token_configured: true }) : status({}),
|
||||
loading: false,
|
||||
});
|
||||
useGitLabAvailableMock.mockReturnValue(gitlabReady);
|
||||
useJiraAvailableMock.mockReturnValue(jiraAvailable);
|
||||
useLinearAvailableMock.mockReturnValue(linearAvailable);
|
||||
}
|
||||
@@ -94,6 +102,7 @@ describe("getAvailableIntegrationLinks", () => {
|
||||
expect(
|
||||
getAvailableIntegrationLinks({
|
||||
githubReady: true,
|
||||
gitlabReady: false,
|
||||
jiraAvailable: false,
|
||||
linearAvailable: true,
|
||||
}),
|
||||
@@ -107,6 +116,7 @@ describe("getAvailableIntegrationLinks", () => {
|
||||
expect(
|
||||
getAvailableIntegrationLinks({
|
||||
githubReady: false,
|
||||
gitlabReady: false,
|
||||
jiraAvailable: false,
|
||||
linearAvailable: false,
|
||||
}),
|
||||
|
||||
@@ -11,17 +11,24 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { IconBrandGithub, IconHexagon, IconPlugConnected, IconTicket } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconBrandGitlab,
|
||||
IconHexagon,
|
||||
IconPlugConnected,
|
||||
IconTicket,
|
||||
} from "@tabler/icons-react";
|
||||
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
|
||||
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
|
||||
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
|
||||
import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import type { GitHubStatus } from "@/lib/types/github";
|
||||
|
||||
type MobileIntegrationsSectionProps = {
|
||||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
type IntegrationId = "github" | "jira" | "linear";
|
||||
type IntegrationId = "github" | "gitlab" | "jira" | "linear";
|
||||
|
||||
type IntegrationLink = {
|
||||
id: IntegrationId;
|
||||
@@ -31,18 +38,21 @@ type IntegrationLink = {
|
||||
|
||||
type IntegrationAvailability = {
|
||||
githubReady: boolean;
|
||||
gitlabReady: boolean;
|
||||
jiraAvailable: boolean;
|
||||
linearAvailable: boolean;
|
||||
};
|
||||
|
||||
const INTEGRATION_LINKS: IntegrationLink[] = [
|
||||
{ id: "github", label: "GitHub", href: "/github" },
|
||||
{ id: "gitlab", label: "GitLab", href: "/gitlab" },
|
||||
{ id: "jira", label: "Jira", href: "/jira" },
|
||||
{ id: "linear", label: "Linear", href: "/linear" },
|
||||
];
|
||||
|
||||
const INTEGRATION_ICONS = {
|
||||
github: IconBrandGithub,
|
||||
gitlab: IconBrandGitlab,
|
||||
jira: IconTicket,
|
||||
linear: IconHexagon,
|
||||
} satisfies Record<IntegrationId, typeof IconBrandGithub>;
|
||||
@@ -51,11 +61,13 @@ const HOVER_CLOSE_DELAY_MS = 180;
|
||||
|
||||
export function getAvailableIntegrationLinks({
|
||||
githubReady,
|
||||
gitlabReady,
|
||||
jiraAvailable,
|
||||
linearAvailable,
|
||||
}: IntegrationAvailability): IntegrationLink[] {
|
||||
return INTEGRATION_LINKS.filter((link) => {
|
||||
if (link.id === "github") return githubReady;
|
||||
if (link.id === "gitlab") return gitlabReady;
|
||||
if (link.id === "jira") return jiraAvailable;
|
||||
return linearAvailable;
|
||||
});
|
||||
@@ -73,12 +85,14 @@ export function getGitHubIntegrationStatus(status: GitHubStatus | null, loading:
|
||||
|
||||
function useConfiguredIntegrationLinks(): IntegrationLink[] {
|
||||
const { status, loading } = useGitHubStatus();
|
||||
const gitlabAvailable = useGitLabAvailable();
|
||||
const jiraAvailable = useJiraAvailable();
|
||||
const linearAvailable = useLinearAvailable();
|
||||
const githubStatus = getGitHubIntegrationStatus(status, loading);
|
||||
|
||||
return getAvailableIntegrationLinks({
|
||||
githubReady: githubStatus.ready,
|
||||
gitlabReady: gitlabAvailable,
|
||||
jiraAvailable,
|
||||
linearAvailable,
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useKanbanData, useKanbanActions, useKanbanNavigation } from "@/hooks/do
|
||||
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
|
||||
import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow";
|
||||
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
|
||||
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useTaskMultiSelect } from "@/hooks/use-task-multi-select";
|
||||
import { HomepageCommands } from "./homepage-commands";
|
||||
@@ -246,6 +247,7 @@ function useKanbanBoardSetup(
|
||||
|
||||
useAllWorkflowSnapshots(workspaceState.activeId);
|
||||
useWorkspacePRs(workspaceState.activeId);
|
||||
useWorkspaceMRs(workspaceState.activeId);
|
||||
|
||||
const hooks = useKanbanBoardHooks(searchQuery, workspaceState, workflowsState);
|
||||
const { handleOpenTask, handleCardClick } = useKanbanNavigation({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconKey,
|
||||
IconMessageCircle,
|
||||
IconBrandGithub,
|
||||
IconBrandGitlab,
|
||||
IconBrandSlack,
|
||||
IconHexagon,
|
||||
IconSparkles,
|
||||
@@ -155,6 +156,7 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec
|
||||
function IntegrationsSidebarSection({ pathname }: { pathname: string }) {
|
||||
const items: Array<{ href: string; label: string; Icon: typeof IconBrandGithub }> = [
|
||||
{ href: "/settings/integrations/github", label: "GitHub", Icon: IconBrandGithub },
|
||||
{ href: "/settings/integrations/gitlab", label: "GitLab", Icon: IconBrandGitlab },
|
||||
{ href: "/settings/integrations/jira", label: "Jira", Icon: IconTicket },
|
||||
{ href: "/settings/integrations/linear", label: "Linear", Icon: IconHexagon },
|
||||
{ href: "/settings/integrations/slack", label: "Slack", Icon: IconBrandSlack },
|
||||
|
||||
@@ -34,6 +34,10 @@ vi.mock("@/components/github/pr-topbar-button", () => ({
|
||||
PRTopbarButton: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/gitlab/mr-topbar-button", () => ({
|
||||
MRTopbarButton: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/jira/jira-ticket-button", () => ({
|
||||
JiraTicketButton: () => null,
|
||||
extractJiraKey: () => null,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { EditorsMenu } from "@/components/task/editors-menu";
|
||||
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
|
||||
import { DocumentControls } from "@/components/task/document/document-controls";
|
||||
import { PRTopbarButton } from "@/components/github/pr-topbar-button";
|
||||
import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button";
|
||||
import { JiraTicketButton, extractJiraKey } from "@/components/jira/jira-ticket-button";
|
||||
import { JiraLinkButton } from "@/components/jira/jira-link-button";
|
||||
import { LinearIssueButton, extractLinearKey } from "@/components/linear/linear-issue-button";
|
||||
@@ -317,7 +318,11 @@ function AttentionStatusGroup({
|
||||
sessionId={activeSessionId}
|
||||
isAgentctlReady={isAgentctlReady}
|
||||
/>
|
||||
{/* PR (GitHub) and MR (GitLab) buttons each render nothing when no
|
||||
rows match, so showing both covers GitHub-only, GitLab-only, and
|
||||
multi-repo tasks without needing an explicit provider switch. */}
|
||||
<PRTopbarButton />
|
||||
<MRTopbarButton />
|
||||
<IssueTrackerButtons taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { StateProvider, useAppStore } from "@/components/state-provider";
|
||||
import type { GitLabStatus, TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
const fetchGitLabStatusMock = vi.fn<[], Promise<GitLabStatus | null>>();
|
||||
const listWorkspaceTaskMRsMock = vi.fn<
|
||||
[string],
|
||||
Promise<{ task_mrs: Record<string, TaskMR[]> } | null>
|
||||
>();
|
||||
|
||||
vi.mock("@/lib/api/domains/gitlab-api", () => ({
|
||||
fetchGitLabStatus: () => fetchGitLabStatusMock(),
|
||||
listWorkspaceTaskMRs: (workspaceId: string) => listWorkspaceTaskMRsMock(workspaceId),
|
||||
}));
|
||||
|
||||
import { useGitLabAvailable, useTaskMRs, useWorkspaceMRs } from "./use-task-mr";
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return createElement(StateProvider, null, children);
|
||||
}
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
|
||||
return {
|
||||
id: "mr-1",
|
||||
task_id: "task-1",
|
||||
host: "https://gitlab.com",
|
||||
project_path: "acme/api",
|
||||
mr_iid: 1,
|
||||
mr_url: "",
|
||||
mr_title: "Test",
|
||||
head_branch: "feat",
|
||||
base_branch: "main",
|
||||
author_username: "alice",
|
||||
state: "open",
|
||||
approval_state: "",
|
||||
pipeline_state: "",
|
||||
merge_status: "",
|
||||
draft: false,
|
||||
approval_count: 0,
|
||||
required_approvals: 0,
|
||||
pipeline_jobs_total: 0,
|
||||
pipeline_jobs_pass: 0,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStatus(overrides: Partial<GitLabStatus> = {}): GitLabStatus {
|
||||
return {
|
||||
authenticated: true,
|
||||
username: "alice",
|
||||
auth_method: "pat",
|
||||
host: "https://gitlab.com",
|
||||
token_configured: true,
|
||||
required_scopes: ["api"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useWorkspaceMRs", () => {
|
||||
beforeEach(() => {
|
||||
listWorkspaceTaskMRsMock.mockReset();
|
||||
});
|
||||
|
||||
it("hydrates the store with the workspace's task MRs", async () => {
|
||||
const mr = makeMR({ task_id: "task-1" });
|
||||
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: { "task-1": [mr] } });
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
useWorkspaceMRs("ws-1");
|
||||
return useAppStore((s) => s.taskMRs.byTaskId);
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current["task-1"]).toEqual([mr]));
|
||||
expect(listWorkspaceTaskMRsMock).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("does not refetch when the workspace id stays the same", async () => {
|
||||
listWorkspaceTaskMRsMock.mockResolvedValue({ task_mrs: {} });
|
||||
const { rerender } = renderHook(({ ws }: { ws: string | null }) => useWorkspaceMRs(ws), {
|
||||
wrapper,
|
||||
initialProps: { ws: "ws-1" },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1));
|
||||
rerender({ ws: "ws-1" });
|
||||
rerender({ ws: "ws-1" });
|
||||
expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears MRs and invalidates in-flight requests when workspace becomes null", async () => {
|
||||
// First fetch is slow — we will switch to null before it resolves to
|
||||
// verify the in-flight result is dropped by the request-id guard.
|
||||
let resolveFirst: (v: { task_mrs: Record<string, TaskMR[]> }) => void = () => {};
|
||||
const firstPromise = new Promise<{ task_mrs: Record<string, TaskMR[]> }>((res) => {
|
||||
resolveFirst = res;
|
||||
});
|
||||
listWorkspaceTaskMRsMock.mockReturnValueOnce(firstPromise);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ ws }: { ws: string | null }) => {
|
||||
useWorkspaceMRs(ws);
|
||||
return useAppStore((s) => s.taskMRs.byTaskId);
|
||||
},
|
||||
{ wrapper, initialProps: { ws: "ws-1" as string | null } },
|
||||
);
|
||||
|
||||
// Pre-populate the store so we can observe it being cleared.
|
||||
const setInitial = renderHook(() => useAppStore((s) => s.setTaskMRs), { wrapper });
|
||||
act(() => {
|
||||
setInitial.result.current({ "task-1": [makeMR()] });
|
||||
});
|
||||
|
||||
rerender({ ws: null });
|
||||
await waitFor(() => expect(result.current).toEqual({}));
|
||||
|
||||
// The first fetch resolves *after* the null switch — its data must
|
||||
// NOT land in the store.
|
||||
const mr = makeMR({ task_id: "task-1" });
|
||||
await act(async () => {
|
||||
resolveFirst({ task_mrs: { "task-1": [mr] } });
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(result.current).toEqual({});
|
||||
});
|
||||
|
||||
it("clears fetchedRef on failure so a workspace switch can retry that id later", async () => {
|
||||
listWorkspaceTaskMRsMock.mockRejectedValueOnce(new Error("boom"));
|
||||
const { rerender } = renderHook(({ ws }: { ws: string | null }) => useWorkspaceMRs(ws), {
|
||||
wrapper,
|
||||
initialProps: { ws: "ws-1" },
|
||||
});
|
||||
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Bounce through another workspace and back to ws-1. Without the
|
||||
// failure-path reset of fetchedRef, the second visit to ws-1 would
|
||||
// be a no-op because the hook would still think the fetch succeeded.
|
||||
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: {} });
|
||||
rerender({ ws: "ws-2" });
|
||||
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: {} });
|
||||
rerender({ ws: "ws-1" });
|
||||
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskMRs", () => {
|
||||
it("returns the same array reference across renders when empty", () => {
|
||||
const { result, rerender } = renderHook(() => useTaskMRs("task-empty"), { wrapper });
|
||||
const first = result.current;
|
||||
rerender();
|
||||
rerender();
|
||||
// If we returned `[]` literal each call, this would fail and the
|
||||
// zustand selector would loop forever.
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
it("reads the task's MRs from the store", async () => {
|
||||
const mr = makeMR({ task_id: "task-1" });
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const setTaskMRs = useAppStore((s) => s.setTaskMRs);
|
||||
const mrs = useTaskMRs("task-1");
|
||||
return { setTaskMRs, mrs };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
act(() => {
|
||||
result.current.setTaskMRs({ "task-1": [mr] });
|
||||
});
|
||||
expect(result.current.mrs).toEqual([mr]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGitLabAvailable", () => {
|
||||
beforeEach(() => {
|
||||
fetchGitLabStatusMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns true when GitLab is authenticated", async () => {
|
||||
fetchGitLabStatusMock.mockResolvedValue(makeStatus({ authenticated: true }));
|
||||
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
|
||||
await waitFor(() => expect(result.current).toBe(true));
|
||||
});
|
||||
|
||||
it("returns true when a token is configured but probe says unauthenticated", async () => {
|
||||
// token_configured is a softer signal — the integration is set up but
|
||||
// the probe might be stale. We want the integration to appear in menus.
|
||||
fetchGitLabStatusMock.mockResolvedValue(
|
||||
makeStatus({ authenticated: false, token_configured: true }),
|
||||
);
|
||||
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
|
||||
await waitFor(() => expect(result.current).toBe(true));
|
||||
});
|
||||
|
||||
it("returns false when neither flag is set", async () => {
|
||||
fetchGitLabStatusMock.mockResolvedValue(
|
||||
makeStatus({ authenticated: false, token_configured: false }),
|
||||
);
|
||||
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
|
||||
// The probe runs once on mount — give it a tick to land.
|
||||
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalled());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the probe rejects (offline / no client)", async () => {
|
||||
fetchGitLabStatusMock.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
|
||||
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalled());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("re-probes when the window regains focus", async () => {
|
||||
fetchGitLabStatusMock.mockResolvedValue(makeStatus());
|
||||
renderHook(() => useGitLabAvailable(), { wrapper });
|
||||
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalledTimes(1));
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchGitLabStatus, listWorkspaceTaskMRs } from "@/lib/api/domains/gitlab-api";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
/**
|
||||
* Hydrate the gitlab task-MRs slice for a workspace. Fetches once per
|
||||
* workspaceId switch and clears the cache on null. Mirrors useWorkspacePRs
|
||||
* for GitHub but stays minimal (no WS subscription yet — that lands with
|
||||
* the poller in a follow-up phase).
|
||||
*/
|
||||
export function useWorkspaceMRs(workspaceId: string | null) {
|
||||
const setTaskMRs = useAppStore((state) => state.setTaskMRs);
|
||||
const resetTaskMRs = useAppStore((state) => state.resetTaskMRs);
|
||||
const fetchedRef = useRef<string | null>(null);
|
||||
const requestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
// Invalidate any in-flight request and clear the cached MRs so a
|
||||
// workspace switch / sign-out doesn't leave the previous workspace's
|
||||
// MRs visible until the next fetch.
|
||||
requestRef.current += 1;
|
||||
fetchedRef.current = null;
|
||||
resetTaskMRs();
|
||||
return;
|
||||
}
|
||||
if (fetchedRef.current === workspaceId) return;
|
||||
const requestId = ++requestRef.current;
|
||||
fetchedRef.current = workspaceId;
|
||||
listWorkspaceTaskMRs(workspaceId, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
if (requestRef.current !== requestId) return;
|
||||
setTaskMRs(response?.task_mrs ?? {});
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestRef.current === requestId) {
|
||||
fetchedRef.current = null; // allow retry on failure
|
||||
}
|
||||
});
|
||||
}, [workspaceId, setTaskMRs, resetTaskMRs]);
|
||||
}
|
||||
|
||||
// Stable empty array so the zustand selector output stays referentially
|
||||
// equal across renders when a task has no MRs. Returning a fresh [] each
|
||||
// call triggers an infinite re-render loop.
|
||||
const EMPTY_MRS: TaskMR[] = [];
|
||||
|
||||
/** Return MRs linked to a task. Reads directly from the store. */
|
||||
export function useTaskMRs(taskId: string | null): TaskMR[] {
|
||||
return useAppStore((state) =>
|
||||
taskId ? (state.taskMRs.byTaskId[taskId] ?? EMPTY_MRS) : EMPTY_MRS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether GitLab is configured enough to surface in the integrations
|
||||
* menu. Token-configured or authenticated counts as "available" — same bar
|
||||
* as useGitHubStatus's `ready` flag. Probes /status on mount + after window
|
||||
* regains focus so settings changes propagate without a hard reload.
|
||||
*/
|
||||
export function useGitLabAvailable(): boolean {
|
||||
const [available, setAvailable] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const probe = () => {
|
||||
// `cache` MUST be top-level — fetchJson reads options.cache directly
|
||||
// and overwrites init.cache with undefined. See lib/api/client.ts.
|
||||
fetchGitLabStatus({ cache: "no-store" })
|
||||
.then((s) => {
|
||||
if (!cancelled) setAvailable(Boolean(s?.authenticated || s?.token_configured));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAvailable(false);
|
||||
});
|
||||
};
|
||||
probe();
|
||||
window.addEventListener("focus", probe);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", probe);
|
||||
};
|
||||
}, []);
|
||||
return available;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { Issue, MR, MRSearchPage, IssueSearchPage } from "@/lib/types/gitlab";
|
||||
|
||||
const searchUserMRsMock = vi.fn<[unknown], Promise<MRSearchPage | null>>();
|
||||
const searchUserIssuesMock = vi.fn<[unknown], Promise<IssueSearchPage | null>>();
|
||||
|
||||
vi.mock("@/lib/api/domains/gitlab-api", () => ({
|
||||
searchUserMRs: (args: unknown) => searchUserMRsMock(args),
|
||||
searchUserIssues: (args: unknown) => searchUserIssuesMock(args),
|
||||
}));
|
||||
|
||||
import { useGitLabUserIssues, useGitLabUserMRs } from "./use-user-search";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function fakeMR(): MR {
|
||||
return {
|
||||
id: 1,
|
||||
iid: 1,
|
||||
project_id: 1,
|
||||
title: "",
|
||||
url: "",
|
||||
web_url: "",
|
||||
state: "opened",
|
||||
head_branch: "feat",
|
||||
head_sha: "",
|
||||
base_branch: "main",
|
||||
author_username: "alice",
|
||||
project_namespace: "acme",
|
||||
project_path: "acme/api",
|
||||
body: "",
|
||||
draft: false,
|
||||
merge_status: "",
|
||||
has_conflicts: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
reviewers: [],
|
||||
assignees: [],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
function fakeIssue(): Issue {
|
||||
return {
|
||||
id: 1,
|
||||
iid: 1,
|
||||
project_id: 1,
|
||||
title: "",
|
||||
body: "",
|
||||
url: "",
|
||||
web_url: "",
|
||||
state: "opened",
|
||||
author_username: "alice",
|
||||
project_namespace: "acme",
|
||||
project_path: "acme/api",
|
||||
labels: [],
|
||||
assignees: [],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useGitLabUserMRs", () => {
|
||||
beforeEach(() => {
|
||||
searchUserMRsMock.mockReset();
|
||||
});
|
||||
|
||||
it("forwards filter, query, and perPage to the API", async () => {
|
||||
searchUserMRsMock.mockResolvedValueOnce({ mrs: [], total_count: 0, page: 1, per_page: 25 });
|
||||
renderHook(() => useGitLabUserMRs("authored", "labels=bug", 25));
|
||||
await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalledTimes(1));
|
||||
expect(searchUserMRsMock).toHaveBeenCalledWith({
|
||||
filter: "authored",
|
||||
customQuery: "labels=bug",
|
||||
perPage: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("populates items on success and clears loading", async () => {
|
||||
const mr = fakeMR();
|
||||
searchUserMRsMock.mockResolvedValueOnce({ mrs: [mr], total_count: 1, page: 1, per_page: 50 });
|
||||
const { result } = renderHook(() => useGitLabUserMRs("a", ""));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.items).toEqual([mr]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces an error message and empty items on rejection", async () => {
|
||||
searchUserMRsMock.mockRejectedValueOnce(new Error("boom"));
|
||||
const { result } = renderHook(() => useGitLabUserMRs("a", ""));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("boom");
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores stale responses when inputs change mid-flight", async () => {
|
||||
let resolveFirst: (v: MRSearchPage) => void = () => {};
|
||||
searchUserMRsMock.mockReturnValueOnce(
|
||||
new Promise<MRSearchPage>((res) => {
|
||||
resolveFirst = res;
|
||||
}),
|
||||
);
|
||||
const second = fakeMR();
|
||||
searchUserMRsMock.mockResolvedValueOnce({
|
||||
mrs: [second],
|
||||
total_count: 1,
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ filter }: { filter: string }) => useGitLabUserMRs(filter, ""),
|
||||
{ initialProps: { filter: "a" } },
|
||||
);
|
||||
rerender({ filter: "b" });
|
||||
await waitFor(() => expect(result.current.items).toEqual([second]));
|
||||
|
||||
// The first request resolves *after* the swap — it must NOT clobber
|
||||
// the newer items list.
|
||||
resolveFirst({
|
||||
mrs: [fakeMR(), fakeMR()],
|
||||
total_count: 2,
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(result.current.items).toEqual([second]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGitLabUserIssues", () => {
|
||||
beforeEach(() => {
|
||||
searchUserIssuesMock.mockReset();
|
||||
});
|
||||
|
||||
it("forwards args and returns items", async () => {
|
||||
const issue = fakeIssue();
|
||||
searchUserIssuesMock.mockResolvedValueOnce({
|
||||
issues: [issue],
|
||||
total_count: 1,
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
});
|
||||
const { result } = renderHook(() => useGitLabUserIssues("assigned_to_me", ""));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.items).toEqual([issue]);
|
||||
});
|
||||
|
||||
it("handles rejection without populating items", async () => {
|
||||
searchUserIssuesMock.mockRejectedValueOnce(new Error("net"));
|
||||
const { result } = renderHook(() => useGitLabUserIssues("a", ""));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("net");
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { searchUserIssues, searchUserMRs } from "@/lib/api/domains/gitlab-api";
|
||||
import type { Issue, MR } from "@/lib/types/gitlab";
|
||||
|
||||
type SearchState<T> = {
|
||||
items: T[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function initial<T>(): SearchState<T> {
|
||||
return { items: [], loading: true, error: null };
|
||||
}
|
||||
|
||||
async function fetchMRs(
|
||||
filter: string,
|
||||
query: string,
|
||||
perPage: number,
|
||||
onResult: (s: SearchState<MR>) => void,
|
||||
) {
|
||||
onResult({ items: [], loading: true, error: null });
|
||||
try {
|
||||
const res = await searchUserMRs({ filter, customQuery: query, perPage });
|
||||
onResult({ items: res?.mrs ?? [], loading: false, error: null });
|
||||
} catch (err) {
|
||||
onResult({
|
||||
items: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Failed to load MRs",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIssues(
|
||||
filter: string,
|
||||
query: string,
|
||||
perPage: number,
|
||||
onResult: (s: SearchState<Issue>) => void,
|
||||
) {
|
||||
onResult({ items: [], loading: true, error: null });
|
||||
try {
|
||||
const res = await searchUserIssues({ filter, customQuery: query, perPage });
|
||||
onResult({ items: res?.issues ?? [], loading: false, error: null });
|
||||
} catch (err) {
|
||||
onResult({
|
||||
items: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Failed to load issues",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the current user's MRs from GitLab. Re-runs whenever filter or query change. */
|
||||
export function useGitLabUserMRs(filter: string, query: string, perPage = 50): SearchState<MR> {
|
||||
const [state, setState] = useState<SearchState<MR>>(initial);
|
||||
const requestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++requestRef.current;
|
||||
void fetchMRs(filter, query, perPage, (next) => {
|
||||
if (requestRef.current !== requestId) return;
|
||||
setState(next);
|
||||
});
|
||||
}, [filter, query, perPage]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Fetch the current user's issues from GitLab. */
|
||||
export function useGitLabUserIssues(
|
||||
filter: string,
|
||||
query: string,
|
||||
perPage = 50,
|
||||
): SearchState<Issue> {
|
||||
const [state, setState] = useState<SearchState<Issue>>(initial);
|
||||
const requestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++requestRef.current;
|
||||
void fetchIssues(filter, query, perPage, (next) => {
|
||||
if (requestRef.current !== requestId) return;
|
||||
setState(next);
|
||||
});
|
||||
}, [filter, query, perPage]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
configureGitLabHost,
|
||||
configureGitLabToken,
|
||||
clearGitLabToken,
|
||||
fetchGitLabStatus,
|
||||
listTaskMRs,
|
||||
listWorkspaceTaskMRs,
|
||||
searchUserIssues,
|
||||
searchUserMRs,
|
||||
syncTaskMR,
|
||||
} from "./gitlab-api";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const SELF_MANAGED_HOST = "https://gitlab.acme.corp";
|
||||
|
||||
function mockResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("gitlab-api — auth", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn();
|
||||
global.fetch = fetchSpy as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("fetchGitLabStatus calls /api/v1/gitlab/status", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
authenticated: true,
|
||||
username: "alice",
|
||||
auth_method: "pat",
|
||||
host: "https://gitlab.com",
|
||||
token_configured: true,
|
||||
required_scopes: ["api"],
|
||||
}),
|
||||
);
|
||||
const status = await fetchGitLabStatus();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const url = fetchSpy.mock.calls[0]![0] as string;
|
||||
expect(url).toContain("/api/v1/gitlab/status");
|
||||
expect(status.username).toBe("alice");
|
||||
expect(status.auth_method).toBe("pat");
|
||||
});
|
||||
|
||||
it("configureGitLabToken POSTs the token", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ configured: true }));
|
||||
const result = await configureGitLabToken("glpat-123");
|
||||
expect(result.configured).toBe(true);
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ token: "glpat-123" });
|
||||
});
|
||||
|
||||
it("clearGitLabToken issues DELETE", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ cleared: true }));
|
||||
await clearGitLabToken();
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("configureGitLabHost POSTs the host", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ configured: true, host: SELF_MANAGED_HOST }));
|
||||
const result = await configureGitLabHost(SELF_MANAGED_HOST);
|
||||
expect(result.host).toBe(SELF_MANAGED_HOST);
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
expect(JSON.parse(init.body as string)).toEqual({ host: SELF_MANAGED_HOST });
|
||||
});
|
||||
});
|
||||
|
||||
describe("gitlab-api — task MRs", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn();
|
||||
global.fetch = fetchSpy as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("listWorkspaceTaskMRs encodes the workspace id and hits /workspaces/:id/task-mrs", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ task_mrs: {} }));
|
||||
await listWorkspaceTaskMRs("ws id/with slash");
|
||||
const url = fetchSpy.mock.calls[0]![0] as string;
|
||||
expect(url).toContain("/api/v1/gitlab/workspaces/");
|
||||
expect(url).toContain(encodeURIComponent("ws id/with slash"));
|
||||
expect(url).toContain("/task-mrs");
|
||||
});
|
||||
|
||||
it("listTaskMRs encodes the task id and hits /tasks/:id/mrs", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ task_mrs: [] }));
|
||||
await listTaskMRs("task/123");
|
||||
const url = fetchSpy.mock.calls[0]![0] as string;
|
||||
expect(url).toContain(`/api/v1/gitlab/tasks/${encodeURIComponent("task/123")}/mrs`);
|
||||
});
|
||||
|
||||
it("syncTaskMR POSTs the project/iid body to /tasks/:id/mrs/sync", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ id: "1", task_id: "t-1", mr_iid: 99 }));
|
||||
await syncTaskMR("t-1", {
|
||||
project_path: "acme/api",
|
||||
iid: 99,
|
||||
repository_id: "repo-a",
|
||||
});
|
||||
const url = fetchSpy.mock.calls[0]![0] as string;
|
||||
expect(url).toContain("/api/v1/gitlab/tasks/t-1/mrs/sync");
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
project_path: "acme/api",
|
||||
iid: 99,
|
||||
repository_id: "repo-a",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("gitlab-api — user search", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn();
|
||||
global.fetch = fetchSpy as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("searchUserMRs builds the query string and disables cache", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ mrs: [], total_count: 0 }));
|
||||
await searchUserMRs({
|
||||
filter: "assigned_to_me",
|
||||
customQuery: "labels=bug",
|
||||
page: 2,
|
||||
perPage: 25,
|
||||
});
|
||||
const url = fetchSpy.mock.calls[0]![0] as string;
|
||||
expect(url).toContain("/api/v1/gitlab/user/mrs");
|
||||
expect(url).toContain("filter=assigned_to_me");
|
||||
// custom_query gets URL-encoded by URLSearchParams.
|
||||
expect(url).toContain(`custom_query=${encodeURIComponent("labels=bug")}`);
|
||||
expect(url).toContain("page=2");
|
||||
expect(url).toContain("per_page=25");
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
// `cache` must be at the top level of fetch init; reading from
|
||||
// options.init.cache (as an earlier revision did) silently no-ops.
|
||||
expect(init.cache).toBe("no-store");
|
||||
});
|
||||
|
||||
it("searchUserIssues builds the query string and disables cache", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ issues: [], total_count: 0 }));
|
||||
await searchUserIssues({ filter: "created_by_me", perPage: 10 });
|
||||
const url = fetchSpy.mock.calls[0]![0] as string;
|
||||
expect(url).toContain("/api/v1/gitlab/user/issues");
|
||||
expect(url).toContain("filter=created_by_me");
|
||||
expect(url).toContain("per_page=10");
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
expect(init.cache).toBe("no-store");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { fetchJson, type ApiRequestOptions } from "../client";
|
||||
import type {
|
||||
GitLabStatus,
|
||||
GitLabConfigureTokenResponse,
|
||||
GitLabClearTokenResponse,
|
||||
GitLabConfigureHostResponse,
|
||||
TaskMR,
|
||||
TaskMRsResponse,
|
||||
MR,
|
||||
Issue,
|
||||
MRSearchPage,
|
||||
IssueSearchPage,
|
||||
} from "@/lib/types/gitlab";
|
||||
|
||||
export async function fetchGitLabStatus(options?: ApiRequestOptions) {
|
||||
return fetchJson<GitLabStatus>("/api/v1/gitlab/status", options);
|
||||
}
|
||||
|
||||
export async function configureGitLabToken(token: string) {
|
||||
return fetchJson<GitLabConfigureTokenResponse>("/api/v1/gitlab/token", {
|
||||
init: { method: "POST", body: JSON.stringify({ token }) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearGitLabToken() {
|
||||
return fetchJson<GitLabClearTokenResponse>("/api/v1/gitlab/token", {
|
||||
init: { method: "DELETE" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function configureGitLabHost(host: string) {
|
||||
return fetchJson<GitLabConfigureHostResponse>("/api/v1/gitlab/host", {
|
||||
init: { method: "POST", body: JSON.stringify({ host }) },
|
||||
});
|
||||
}
|
||||
|
||||
/** List every MR association for tasks in a workspace, grouped by task ID. */
|
||||
export async function listWorkspaceTaskMRs(workspaceId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<TaskMRsResponse>(
|
||||
`/api/v1/gitlab/workspaces/${encodeURIComponent(workspaceId)}/task-mrs`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** List the MRs linked to a single task. */
|
||||
export async function listTaskMRs(taskId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<{ task_mrs: TaskMR[] | null }>(
|
||||
`/api/v1/gitlab/tasks/${encodeURIComponent(taskId)}/mrs`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a task↔MR row from GitLab. Used by the `pr` skill after creating an MR
|
||||
* and by the topbar's manual refresh. project_path is "namespace/path".
|
||||
*/
|
||||
export async function syncTaskMR(
|
||||
taskId: string,
|
||||
body: { project_path: string; iid: number; repository_id?: string },
|
||||
) {
|
||||
return fetchJson<TaskMR>(`/api/v1/gitlab/tasks/${encodeURIComponent(taskId)}/mrs/sync`, {
|
||||
init: { method: "POST", body: JSON.stringify(body) },
|
||||
});
|
||||
}
|
||||
|
||||
/** Search the current user's MRs. filter is one of "assigned", "authored",
|
||||
* "review_requested" (matches GitLab's `scope` query param). */
|
||||
export async function searchUserMRs(params: {
|
||||
filter?: string;
|
||||
customQuery?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.filter) qs.set("filter", params.filter);
|
||||
if (params.customQuery) qs.set("custom_query", params.customQuery);
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
if (params.perPage) qs.set("per_page", String(params.perPage));
|
||||
return fetchJson<MRSearchPage>(`/api/v1/gitlab/user/mrs?${qs.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
/** Search the current user's issues. */
|
||||
export async function searchUserIssues(params: {
|
||||
filter?: string;
|
||||
customQuery?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.filter) qs.set("filter", params.filter);
|
||||
if (params.customQuery) qs.set("custom_query", params.customQuery);
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
if (params.perPage) qs.set("per_page", String(params.perPage));
|
||||
return fetchJson<IssueSearchPage>(`/api/v1/gitlab/user/issues?${qs.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
}
|
||||
|
||||
export type { MR, Issue, MRSearchPage, IssueSearchPage };
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
defaultSessionRuntimeState,
|
||||
defaultUIState,
|
||||
defaultGitHubState,
|
||||
defaultGitLabState,
|
||||
defaultJiraState,
|
||||
defaultLinearState,
|
||||
defaultOfficeState,
|
||||
@@ -67,6 +68,7 @@ export const defaultState = {
|
||||
reviewWatches: defaultGitHubState.reviewWatches,
|
||||
issueWatches: defaultGitHubState.issueWatches,
|
||||
actionPresets: defaultGitHubState.actionPresets,
|
||||
taskMRs: defaultGitLabState.taskMRs,
|
||||
jiraIssueWatches: defaultJiraState.jiraIssueWatches,
|
||||
linearIssueWatches: defaultLinearState.linearIssueWatches,
|
||||
office: defaultOfficeState.office,
|
||||
@@ -157,6 +159,7 @@ export function mergeInitialState(initialState?: Partial<DefaultState>): Default
|
||||
reviewWatches: { ...defaultState.reviewWatches, ...initialState.reviewWatches },
|
||||
issueWatches: { ...defaultState.issueWatches, ...initialState.issueWatches },
|
||||
actionPresets: { ...defaultState.actionPresets, ...initialState.actionPresets },
|
||||
taskMRs: { ...defaultState.taskMRs, ...initialState.taskMRs },
|
||||
jiraIssueWatches: { ...defaultState.jiraIssueWatches, ...initialState.jiraIssueWatches },
|
||||
linearIssueWatches: {
|
||||
...defaultState.linearIssueWatches,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { create } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { createGitLabSlice } from "./gitlab-slice";
|
||||
import type { GitLabSlice } from "./types";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
|
||||
return {
|
||||
id: "id",
|
||||
task_id: "task-1",
|
||||
host: "https://gitlab.com",
|
||||
project_path: "acme/api",
|
||||
mr_iid: 1,
|
||||
mr_url: "https://gitlab.com/acme/api/-/merge_requests/1",
|
||||
mr_title: "Test MR",
|
||||
head_branch: "feat",
|
||||
base_branch: "main",
|
||||
author_username: "alice",
|
||||
state: "open",
|
||||
approval_state: "",
|
||||
pipeline_state: "",
|
||||
merge_status: "",
|
||||
draft: false,
|
||||
approval_count: 0,
|
||||
required_approvals: 0,
|
||||
pipeline_jobs_total: 0,
|
||||
pipeline_jobs_pass: 0,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
return create<GitLabSlice>()(immer((...a) => createGitLabSlice(...a)));
|
||||
}
|
||||
|
||||
describe("setTaskMRs", () => {
|
||||
it("replaces byTaskId wholesale", () => {
|
||||
const store = makeStore();
|
||||
const mrA = makeMR({ id: "a" });
|
||||
store.getState().setTaskMRs({ "task-1": [mrA] });
|
||||
expect(store.getState().taskMRs.byTaskId["task-1"]).toEqual([mrA]);
|
||||
|
||||
store.getState().setTaskMRs({ "task-2": [makeMR({ id: "b" })] });
|
||||
expect(store.getState().taskMRs.byTaskId).not.toHaveProperty("task-1");
|
||||
expect(store.getState().taskMRs.byTaskId["task-2"]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTaskMR", () => {
|
||||
it("appends an MR when the task has no rows yet", () => {
|
||||
const store = makeStore();
|
||||
const mr = makeMR({ repository_id: "repo-a" });
|
||||
|
||||
store.getState().setTaskMR("task-1", mr);
|
||||
|
||||
expect(store.getState().taskMRs.byTaskId["task-1"]).toEqual([mr]);
|
||||
});
|
||||
|
||||
it("upserts the same (repo, project, iid) key in place", () => {
|
||||
const store = makeStore();
|
||||
const original = makeMR({ id: "a", repository_id: "repo-a", mr_iid: 5 });
|
||||
const updated = makeMR({
|
||||
id: "a",
|
||||
repository_id: "repo-a",
|
||||
mr_iid: 5,
|
||||
mr_title: "renamed",
|
||||
});
|
||||
|
||||
store.getState().setTaskMR("task-1", original);
|
||||
store.getState().setTaskMR("task-1", updated);
|
||||
|
||||
const list = store.getState().taskMRs.byTaskId["task-1"];
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]!.mr_title).toBe("renamed");
|
||||
});
|
||||
|
||||
it("keeps multi-repo MRs distinct on (repository_id, project_path, mr_iid)", () => {
|
||||
const store = makeStore();
|
||||
// Same project + iid, different repositories — must coexist.
|
||||
const repoA = makeMR({ id: "a", repository_id: "repo-a", mr_iid: 1 });
|
||||
const repoB = makeMR({ id: "b", repository_id: "repo-b", mr_iid: 1 });
|
||||
// Same repo, different project — must also coexist.
|
||||
const repoAOtherProject = makeMR({
|
||||
id: "c",
|
||||
repository_id: "repo-a",
|
||||
project_path: "acme/web",
|
||||
mr_iid: 1,
|
||||
});
|
||||
// Same repo + project, different iid — must coexist (multiple open MRs).
|
||||
const repoASecondMR = makeMR({ id: "d", repository_id: "repo-a", mr_iid: 99 });
|
||||
|
||||
store.getState().setTaskMR("task-1", repoA);
|
||||
store.getState().setTaskMR("task-1", repoB);
|
||||
store.getState().setTaskMR("task-1", repoAOtherProject);
|
||||
store.getState().setTaskMR("task-1", repoASecondMR);
|
||||
|
||||
const list = store.getState().taskMRs.byTaskId["task-1"];
|
||||
expect(list).toHaveLength(4);
|
||||
expect(list.map((m) => m.id).sort()).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("treats missing repository_id as the empty key (single-repo tasks)", () => {
|
||||
// A multi-repo MR with repository_id and a single-repo MR with the same
|
||||
// (project_path, mr_iid) but no repository_id must still coexist — the
|
||||
// empty repo key is its own slot.
|
||||
const store = makeStore();
|
||||
const noRepo = makeMR({ id: "x", mr_iid: 1 });
|
||||
const withRepo = makeMR({ id: "y", repository_id: "repo-a", mr_iid: 1 });
|
||||
|
||||
store.getState().setTaskMR("task-1", noRepo);
|
||||
store.getState().setTaskMR("task-1", withRepo);
|
||||
|
||||
const list = store.getState().taskMRs.byTaskId["task-1"];
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list.map((m) => m.id).sort()).toEqual(["x", "y"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetTaskMRs", () => {
|
||||
it("clears byTaskId so a workspace switch doesn't leak previous MRs", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setTaskMR("task-1", makeMR({ id: "a" }));
|
||||
expect(store.getState().taskMRs.byTaskId["task-1"]).toHaveLength(1);
|
||||
|
||||
store.getState().resetTaskMRs();
|
||||
|
||||
expect(store.getState().taskMRs.byTaskId).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { GitLabSlice, GitLabSliceState } from "./types";
|
||||
|
||||
export const defaultGitLabState: GitLabSliceState = {
|
||||
taskMRs: { byTaskId: {} },
|
||||
};
|
||||
|
||||
type ImmerSet = Parameters<
|
||||
StateCreator<GitLabSlice, [["zustand/immer", never]], [], GitLabSlice>
|
||||
>[0];
|
||||
|
||||
export const createGitLabSlice: StateCreator<
|
||||
GitLabSlice,
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
GitLabSlice
|
||||
> = (set: ImmerSet) => ({
|
||||
...defaultGitLabState,
|
||||
setTaskMRs: (mrs) =>
|
||||
set((draft) => {
|
||||
draft.taskMRs.byTaskId = mrs;
|
||||
}),
|
||||
setTaskMR: (taskId, mr) =>
|
||||
set((draft) => {
|
||||
const existing = draft.taskMRs.byTaskId[taskId] ?? [];
|
||||
// Key by (repository_id, project_path, mr_iid) so multi-repo and
|
||||
// multiple MRs for the same task coexist. Mirrors the backend's
|
||||
// UNIQUE constraint on gitlab_task_mrs.
|
||||
const repoKey = mr.repository_id ?? "";
|
||||
const idx = existing.findIndex(
|
||||
(m) =>
|
||||
(m.repository_id ?? "") === repoKey &&
|
||||
m.project_path === mr.project_path &&
|
||||
m.mr_iid === mr.mr_iid,
|
||||
);
|
||||
if (idx >= 0) existing[idx] = mr;
|
||||
else existing.push(mr);
|
||||
draft.taskMRs.byTaskId[taskId] = existing;
|
||||
}),
|
||||
resetTaskMRs: () =>
|
||||
set((draft) => {
|
||||
draft.taskMRs.byTaskId = {};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
export type TaskMRsState = {
|
||||
/** Each task may have multiple MRs (one per repository for multi-repo tasks). */
|
||||
byTaskId: Record<string, TaskMR[]>;
|
||||
};
|
||||
|
||||
export type GitLabSliceState = {
|
||||
taskMRs: TaskMRsState;
|
||||
};
|
||||
|
||||
export type GitLabSliceActions = {
|
||||
setTaskMRs: (mrs: Record<string, TaskMR[]>) => void;
|
||||
/**
|
||||
* Upsert a single MR for a task — keyed by (repository_id, project_path,
|
||||
* mr_iid) so multi-repo + multi-MR scenarios coexist without overwriting.
|
||||
*/
|
||||
setTaskMR: (taskId: string, mr: TaskMR) => void;
|
||||
/** Clear all task MR state. Used on workspace switch / sign-out. */
|
||||
resetTaskMRs: () => void;
|
||||
};
|
||||
|
||||
export type GitLabSlice = GitLabSliceState & GitLabSliceActions;
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createSessionRuntimeSlice,
|
||||
createUISlice,
|
||||
createGitHubSlice,
|
||||
createGitLabSlice,
|
||||
createJiraSlice,
|
||||
createLinearSlice,
|
||||
createOfficeSlice,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
defaultSessionRuntimeState,
|
||||
defaultUIState,
|
||||
defaultGitHubState,
|
||||
defaultGitLabState,
|
||||
defaultJiraState,
|
||||
defaultLinearState,
|
||||
defaultOfficeState,
|
||||
@@ -88,6 +90,7 @@ import type {
|
||||
// Re-export all types from slices for backwards compatibility (split out
|
||||
// to keep this file under the max-lines limit).
|
||||
export type * from "./store-reexports";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import type { JiraIssueWatch } from "@/lib/types/jira";
|
||||
import type { LinearIssueWatch } from "@/lib/types/linear";
|
||||
import type {
|
||||
@@ -186,6 +189,9 @@ export type AppState = {
|
||||
actionPresets: (typeof defaultGitHubState)["actionPresets"];
|
||||
prFeedbackCache: (typeof defaultGitHubState)["prFeedbackCache"];
|
||||
|
||||
// GitLab slice
|
||||
taskMRs: (typeof defaultGitLabState)["taskMRs"];
|
||||
|
||||
// JIRA slice
|
||||
jiraIssueWatches: (typeof defaultJiraState)["jiraIssueWatches"];
|
||||
|
||||
@@ -246,6 +252,11 @@ export type AppState = {
|
||||
setPRFeedbackCacheEntry: (key: string, feedback: PRFeedback) => void;
|
||||
removePRFeedbackCacheEntry: (key: string) => void;
|
||||
|
||||
// GitLab actions
|
||||
setTaskMRs: (mrs: Record<string, TaskMR[]>) => void;
|
||||
setTaskMR: (taskId: string, mr: TaskMR) => void;
|
||||
resetTaskMRs: () => void;
|
||||
|
||||
// JIRA actions
|
||||
setJiraIssueWatches: (watches: JiraIssueWatch[]) => void;
|
||||
setJiraIssueWatchesLoading: (loading: boolean) => void;
|
||||
@@ -561,6 +572,8 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...createGitHubSlice(set as any, get as any, api as any),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...createGitLabSlice(set as any, get as any, api as any),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...createJiraSlice(set as any, get as any, api as any),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...createLinearSlice(set as any, get as any, api as any),
|
||||
@@ -622,6 +635,7 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
reviewWatches: merged.reviewWatches,
|
||||
issueWatches: merged.issueWatches,
|
||||
actionPresets: merged.actionPresets,
|
||||
taskMRs: merged.taskMRs,
|
||||
jiraIssueWatches: merged.jiraIssueWatches,
|
||||
linearIssueWatches: merged.linearIssueWatches,
|
||||
office: merged.office,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Connection status for the GitLab integration. Returned by
|
||||
* `GET /api/v1/gitlab/status` and shaped by `internal/gitlab.Status`.
|
||||
*/
|
||||
export type GitLabStatus = {
|
||||
authenticated: boolean;
|
||||
username: string;
|
||||
auth_method: "glab_cli" | "pat" | "none" | "mock";
|
||||
host: string;
|
||||
token_configured: boolean;
|
||||
token_secret_id?: string;
|
||||
glab_version?: string;
|
||||
glab_outdated?: boolean;
|
||||
required_scopes: string[];
|
||||
diagnostics?: GitLabAuthDiagnostics;
|
||||
/**
|
||||
* Transport-layer error from the most recent `IsAuthenticated` probe
|
||||
* (network down, 5xx, parse failure). Distinct from `authenticated:
|
||||
* false` with no `connection_error`, which means a 401/403 / no token
|
||||
* configured. Present so the UI can render "GitLab unreachable" instead
|
||||
* of "not connected" during an outage and stop users from rotating an
|
||||
* actually-valid token.
|
||||
*/
|
||||
connection_error?: string;
|
||||
};
|
||||
|
||||
export type GitLabAuthDiagnostics = {
|
||||
command: string;
|
||||
output: string;
|
||||
exit_code: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Task ↔ MR association — parallel to github's TaskPR. Surfaces what the MR
|
||||
* topbar button needs to render (state + counts + a click target). Backend
|
||||
* row in `gitlab_task_mrs`.
|
||||
*/
|
||||
export type TaskMR = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
repository_id?: string;
|
||||
host: string;
|
||||
project_path: string;
|
||||
mr_iid: number;
|
||||
mr_url: string;
|
||||
mr_title: string;
|
||||
head_branch: string;
|
||||
base_branch: string;
|
||||
author_username: string;
|
||||
state: "open" | "closed" | "merged" | "locked" | string;
|
||||
approval_state: "" | "approved" | "pending" | string;
|
||||
pipeline_state: "" | "success" | "failure" | "pending" | string;
|
||||
merge_status: string;
|
||||
draft: boolean;
|
||||
approval_count: number;
|
||||
required_approvals: number;
|
||||
pipeline_jobs_total: number;
|
||||
pipeline_jobs_pass: number;
|
||||
created_at: string;
|
||||
merged_at?: string;
|
||||
closed_at?: string;
|
||||
last_synced_at?: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/** Response shape for `GET /api/v1/gitlab/workspaces/:id/task-mrs`. */
|
||||
export type TaskMRsResponse = {
|
||||
task_mrs: Record<string, TaskMR[]>;
|
||||
};
|
||||
|
||||
/** Merge request returned by /api/v1/gitlab/user/mrs (matches backend MR). */
|
||||
export type MR = {
|
||||
id: number;
|
||||
iid: number;
|
||||
project_id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
web_url: string;
|
||||
state: "open" | "closed" | "merged" | "locked" | "opened" | string;
|
||||
head_branch: string;
|
||||
head_sha: string;
|
||||
base_branch: string;
|
||||
author_username: string;
|
||||
project_namespace: string;
|
||||
project_path: string;
|
||||
body: string;
|
||||
draft: boolean;
|
||||
merge_status: string;
|
||||
has_conflicts: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
reviewers: { username: string; name: string; type: string }[];
|
||||
assignees: { username: string; name: string; type: string }[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
merged_at?: string;
|
||||
closed_at?: string;
|
||||
};
|
||||
|
||||
/** Issue returned by /api/v1/gitlab/user/issues. */
|
||||
export type Issue = {
|
||||
id: number;
|
||||
iid: number;
|
||||
project_id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
web_url: string;
|
||||
state: "opened" | "closed" | string;
|
||||
author_username: string;
|
||||
project_namespace: string;
|
||||
project_path: string;
|
||||
labels: string[];
|
||||
assignees: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at?: string;
|
||||
};
|
||||
|
||||
export type MRSearchPage = {
|
||||
mrs: MR[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
};
|
||||
|
||||
export type IssueSearchPage = {
|
||||
issues: Issue[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
};
|
||||
|
||||
export type GitLabConfigureTokenResponse = { configured: boolean };
|
||||
export type GitLabClearTokenResponse = { cleared: boolean };
|
||||
export type GitLabConfigureHostResponse = { configured: boolean; host: string };
|
||||
|
||||
export type GitLabMRNote = {
|
||||
id: number;
|
||||
author: string;
|
||||
author_avatar?: string;
|
||||
author_is_bot?: boolean;
|
||||
body: string;
|
||||
type?: string;
|
||||
system?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type GitLabMRDiscussion = {
|
||||
id: string;
|
||||
resolvable: boolean;
|
||||
resolved: boolean;
|
||||
notes: GitLabMRNote[];
|
||||
path?: string;
|
||||
line?: number;
|
||||
old_line?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
# GitLab Integration — Implementation Plan
|
||||
|
||||
**Spec:** `./spec.md`
|
||||
**Issue:** [#820](https://github.com/kdlbs/kandev/issues/820)
|
||||
|
||||
## 1. Architecture decisions
|
||||
|
||||
**One package, mirror don't merge.** A new `internal/gitlab/` package mirrors
|
||||
the layout of `internal/github/`. Do **not** introduce a unifying
|
||||
`codehost.Client` interface in v1. The GitHub package is ~14k LOC and encodes
|
||||
deep GitHub-specific knowledge (search query DSL, review states, mergeable
|
||||
states, action presets). Premature abstraction would hurt both packages.
|
||||
Defer the unifying interface to v2 — once the GitLab package is real, the
|
||||
shared shape is concrete instead of guessed.
|
||||
|
||||
**Two clients, both shipped in v1.** Implement `pat_client.go` against
|
||||
GitLab REST API v4 (Go HTTP client, similar shape to
|
||||
`internal/github/pat_client.go`) **and** `glab_client.go` that shells out
|
||||
to the `glab` CLI (similar shape to `internal/github/gh_client.go`).
|
||||
`factory.go` selects: mock env var → `glab` CLI if installed and
|
||||
authenticated → PAT (env var, then secrets store) → noop. Same selection
|
||||
order as the GitHub package so users with `glab` already configured pick
|
||||
up zero-config, and PAT remains the path for headless / corp-managed
|
||||
environments.
|
||||
|
||||
**Auth: token-based, per-workspace host.** Token in `secrets.SecretStore`
|
||||
under name `GITLAB_TOKEN` (env var `GITLAB_TOKEN` also accepted). Self-managed
|
||||
host URL stored as a per-workspace setting (`gitlab_host`, default
|
||||
`https://gitlab.com`). Per-workspace because different workspaces commonly
|
||||
target different GitLab instances (work vs personal).
|
||||
|
||||
**Data model: parallel tables, shared field semantics.** New tables
|
||||
`gitlab_mr_watches`, `gitlab_review_watches`, `gitlab_issue_watches`,
|
||||
`gitlab_action_presets` parallel to their `github_*` counterparts. The
|
||||
existing `Repository.Provider` field already supports `"gitlab"`; no schema
|
||||
change there. PR/MR data is **not** unified — they're sibling concepts with
|
||||
different fields (e.g. GitLab pipelines vs GitHub checks, GitLab approvals
|
||||
vs GitHub reviews).
|
||||
|
||||
**`pr` skill: provider-aware, single skill.** Update `.agents/skills/pr/`
|
||||
to detect the repo provider from `git remote` and branch on host. For GitLab
|
||||
repos, use REST API directly via `curl`+`GITLAB_TOKEN` — no `glab`
|
||||
dependency in the agent container.
|
||||
|
||||
## 2. File map
|
||||
|
||||
### Backend — new package `internal/gitlab/`
|
||||
|
||||
Mirrors `internal/github/` (~40 files) but deliberately smaller in v1. Files
|
||||
to create:
|
||||
|
||||
- `models.go` — `MR`, `MRReview` (approvals), `MRComment`, `Pipeline`,
|
||||
`MRFeedback`, `MRStatus`, `MRSearchPage`, `Issue`, `IssueSearchPage`,
|
||||
`Project`, `RepoBranch`, `MRWatch`, `ReviewWatch`, `IssueWatch`. Shape
|
||||
parallel to `github/models.go` so frontend stores can share component
|
||||
families.
|
||||
- `client.go` — `Client` interface (parallel to GitHub's, but with
|
||||
GitLab nouns: `GetMR`, `FindMRByBranch`, `ListAuthoredMRs`,
|
||||
`ListReviewRequestedMRs`, `SubmitMRApproval`, `ListPipelines`,
|
||||
`GetMRDiscussions`, `ResolveDiscussion`, `CreateMRDiscussionNote`).
|
||||
- `pat_client.go` — REST v4 implementation. Per-instance host URL.
|
||||
Uses `PRIVATE-TOKEN` header for auth. Methods needed:
|
||||
- Projects: `/projects?membership=true`, `/projects/:id`, `/projects/:id/repository/branches`.
|
||||
- MRs: `/projects/:id/merge_requests`, `/merge_requests` (search),
|
||||
`/projects/:id/merge_requests/:iid`,
|
||||
`/projects/:id/merge_requests/:iid/discussions` (read + post + resolve),
|
||||
`/projects/:id/merge_requests/:iid/approvals`,
|
||||
`/projects/:id/merge_requests/:iid/changes`,
|
||||
`/projects/:id/merge_requests/:iid/commits`.
|
||||
- Pipelines: `/projects/:id/pipelines`, `/projects/:id/pipelines/:pipeline_id/jobs`.
|
||||
- Issues: `/issues` (search), `/projects/:id/issues/:iid`.
|
||||
- User: `/user` (auth check + login).
|
||||
- `pat_client_test.go` — table-driven tests with `httptest.Server` per
|
||||
endpoint group, mirroring `github/pat_client_test.go`.
|
||||
- `glab_client.go` — shells out to `glab` CLI for the same `Client`
|
||||
interface. Mirrors `internal/github/gh_client.go`: `GLabAvailable()`
|
||||
detects the binary, `IsAuthenticated()` runs `glab auth status`, and
|
||||
each method composes a `glab api …` or `glab mr …` invocation,
|
||||
parsing JSON stdout. Where `glab` lacks a direct command (e.g. specific
|
||||
search filters), fall through to `glab api` against the REST endpoint.
|
||||
- `glab_client_test.go` — uses `exec.LookPath` shimming and a
|
||||
fake `glab` binary on `PATH` (same pattern as `gh_client_test.go`).
|
||||
- `mock_client.go` + `mock_client_test.go` + `mock_controller.go` —
|
||||
E2E mock surface, gated by `KANDEV_MOCK_GITLAB=true`. Uses the
|
||||
`internal/integrations` shared shapes? **No** — copy the GitHub pattern
|
||||
(the mock currently lives outside `internal/integrations`). Match
|
||||
whichever pattern the team has standardized on (Linear/Jira use
|
||||
`internal/integrations`; GitHub does not). For consistency with
|
||||
GitHub-the-code-host, follow the GitHub pattern.
|
||||
- `noop_client.go` — null-object fallback when unauthenticated.
|
||||
- `factory.go` — `NewClient(ctx, secrets, host, log)`. Order: mock env
|
||||
var → `glab` CLI (if installed and authenticated for the configured
|
||||
host) → PAT (env var `GITLAB_TOKEN`, then secrets store key
|
||||
`GITLAB_TOKEN`) → noop.
|
||||
- `service.go` — coordinator parallel to `github.Service`. Accepts the
|
||||
per-workspace host. Methods: status, MR list/search, MR feedback (incl.
|
||||
discussions), watches CRUD, action presets CRUD, sync. **Use
|
||||
`internal/integrations/healthpoll`** for the auth-health loop — that's
|
||||
the shared utility Jira/Linear already use, and the GitHub package
|
||||
predates it. New code should pick the better pattern.
|
||||
- `service_*_test.go` — tests for issue, status, sync, watches, search
|
||||
cache (parallel to GitHub's split).
|
||||
- `service_task_events.go` — subscribes to task events and resyncs when
|
||||
MRs are linked. Mirror GitHub.
|
||||
- `store.go` — SQLx persistence for the four new tables + watches/presets
|
||||
read/write. Migrations next.
|
||||
- `store_test.go` — multi-project / multi-workspace round-trip.
|
||||
- `controller.go` — HTTP routes under `/api/v1/gitlab/*`:
|
||||
`GET /status`, `GET/POST/PUT/DELETE /mr-watches`,
|
||||
`GET/POST/PUT/DELETE /review-watches`,
|
||||
`GET/POST/PUT/DELETE /issue-watches`,
|
||||
`GET /mrs/:projectId/:iid/feedback`, etc.
|
||||
- `handlers.go` — WS dispatcher registrations
|
||||
(`ActionGitLabStatus`, `ActionGitLabMRList`, …).
|
||||
- `poller.go` — review-watch + issue-watch background loop. Reuse
|
||||
`healthpoll.Poller` for auth health; keep MR/issue polling in this
|
||||
package (different cadence, payload-shaped).
|
||||
- `connectivity.go`, `constants.go`, `ttl_cache.go` — verbatim parallels.
|
||||
- `provider.go` — `Provide(writer, reader *sqlx.DB, secrets SecretStore,
|
||||
eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error)`.
|
||||
|
||||
### Backend — schema migrations
|
||||
|
||||
- `apps/backend/internal/task/repository/migrations/NNNN_gitlab_tables.sql` —
|
||||
create `gitlab_mr_watches`, `gitlab_review_watches`,
|
||||
`gitlab_issue_watches`, `gitlab_action_presets`. Use `gitlab_*` prefix
|
||||
on every column to avoid collisions when the unifying refactor lands.
|
||||
- `apps/backend/internal/workspace/...` — add `gitlab_host` column to the
|
||||
workspace settings table (or, if workspace settings are JSON, add the
|
||||
field to the JSON shape). Default `https://gitlab.com`.
|
||||
|
||||
### Backend — wiring
|
||||
|
||||
- `cmd/kandev/services.go` — new `initGitLabService(...)` helper that
|
||||
resolves the per-workspace host (or a global default for unscoped
|
||||
callers) and wires the service.
|
||||
- `cmd/kandev/helpers.go` — register routes via `gitlab.RegisterRoutes`
|
||||
and `gitlab.RegisterMockRoutes`.
|
||||
- `internal/repoclone/protocol.go` — replace the `gitlab.com` hardcode
|
||||
with a host parameter. `CloneURL` signature gains `host string`. Update
|
||||
call sites in `internal/agent/lifecycle/executor_*.go` and
|
||||
`internal/orchestrator/executor/executor_execute.go` to pass the
|
||||
workspace's `gitlab_host` when `provider == "gitlab"`.
|
||||
- `internal/agent/credentials/env_provider.go` — already lists
|
||||
`GITLAB_TOKEN` in `knownAPIKeyPatterns`. No change required.
|
||||
- `internal/agent/lifecycle/default_scripts.go` — extend the default
|
||||
prepare script so HTTPS clones for GitLab use
|
||||
`https://oauth2:${GITLAB_TOKEN}@<host>/<owner>/<name>.git`. Existing
|
||||
GitHub equivalent is the model.
|
||||
|
||||
### Frontend — new pages and components
|
||||
|
||||
- `apps/web/app/gitlab/page.tsx` + `gitlab-page-client.tsx` — mirror of
|
||||
`app/github/`. SSR fetches `/api/v1/gitlab/status`, hydrates store.
|
||||
- `apps/web/app/settings/integrations/gitlab/page.tsx` — mirror of
|
||||
`settings/integrations/github/page.tsx`. Hosts the
|
||||
`GitLabIntegrationPage` panel: connection status banner, host URL
|
||||
field, PAT field, reconnect CTA.
|
||||
- `apps/web/components/gitlab/` — domain components. The MyGitHub
|
||||
component family (`my-github/pr-list`, `my-github/issue-list`,
|
||||
presets sidebar, search bar, action presets, save-preset dialog,
|
||||
pagination, list toolbar, quick task launcher) is large and tightly
|
||||
coupled to the GitHub data shape. Two viable approaches:
|
||||
- **(A) Extract shared components.** Move generic pieces (search bar,
|
||||
pagination, save-preset dialog, list toolbar, presets sidebar
|
||||
skeleton) into `components/code-host/` parameterised on item shape.
|
||||
Wire both `github` and `gitlab` to them. Higher upfront cost, no
|
||||
duplication.
|
||||
- **(B) Copy-and-adapt.** Duplicate the components folder, swap PR
|
||||
types for MR types. Lower upfront cost, two parallel codebases to
|
||||
maintain.
|
||||
- **Recommendation: (A) for shells, (B) for body.** Extract the
|
||||
container components (sidebar, toolbar, pagination, save dialog) —
|
||||
they're already provider-agnostic in shape. Copy the row renderers
|
||||
(`pr-row.tsx` → `mr-row.tsx`) where the visible fields differ
|
||||
(approvals vs reviews, pipelines vs checks).
|
||||
- `apps/web/lib/state/slices/gitlab/` — new slice mirroring
|
||||
`slices/github/`. Same hydration patterns.
|
||||
- `apps/web/hooks/domains/gitlab/` — `use-gitlab-status`, watches,
|
||||
search, action presets. Symmetric to `hooks/domains/github/`.
|
||||
- `apps/web/lib/api/domains/gitlab-api.ts` — REST client.
|
||||
- `apps/web/lib/types/gitlab.ts` — `GitLabMR`, `GitLabIssue`, etc.
|
||||
|
||||
### Frontend — kanban top-bar
|
||||
|
||||
- `apps/web/components/kanban/kanban-header.tsx` — add
|
||||
`GitLabTopbarButton` next to `GitHubTopbarButton`. Render only when
|
||||
GitLab is configured for the active workspace (gate on
|
||||
`useGitLabAvailable`).
|
||||
- `apps/web/components/kanban/kanban-header-mobile.tsx` — same.
|
||||
|
||||
### Agent skill — `pr`
|
||||
|
||||
- `.agents/skills/pr/SKILL.md` — add a "GitLab repos" section
|
||||
explaining the detection (`git remote get-url origin` matches
|
||||
`:gitlab|/gitlab`) and the GitLab MR-creation flow. Prefer `glab` CLI
|
||||
if available in the agent container; fall back to REST API via `curl`
|
||||
with `GITLAB_TOKEN`. Keep `gh` as the GitHub path.
|
||||
- `.claude/skills/pr/SKILL.md` symlink — already symlinked, no change.
|
||||
|
||||
### WebSocket actions
|
||||
|
||||
- `pkg/websocket/actions.go` (or wherever GitHub actions are declared) —
|
||||
add `ActionGitLabStatus`, `ActionGitLabMRList`, `ActionGitLabMRGet`,
|
||||
`ActionGitLabMRFeedbackGet`, watches (review/issue/MR), pipeline
|
||||
status, action presets, stats.
|
||||
|
||||
## 3. Phases
|
||||
|
||||
Each phase ends with `make fmt` then `make typecheck test lint` (backend)
|
||||
and `pnpm typecheck test lint` (web).
|
||||
|
||||
### Phase 1 — Backend client + auth (no UI)
|
||||
|
||||
Goal: the backend can list one user's MRs over REST.
|
||||
|
||||
- `internal/gitlab/{models,client,pat_client,glab_client,noop_client,
|
||||
factory,connectivity,constants}.go` plus tests.
|
||||
- Mock client + controller. Wired under `KANDEV_MOCK_GITLAB=true`.
|
||||
- Provider + `cmd/kandev/services.go` `initGitLabService`.
|
||||
- Status WS handler + `/api/v1/gitlab/status` HTTP route.
|
||||
- Migration for the new tables (kept empty-but-present so future phases
|
||||
can write).
|
||||
- Tests: PAT client per-endpoint, noop client, factory selection,
|
||||
controller status route.
|
||||
|
||||
### Phase 2 — Repo cloning + agent env
|
||||
|
||||
Goal: a GitLab repo can be cloned and the agent has the token.
|
||||
|
||||
- `repoclone/protocol.go` host parameter. Call-site updates.
|
||||
- `lifecycle/default_scripts.go` HTTPS-with-token rewrite for GitLab.
|
||||
- Workspace `gitlab_host` setting (read/write API + UI field deferred
|
||||
to Phase 3).
|
||||
- Tests: protocol unit test for SSH/HTTPS variants on custom host.
|
||||
Lifecycle prepare-script test for token injection.
|
||||
|
||||
### Phase 3 — Settings page + connection UX
|
||||
|
||||
Goal: a user can connect/reconnect a GitLab account from the UI.
|
||||
|
||||
- `app/settings/integrations/gitlab/page.tsx` + panel.
|
||||
- API endpoints: get/set host, set/clear token.
|
||||
- Reuse `<IntegrationAuthStatusBanner>` and
|
||||
`<IntegrationAuthErrorMessage>` from `internal/integrations` if those
|
||||
shells fit (they were built for Linear/Jira; check shape compat first).
|
||||
- Tests: settings panel render + token-save flow (vitest).
|
||||
|
||||
### Phase 4 — MR list page + watches
|
||||
|
||||
Goal: full feature parity with the GitHub page for browse + watch.
|
||||
|
||||
- Extract shared shells from GitHub MyGitHub components into
|
||||
`components/code-host/`.
|
||||
- New `components/gitlab/my-gitlab/` — row renderers, search-bar
|
||||
preset definitions, action-presets resolution.
|
||||
- `app/gitlab/page.tsx` + client.
|
||||
- Watch CRUD WS handlers + UI.
|
||||
- `gitlab` slice + hooks.
|
||||
- Poller for review-watches and issue-watches (uses
|
||||
`healthpoll.Poller` for auth-health; MR/issue polling in
|
||||
`gitlab/poller.go`).
|
||||
- Tests: store slice, hooks, save-preset, search.
|
||||
|
||||
### Phase 5 — MR review surface (#820 core)
|
||||
|
||||
Goal: the agent UI shows MR feedback (discussions, approvals, pipeline)
|
||||
and the agent can post/resolve discussions.
|
||||
|
||||
- `GetMRDiscussions` + `CreateMRDiscussionNote` + `ResolveDiscussion`
|
||||
in `pat_client.go`.
|
||||
- `MRFeedback` payload + `WS ActionGitLabMRFeedbackGet`.
|
||||
- Frontend: in the existing PR-feedback panel that lives next to a
|
||||
task, branch on `provider`. Reuse the comments/threads UI; map
|
||||
GitLab discussions onto the same shape (discussion = thread, notes =
|
||||
comments, `resolved` flag).
|
||||
- Tests: pat_client discussions round-trip, frontend feedback panel
|
||||
with a GitLab fixture.
|
||||
|
||||
### Phase 6 — `pr` skill update + #820 wiring
|
||||
|
||||
Goal: clicking "Create PR" on a GitLab task opens a real MR.
|
||||
|
||||
- `.agents/skills/pr/SKILL.md` — GitLab branch.
|
||||
- The kanban "Create PR" button already runs the `pr` skill on the
|
||||
task; no new wiring needed. Verify the surfaced MR URL flows back
|
||||
through the existing PR-watch path (Phase 4 watches consume it).
|
||||
- Manual smoke: open a kandev workspace pointing at a GitLab repo,
|
||||
edit, click Create PR, watch the MR appear, comment from gitlab.com,
|
||||
watch the agent see and resolve the comment.
|
||||
|
||||
### Phase 7 — Cleanup + docs
|
||||
|
||||
- Remove `// TODO: GitLab support is not yet implemented` from
|
||||
`repoclone/protocol.go`.
|
||||
- Update `CLAUDE.md` "Repo Layout" and the integrations section to
|
||||
list `internal/gitlab/`.
|
||||
- Add the new spec/plan to `docs/specs/INDEX.md` (already done) and
|
||||
flip status to `shipped` once merged.
|
||||
|
||||
## 4. Risks / open points
|
||||
|
||||
- **API shape mismatches.** GitLab's MR discussions are nested
|
||||
(discussion → notes → resolved); GitHub's review comments are flat
|
||||
with `in_reply_to`. Mapping into the existing comments/threads store
|
||||
needs care — discussions are the natural unit on GitLab, not
|
||||
individual comments. Mitigation: GitLab discussions become first-class
|
||||
in the store, the GitHub side is mapped as "discussions of one note".
|
||||
Defer if it explodes scope; v1 can render two distinct components.
|
||||
- **Action-preset query syntax.** GitHub presets use the GitHub search
|
||||
DSL (`is:pr is:open author:@me`). GitLab MR search uses query
|
||||
parameters (`?author_username=…&state=opened`). The presets feature is
|
||||
GitHub-shaped; v1 ships GitLab presets as a constrained set
|
||||
(assignee/author/reviewer × open/merged) without full DSL parity.
|
||||
- **Self-managed TLS.** Self-hosted GitLabs sometimes use private CAs.
|
||||
`pat_client.go` uses the default `http.Client`. A workspace setting
|
||||
`gitlab_insecure_skip_verify` is the lazy answer; the right answer is
|
||||
reading `SSL_CERT_FILE`. v1 reads `SSL_CERT_FILE`; flag the
|
||||
self-signed escape hatch as a follow-up.
|
||||
- **Per-workspace vs global host.** Per-workspace adds per-call
|
||||
resolution overhead and complicates non-workspace-scoped callers
|
||||
(review/issue pollers). Mitigation: the poller iterates workspaces
|
||||
and uses each workspace's host; the MyGitLab page is workspace-scoped
|
||||
by construction.
|
||||
- **`glab` CLI parity.** `glab` lacks direct commands for some search
|
||||
filters that REST exposes; the CLI client falls back to `glab api`
|
||||
for those, which means the `glab` binary version matters less than
|
||||
the underlying REST endpoint version. Pin a minimum `glab` version
|
||||
in `GLabAvailable()` and surface the version mismatch in the
|
||||
status WS payload so the settings page can warn.
|
||||
- **Frontend component extraction (Phase 4).** Risk of refactor churn
|
||||
on GitHub during the extraction. Mitigation: extract
|
||||
one-component-at-a-time behind matching tests; revert the extraction
|
||||
if scope creeps and just copy-and-adapt instead.
|
||||
- **Mock harness scope.** GitHub's mock has 14k LOC of behavior to
|
||||
emulate; GitLab's mock can ship with a thin slice (status, MR list,
|
||||
MR feedback, watch round-trips). The Playwright e2e suite is the
|
||||
forcing function — match what tests need, not full parity.
|
||||
|
||||
## 5. Out of scope (per spec)
|
||||
|
||||
Webhook ingestion, GitLab CI editing, GitLab-Issues-as-tracker,
|
||||
group-wide dashboards, OAuth login, Bitbucket. Two natural follow-ups
|
||||
once shipped: webhook ingestion (kills the pollers) and the unifying
|
||||
`codehost.Client` interface (now that two real clients exist).
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-05-04
|
||||
owner: tbd
|
||||
---
|
||||
|
||||
# GitLab Integration
|
||||
|
||||
## Why
|
||||
|
||||
Kandev's code-host integration is GitHub-only: PR browsing, review queue, issue
|
||||
watches, repo cloning, and the agent `pr` skill all assume `github.com` and the
|
||||
`gh` CLI. Users on GitLab (gitlab.com or self-managed) can't connect their
|
||||
account, can't see their merge requests in the MyGitHub-style page, can't have
|
||||
agents auto-clone or open MRs, and have to manually paste tokens for each task.
|
||||
This excludes a large chunk of professional users — and self-managed GitLab is
|
||||
common in regulated/enterprise environments where users have no choice.
|
||||
Tracked in [#820](https://github.com/kdlbs/kandev/issues/820).
|
||||
|
||||
## What
|
||||
|
||||
- A GitLab integration runs alongside the GitHub one. Both can be configured at
|
||||
the same time; neither replaces the other.
|
||||
- Users authenticate GitLab via one of:
|
||||
- `glab` CLI (auto-detected if installed and logged in).
|
||||
- Personal access token stored in the secret store as `GITLAB_TOKEN` (or set
|
||||
via the env var of the same name on the backend host).
|
||||
- Self-managed instances are supported by configuring a GitLab host URL
|
||||
(default `https://gitlab.com`). The host applies to API calls, clone URLs,
|
||||
and web links.
|
||||
- Repositories can be added with `provider = "gitlab"`. The repo cloner builds
|
||||
SSH (`git@<host>:<owner>/<name>.git`) and HTTPS clone URLs against the
|
||||
configured host, and authenticates HTTPS clones with the GitLab token when
|
||||
one is available.
|
||||
- A "GitLab" page mirrors the existing GitHub page: a sidebar of MR / issue
|
||||
presets, a search bar, paginated lists, save-preset support, and the
|
||||
quick-task launcher. MRs are labelled "Merge requests" throughout the UI;
|
||||
the underlying state model is shared with PRs (open/closed/merged, draft,
|
||||
approvals, pipelines).
|
||||
- For an MR linked to a task, the agent UI shows the same review surface it
|
||||
shows for PRs: title, description, head/base branches, mergeable state,
|
||||
approval status, pipeline status, and threaded discussions.
|
||||
- A review-watch and an issue-watch poller mirror the GitHub ones: users can
|
||||
subscribe to MRs needing their review, or to GitLab issues matching a
|
||||
filter, and Kandev creates tasks when matches appear.
|
||||
- The agent `pr` skill works for GitLab repos: when the task's repo is
|
||||
`provider = "gitlab"`, the skill opens a merge request via `glab` (or the
|
||||
REST API with the stored token), targeting the same base branch logic used
|
||||
for GitHub.
|
||||
- Settings live under `/settings/integrations/gitlab` with a page parallel to
|
||||
the GitHub one: connection status, host URL field, auth method (CLI vs PAT),
|
||||
reconnect CTA, and a token field that writes to the secret store.
|
||||
- The orchestrator and credential providers pass `GITLAB_TOKEN` into agent
|
||||
environments the same way `GITHUB_TOKEN` is passed today, so agents running
|
||||
in containers can run `glab` or `git push` without extra setup.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a user with `glab` authenticated against `gitlab.com`, **WHEN**
|
||||
they open Kandev for the first time, **THEN** the GitLab integration shows
|
||||
as connected without requiring any manual token entry, and their merge
|
||||
requests appear on the GitLab page.
|
||||
|
||||
- **GIVEN** a user on a self-managed GitLab at `https://gitlab.acme.corp`,
|
||||
**WHEN** they enter the host URL and a personal access token in
|
||||
Settings → Integrations → GitLab, **THEN** the connection status flips to
|
||||
connected, MR/issue lists populate against the custom host, and clone URLs
|
||||
for added repos use `gitlab.acme.corp`.
|
||||
|
||||
- **GIVEN** a task whose repository has `provider = "gitlab"`, **WHEN** the
|
||||
agent runs the `pr` skill, **THEN** a merge request is opened on the
|
||||
configured GitLab host targeting the repo's default branch, and the MR URL
|
||||
is recorded against the task.
|
||||
|
||||
- **GIVEN** the user has a GitLab review watch for `assignee=@me`, **WHEN** a
|
||||
new MR is assigned to them upstream, **THEN** within one poll interval a
|
||||
Kandev task is created in the configured workflow step, linked to that MR.
|
||||
|
||||
- **GIVEN** GitLab is connected and GitHub is also connected, **WHEN** the
|
||||
user opens the kanban top bar, **THEN** both the GitHub and GitLab page
|
||||
buttons are visible and each lists items from its own provider only.
|
||||
|
||||
- **GIVEN** the user revokes their GitLab token externally, **WHEN** the
|
||||
background poller next runs, **THEN** the integration's status flips to
|
||||
"auth required" with a reconnect banner on the GitLab page and the settings
|
||||
page, and no further API calls are issued until the user reconnects.
|
||||
|
||||
- **GIVEN** no GitLab auth is configured, **WHEN** the GitLab page is opened,
|
||||
**THEN** it shows a "not connected" notice linking to settings, and no
|
||||
network calls are made to any GitLab host.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- GitLab webhook ingestion. v1 polls only, like the GitHub integration.
|
||||
- GitLab CI pipeline editing or job log streaming inside Kandev. Pipeline
|
||||
status is shown read-only as part of MR feedback.
|
||||
- GitLab Issues used as a Jira/Linear-style task source (link issue → task
|
||||
with state syncing, structured field mapping). Kandev's Jira and Linear
|
||||
integrations cover that pattern; this spec treats GitLab as a code host
|
||||
parallel to GitHub, not a project-tracker integration.
|
||||
- Group-level MR / issue listings outside the user's own scope (no
|
||||
org-wide dashboards in v1).
|
||||
- GitLab-specific features without a GitHub analogue: approval rules editing,
|
||||
protected-branch management, MR templates authoring, Duo / GitLab AI
|
||||
integration.
|
||||
- OAuth login via GitLab as a Kandev sign-in method.
|
||||
- Migration tooling for moving tasks/repos between GitHub and GitLab.
|
||||
- Bitbucket. The placeholder in `repoclone/protocol.go` stays a placeholder.
|
||||
Reference in New Issue
Block a user