dfaab11959
* feat(backend): full GitLab parity — store, service, poller, WS, presets Add the watcher/poll/write-action surface on top of the existing GitLab client + service skeleton: * 5 new SQLite tables (mr_watches, review_watches, review_mr_tasks, issue_watches, issue_watch_tasks, action_presets) + CRUD * Client + Service: MergeMR, GetProjectMergeMethods, GetProtectedBranch, ListUserProjects, SearchProjects, SetMRLabels, SetMRAssignees * Service: review/issue/MR watch CRUD + Check/Trigger, GetStats, CleanupAllReview/IssueTasks (cleanup-policy aware), TriggerMRSync, action-presets CRUD with defaults * Background poller (MR / review / issue loops) wired from main.go * ~35 GitLab WS action constants + handlers.go registrations * Mock controller for E2E seeding (MRs, issues, pipelines, discussions, approvals, branches) * eventBus + taskDeleter + taskSessionChecker plumbed through Service * Event types: GitLabMRFeedback / NewReviewMR / NewIssue / TaskMRUpdated * feat(web): GitLab frontend parity — types, API, store slice, domain hooks Extend the frontend GitLab integration on top of the new backend surface: * HTTP API client expansion (gitlab-api.ts): full CRUD for review/issue/MR watches, MR write actions (merge/approve/labels/assignees), MR files/commits/feedback, action presets, projects autocomplete, stats * Store slice expansion: review/issue/MR watches lists, action presets by workspace, stats + status with loading flags. Properties prefixed with gitlab to avoid collision with GitHub slice * Domain hooks: use-gitlab-status, use-gitlab-stats, use-gitlab-review-watches, use-gitlab-issue-watches, use-gitlab-action-presets — mirror github hook shapes * Type model: ReviewWatch, IssueWatch, MRWatch, ActionPresets, Stats, ProjectMergeMethods, MRApproval, Pipeline, MRFeedback, etc. * Backend controller HTTP routes for watches/presets/write-actions/ projects/stats so the frontend HTTP API client has endpoints to hit All passes: backend (build + vet + lint + tests), web (typecheck + lint + 2400 tests). * fix(gitlab): wire orchestrator event handlers, fix label-filter drop, add tests Address Claude review blockers on the parity PR: 1. Wire orchestrator subscriptions for GitLab review/issue watch events. Add event_handlers_gitlab.go with handleGitLabNewReviewMR / handleGitLabNewIssue, dedup reservation handshake, and task-creator interfaces (mirrors event_handlers_github.go). main.go now calls orchestratorSvc.SetGitLabService so the dedup APIs are usable 2. Fix silent label-filter drop: when a custom_query is set on an issue watch, labels were being appended to the unused filter arg. Fold labels into customQuery (or default filter) so they actually reach GitLab; extracted appendLabelsToQuery helper with explicit handling of pre-existing labels= clauses 3. Switch event payloads to pointer types (mirrors GitHub) so handlers can type-assert pointer events 4. Backend tests: store_watches_test.go (CRUD + reserve/assign for all 4 new tables + presets), service_watches_test.go (label-filter helper, cleanup policy, project normalisation), action_presets_test.go 5. Frontend slice tests: review/issue watch CRUD round-trips, action presets + stats reducer coverage All green: backend (build+vet+lint+tests), web (typecheck+lint+2403 tests). * fix(gitlab): address remaining blockers — cleanup wiring, safe defaults, nil guards Address the second round of Claude review blockers: 1. main.go: wire SetTaskDeleter + SetTaskSessionChecker on services.GitLab (mirrors GitHub). Without these, manual cleanup sweeps would always error with "task deleter not configured" 2. service_cleanup.go: default cleanup policy on transient DB error is now CleanupPolicyNever (preserve tasks) instead of CleanupPolicyAuto (silently delete). Genuine "watch was deleted" path also falls under the safe-side default; user can manually delete the orphan tasks 3. service_watches.go: add nil guards on requireStore() so the 6 MR-watch list/get/delete methods return an errStoreUnavailable error instead of panicking when the SQLite store failed to construct at boot 4. use-gitlab-status.ts + use-gitlab-stats.ts: add per-mount attemptedRef so an unreachable GitLab doesn't trigger an infinite re-fetch loop — the previous useEffect re-ran every render because the failure path left status null, satisfying the !status guard * fix(gitlab): propagate session-check errors, clamp poll interval on update Address remaining Claude blockers: * service_cleanup.go: HasUserAuthoredMessage transient errors are now preserved (return false → skip delete) instead of silently ignored; the alternative was occasionally deleting tasks a user had touched * service_watches.go: extract clampPollInterval helper applying the same bounds (0 → default, <60 → 60) as the create path; both applyReviewWatchPatch and applyIssueWatchPatch use it so user-supplied zero or tiny values via UpdateXxxWatchRequest no longer hammer GitLab * fix(gitlab): clear dupl lint, fix preset retry loop + per-workspace watch cache * service_watches.go: applyReviewWatchPatch / applyIssueWatchPatch get nolint:dupl markers — they share shape but per-domain field validation lives in the create paths, so deduplicating via generics would obscure the contract. Restores backend lint to 0 issues * use-gitlab-action-presets.ts: per-workspace attemptedRef set so a failing preset fetch doesn't retry on every render * use-gitlab-review-watches.ts + use-gitlab-issue-watches.ts: track lastFetchedRef per consumer so a workspace switch triggers a refetch. The slice-level loaded flag is shared across instances and can't double as a per-workspace cache key * fix(gitlab): add nil-store guards across review/issue watch + cleanup paths Round-3 Claude finding: ReviewWatch / IssueWatch / preset / reservation methods still called s.requireStore().X() directly. If NewStore fails at boot (table migration error), the service struct's store is nil and every list/get/update/create/reserve method panics on first request. Apply the same store-nil → errStoreUnavailable pattern from f95a929 to every remaining method: * CreateReviewWatch / CreateIssueWatch * GetReviewWatch / ListReviewWatches / ListAllReviewWatches / UpdateReviewWatch / TriggerReviewWatchAll * GetIssueWatch / ListIssueWatches / ListAllIssueWatches / UpdateIssueWatch / TriggerIssueWatchAll * ReserveReviewMRTask / AssignReviewMRTaskID / ReleaseReviewMRTask * ReserveIssueWatchTask / AssignIssueWatchTaskID / ReleaseIssueWatchTask * lookupReviewPolicy / lookupIssuePolicy (return CleanupPolicyNever) * fix(gitlab): nil-store guards on CheckXxxWatch + DeleteXxxWatch + cleanup paths Round-4 Claude finding: 4 remaining panic sites the previous nil-guard pass missed — these run from the background poller (every 5 min) and the manual delete/cleanup flows, so a boot-time NewStore failure would crash the orchestrator process on first tick. * service_watches.go: add store-nil guards to DeleteReviewWatch / DeleteIssueWatch / CheckReviewWatch / CheckIssueWatch * service_cleanup.go: guard CleanupAllReviewTasks / CleanupAllIssueTasks entry points; wrap the trailing DeleteReviewMRTask / DeleteIssueWatchTask calls in `if store := s.requireStore(); store != nil` blocks so the cleanup succeeds even when the dedup-row delete is unreachable * refactor(gitlab): split service_watches.go to stay under 800-line revive limit CI's --new-from-rev lint caught service_watches.go at 808 lines (limit 800). Split into three focused files: * service_events.go (94 LOC) — publish helpers for MR feedback / new review MR / new issue / watch lifecycle events * service_issue_watches.go (302 LOC) — Issue watch CRUD + Check + Trigger + fetch + helpers * service_reservations.go (59 LOC) — Reserve/Assign/Release dedup handles used by the orchestrator event handlers service_watches.go is now 505 lines and contains MR watch + Review watch only. No behavioral change. * fix(gitlab): address inline code-review feedback Address actionable inline comments from Claude/CodeRabbit/cubic/Greptile: * appendLabelsToQuery: use url.ParseQuery for exact key match instead of strings.Contains; previously false-matched keys like mylabels= and silently dropped the watch's labels (test added) * fetchReviewMRs: drop dead `filter = watch.CustomQuery` assignment — SearchMRs's buildMRSearchQuery returns customQuery verbatim and ignores filter when customQuery is non-empty * store.go: add workspace_id indexes on gitlab_review_watches and gitlab_issue_watches (the 5-min poller and HTTP list endpoints did full table scans) * mock_client.SearchProjects: switch from == to case-insensitive Contains so partial-query autocomplete returns the seeded project, matching the doc comment * Poller: add sync.Mutex to guard `started` field; Start/Stop are now safe to call concurrently (go test -race would have flagged the previous read/write race) * Add ErrWatchNotFound sentinel; controller_watches.go maps it to HTTP 404 via httpRespondError helper. Update/Trigger handlers previously returned 500 for missing watches, hiding the distinction from real server faults * service_events.go: log Publish errors on watch lifecycle events instead of swallowing them silently * handlers.go: reject malformed JSON payloads in list-style WS handlers rather than silently falling through to broader list/search behavior * event_handlers_gitlab.go: remove dead gitlabRepoSlug helper + blank-identifier suppression * ci: re-trigger to clear flaky E2E shard 3 * fix(gitlab): address final review round — nil guards, store merge, logs, body validation - CheckMRWatch, UpdateReviewWatch, UpdateIssueWatch: nil guards for store/req - handlers: wsNewDiscussionNote rejects empty body - action_presets: Update reads raw stored row (no default freezing); Reset validates workspace_id - service_search.GetStats: log Warn per sub-call failure before zero - event_handlers_gitlab: log Release* dedup-row errors with context - web/store.ts: post-slice merge restores every GitLab sub-state field; extract buildStateOverrides helper - web/default-state.ts: add GitLab fields to defaultState + mergeInitialState via mergeGitLabFields helper - web/gitlab-api.ts: UpdateXxxWatchRequest now Omit workspace_id 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>
474 lines
15 KiB
Go
474 lines
15 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"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)
|
|
}
|
|
|
|
// MergeMR marks the seeded MR as merged.
|
|
func (c *MockClient) MergeMR(_ context.Context, projectPath string, iid int, _ bool, _ string) (*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)
|
|
}
|
|
mr.State = gitlabStateMerged
|
|
now := time.Now().UTC()
|
|
mr.MergedAt = &now
|
|
return mr, nil
|
|
}
|
|
|
|
// GetProjectMergeMethods returns a canned merge-methods response (merge + squash).
|
|
func (c *MockClient) GetProjectMergeMethods(context.Context, string) (*ProjectMergeMethods, error) {
|
|
return &ProjectMergeMethods{Merge: true, AllowSquash: true}, nil
|
|
}
|
|
|
|
// GetProtectedBranch always reports the branch as unprotected in the mock.
|
|
func (c *MockClient) GetProtectedBranch(context.Context, string, string) (*ProtectedBranch, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// ListUserProjects returns one fake project for the mock.
|
|
func (c *MockClient) ListUserProjects(context.Context) ([]Project, error) {
|
|
return []Project{
|
|
{ID: 1, PathWithNamespace: "kandev/sample", Namespace: "kandev", Path: "sample", Name: "sample"},
|
|
}, nil
|
|
}
|
|
|
|
// SearchProjects returns the same fake project filtered by query substring.
|
|
func (c *MockClient) SearchProjects(ctx context.Context, query string, _ int) ([]Project, error) {
|
|
projects, _ := c.ListUserProjects(ctx)
|
|
if query == "" {
|
|
return projects, nil
|
|
}
|
|
needle := strings.ToLower(query)
|
|
out := make([]Project, 0, len(projects))
|
|
for _, p := range projects {
|
|
if strings.Contains(strings.ToLower(p.PathWithNamespace), needle) ||
|
|
strings.Contains(strings.ToLower(p.Name), needle) ||
|
|
strings.Contains(strings.ToLower(p.Path), needle) {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SetMRLabels is a no-op in the mock (seeded MRs are accepted verbatim).
|
|
func (c *MockClient) SetMRLabels(context.Context, string, int, []string) error { return nil }
|
|
|
|
// SetMRAssignees is a no-op in the mock.
|
|
func (c *MockClient) SetMRAssignees(context.Context, string, int, []int) error { return nil }
|
|
|
|
// 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
|
|
}
|