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>
455 lines
13 KiB
Go
455 lines
13 KiB
Go
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"
|
|
}
|
|
}
|