68aeefa296
* 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>
702 lines
22 KiB
Go
702 lines
22 KiB
Go
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
|
|
}
|