From dfaab119591c92471edf83053ba8dd18a6d8986a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 2 Jun 2026 22:10:07 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20full=20GitLab=20integration=20=E2=80=94?= =?UTF-8?q?=20parity=20with=20GitHub=20(#1120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- apps/backend/cmd/kandev/helpers.go | 5 +- apps/backend/cmd/kandev/main.go | 13 + apps/backend/cmd/kandev/services.go | 10 +- apps/backend/internal/events/types.go | 10 + .../internal/gitlab/action_presets_service.go | 83 ++ .../internal/gitlab/action_presets_test.go | 76 ++ apps/backend/internal/gitlab/client.go | 24 + apps/backend/internal/gitlab/controller.go | 2 + .../internal/gitlab/controller_watches.go | 521 ++++++++++++ apps/backend/internal/gitlab/handlers.go | 734 +++++++++++++++++ apps/backend/internal/gitlab/mock_client.go | 56 ++ .../internal/gitlab/mock_controller.go | 143 ++++ apps/backend/internal/gitlab/noop_client.go | 28 + .../internal/gitlab/pat_client_actions.go | 181 +++++ apps/backend/internal/gitlab/poller.go | 203 +++++ apps/backend/internal/gitlab/service.go | 58 +- .../internal/gitlab/service_cleanup.go | 203 +++++ .../backend/internal/gitlab/service_events.go | 100 +++ .../internal/gitlab/service_issue_watches.go | 305 ++++++++ apps/backend/internal/gitlab/service_merge.go | 149 ++++ .../internal/gitlab/service_reservations.go | 59 ++ .../backend/internal/gitlab/service_search.go | 88 +++ apps/backend/internal/gitlab/service_sync.go | 35 + .../internal/gitlab/service_watches.go | 518 ++++++++++++ .../internal/gitlab/service_watches_test.go | 85 ++ apps/backend/internal/gitlab/store.go | 87 +++ apps/backend/internal/gitlab/store_watches.go | 739 ++++++++++++++++++ .../internal/gitlab/store_watches_test.go | 152 ++++ apps/backend/internal/gitlab/watch_models.go | 346 ++++++++ .../orchestrator/event_handlers_gitlab.go | 166 ++++ apps/backend/internal/orchestrator/service.go | 12 + apps/backend/pkg/websocket/actions.go | 55 ++ .../gitlab/use-gitlab-action-presets.ts | 58 ++ .../gitlab/use-gitlab-issue-watches.ts | 73 ++ .../gitlab/use-gitlab-review-watches.ts | 79 ++ .../hooks/domains/gitlab/use-gitlab-stats.ts | 31 + .../hooks/domains/gitlab/use-gitlab-status.ts | 46 ++ apps/web/lib/api/domains/gitlab-api.ts | 284 +++++++ apps/web/lib/state/default-state.ts | 32 +- .../state/slices/gitlab/gitlab-slice.test.ts | 88 +++ .../lib/state/slices/gitlab/gitlab-slice.ts | 198 ++++- apps/web/lib/state/slices/gitlab/types.ts | 74 +- apps/web/lib/state/store-overrides.ts | 86 ++ apps/web/lib/state/store.ts | 104 +-- apps/web/lib/types/gitlab.ts | 153 ++++ 45 files changed, 6417 insertions(+), 135 deletions(-) create mode 100644 apps/backend/internal/gitlab/action_presets_service.go create mode 100644 apps/backend/internal/gitlab/action_presets_test.go create mode 100644 apps/backend/internal/gitlab/controller_watches.go create mode 100644 apps/backend/internal/gitlab/handlers.go create mode 100644 apps/backend/internal/gitlab/mock_controller.go create mode 100644 apps/backend/internal/gitlab/pat_client_actions.go create mode 100644 apps/backend/internal/gitlab/poller.go create mode 100644 apps/backend/internal/gitlab/service_cleanup.go create mode 100644 apps/backend/internal/gitlab/service_events.go create mode 100644 apps/backend/internal/gitlab/service_issue_watches.go create mode 100644 apps/backend/internal/gitlab/service_merge.go create mode 100644 apps/backend/internal/gitlab/service_reservations.go create mode 100644 apps/backend/internal/gitlab/service_search.go create mode 100644 apps/backend/internal/gitlab/service_sync.go create mode 100644 apps/backend/internal/gitlab/service_watches.go create mode 100644 apps/backend/internal/gitlab/service_watches_test.go create mode 100644 apps/backend/internal/gitlab/store_watches.go create mode 100644 apps/backend/internal/gitlab/store_watches_test.go create mode 100644 apps/backend/internal/gitlab/watch_models.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_gitlab.go create mode 100644 apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts create mode 100644 apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts create mode 100644 apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts create mode 100644 apps/web/hooks/domains/gitlab/use-gitlab-stats.ts create mode 100644 apps/web/hooks/domains/gitlab/use-gitlab-status.ts create mode 100644 apps/web/lib/state/store-overrides.ts diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index fb507fcc6f..05aff539ef 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -747,8 +747,9 @@ func registerSecondaryRoutes( } if p.services.GitLab != nil { - gitlab.RegisterRoutes(p.router, p.services.GitLab, p.log) - p.log.Debug("Registered GitLab handlers (HTTP)") + gitlab.RegisterRoutesWithDispatcher(p.router, p.gateway.Dispatcher, p.services.GitLab, p.log) + gitlab.RegisterMockRoutes(p.router, p.services.GitLab, p.log) + p.log.Debug("Registered GitLab handlers (HTTP + WebSocket)") } if p.services.Jira != nil { diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 04b8ce2f12..d1dd9d22d5 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -30,6 +30,7 @@ import ( // GitHub integration githubpkg "github.com/kandev/kandev/internal/github" + gitlabpkg "github.com/kandev/kandev/internal/gitlab" // JIRA integration jirapkg "github.com/kandev/kandev/internal/jira" @@ -437,6 +438,18 @@ func startAgentInfrastructure( log.Info("GitHub poller started") } + // Start GitLab background poller + wire the service into the + // orchestrator so review/issue watch events get turned into tasks. + if services.GitLab != nil { + orchestratorSvc.SetGitLabService(services.GitLab) + services.GitLab.SetTaskDeleter(&taskDeleterAdapter{svc: services.Task}) + services.GitLab.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task}) + glPoller := gitlabpkg.NewPoller(services.GitLab, eventBus, log) + glPoller.Start(ctx) + addCleanup(func() error { glPoller.Stop(); return nil }) + log.Info("GitLab poller started") + } + // Start JIRA poller. Drives two background loops sharing one service: an // auth-health probe (so the UI can show connect status without polling // JIRA itself) and an issue-watch loop that runs configured JQL queries diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 59c3a894ec..4fb6bf4a8b 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -92,7 +92,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories ) githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log) - gitlabSvc := initGitLabService(dbPool, repos.Secrets, log) + gitlabSvc := initGitLabService(dbPool, eventBus, repos.Secrets, log) jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) slackSvc := initSlackService(dbPool, repos.Secrets, log) @@ -307,19 +307,15 @@ func (a *gitlabSecretAdapter) Delete(ctx context.Context, id string) error { // initGitLabService wires up the GitLab integration. Failures are non-fatal: // the rest of the backend still boots without GitLab configured. -func initGitLabService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *gitlab.Service { +func initGitLabService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *gitlab.Service { adapter := &gitlabSecretAdapter{store: secretsStore} - // Host persistence (per-workspace gitlab_host) is deferred to a - // follow-up; v1 reads from DefaultHost on every boot. svc, _, err := gitlab.Provide(context.Background(), adapter, nil, log) if err != nil { log.Warn("GitLab service initialization failed (non-fatal)", zap.Error(err)) } if svc != nil { svc.SetSecretManager(adapter) - // Task↔MR association store backs the topbar review surface. - // Non-fatal: if the table fails to create the rest of the - // integration (status, configure, MR feedback) still works. + svc.SetEventBus(eventBus) if store, storeErr := gitlab.NewStore(dbPool.Writer(), dbPool.Reader()); storeErr == nil { svc.SetStore(store) } else { diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 57ee20c7aa..999c14a7df 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -228,6 +228,16 @@ const ( GitHubRateLimitUpdated = "github.rate_limit.updated" // GitHub API rate-limit snapshot changed ) +// Event types for GitLab integration +const ( + GitLabMRFeedback = "gitlab.mr_feedback" // MR has new feedback (UI notification only) + GitLabMRStateChanged = "gitlab.mr_state_changed" // MR state changed (merged, closed, etc.) + GitLabNewReviewMR = "gitlab.new_mr_to_review" // New MR found needing review + GitLabNewIssue = "gitlab.new_issue" // New issue found matching issue watch + GitLabTaskMRUpdated = "gitlab.task_mr.updated" // TaskMR record updated (for UI refresh) + GitLabWatchEvent = "gitlab.watch.event" // Watch created/deleted +) + // Event types for Jira integration const ( JiraNewIssue = "jira.new_issue" // New issue found matching a Jira issue watch diff --git a/apps/backend/internal/gitlab/action_presets_service.go b/apps/backend/internal/gitlab/action_presets_service.go new file mode 100644 index 0000000000..29032fdd20 --- /dev/null +++ b/apps/backend/internal/gitlab/action_presets_service.go @@ -0,0 +1,83 @@ +package gitlab + +import ( + "context" + "fmt" +) + +// GetActionPresetsOrDefault returns the workspace's stored presets, falling +// back to the built-in defaults when none are stored. +func (s *Service) GetActionPresetsOrDefault(ctx context.Context, workspaceID string) (*ActionPresets, error) { + store := s.requireStore() + if store == nil { + return defaultPresets(workspaceID), nil + } + presets, err := store.GetActionPresets(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("get action presets: %w", err) + } + if len(presets.MR) == 0 { + presets.MR = DefaultMRActionPresets() + } + if len(presets.Issue) == 0 { + presets.Issue = DefaultIssueActionPresets() + } + return presets, nil +} + +// UpdateActionPresets persists a partial update to a workspace's presets. +// Nil fields are left unchanged. Untouched kinds are NOT filled with current +// defaults before persistence — that would freeze stale defaults into the +// workspace row, masking future default changes. The reader +// (GetActionPresetsOrDefault) substitutes defaults on read instead. +func (s *Service) UpdateActionPresets(ctx context.Context, req *UpdateActionPresetsRequest) (*ActionPresets, error) { + if req == nil || req.WorkspaceID == "" { + return nil, fmt.Errorf("workspace_id required") + } + store := s.requireStore() + if store == nil { + return nil, fmt.Errorf("gitlab store not configured") + } + current, err := store.GetActionPresets(ctx, req.WorkspaceID) + if err != nil { + return nil, fmt.Errorf("get action presets: %w", err) + } + if current == nil { + current = &ActionPresets{WorkspaceID: req.WorkspaceID} + } + if req.MR != nil { + current.MR = *req.MR + } + if req.Issue != nil { + current.Issue = *req.Issue + } + if err := store.UpsertActionPresets(ctx, current); err != nil { + return nil, fmt.Errorf("upsert action presets: %w", err) + } + // Return the rendered view (defaults substituted) so the caller sees the + // same shape the read endpoint produces. + return s.GetActionPresetsOrDefault(ctx, req.WorkspaceID) +} + +// ResetActionPresets removes a workspace's stored presets, falling back to defaults. +func (s *Service) ResetActionPresets(ctx context.Context, workspaceID string) (*ActionPresets, error) { + if workspaceID == "" { + return nil, fmt.Errorf("workspace_id required") + } + store := s.requireStore() + if store == nil { + return defaultPresets(workspaceID), nil + } + if err := store.DeleteActionPresets(ctx, workspaceID); err != nil { + return nil, fmt.Errorf("reset action presets: %w", err) + } + return defaultPresets(workspaceID), nil +} + +func defaultPresets(workspaceID string) *ActionPresets { + return &ActionPresets{ + WorkspaceID: workspaceID, + MR: DefaultMRActionPresets(), + Issue: DefaultIssueActionPresets(), + } +} diff --git a/apps/backend/internal/gitlab/action_presets_test.go b/apps/backend/internal/gitlab/action_presets_test.go new file mode 100644 index 0000000000..39137ffb04 --- /dev/null +++ b/apps/backend/internal/gitlab/action_presets_test.go @@ -0,0 +1,76 @@ +package gitlab + +import ( + "context" + "testing" +) + +func TestService_GetActionPresetsOrDefault_FallsBackToDefaults(t *testing.T) { + svc := newServiceWithStore(t) + got, err := svc.GetActionPresetsOrDefault(context.Background(), "ws-new") + if err != nil { + t.Fatalf("GetActionPresetsOrDefault: %v", err) + } + if got == nil { + t.Fatalf("expected non-nil presets") + } + if len(got.MR) == 0 || len(got.Issue) == 0 { + t.Fatalf("expected defaults to be injected when none stored, got %+v", got) + } + if got.MR[0].ID == "" { + t.Fatalf("default presets missing ID: %+v", got.MR[0]) + } +} + +func TestService_UpdateActionPresets_PartialMerge(t *testing.T) { + svc := newServiceWithStore(t) + ctx := context.Background() + // First write only MR presets. + mrPresets := []ActionPreset{{ID: "x", Label: "Custom", PromptTemplate: "do {{url}}"}} + if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{ + WorkspaceID: "ws-1", + MR: &mrPresets, + }); err != nil { + t.Fatalf("UpdateActionPresets: %v", err) + } + // Read back: MR should be custom, Issue should still be defaults (from + // GetActionPresetsOrDefault). + got, err := svc.GetActionPresetsOrDefault(ctx, "ws-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if len(got.MR) != 1 || got.MR[0].ID != "x" { + t.Fatalf("MR presets not persisted: %+v", got.MR) + } + if len(got.Issue) == 0 { + t.Fatalf("Issue presets should fallback to defaults when empty") + } +} + +func TestService_ResetActionPresets(t *testing.T) { + svc := newServiceWithStore(t) + ctx := context.Background() + custom := []ActionPreset{{ID: "x", Label: "x"}} + if _, err := svc.UpdateActionPresets(ctx, &UpdateActionPresetsRequest{ + WorkspaceID: "ws-1", + MR: &custom, + }); err != nil { + t.Fatalf("UpdateActionPresets: %v", err) + } + got, err := svc.ResetActionPresets(ctx, "ws-1") + if err != nil { + t.Fatalf("ResetActionPresets: %v", err) + } + if len(got.MR) == 0 || got.MR[0].ID == "x" { + t.Fatalf("reset should restore defaults, got %+v", got.MR) + } +} + +func newServiceWithStore(t *testing.T) *Service { + t.Helper() + store := newTestStore(t) + log := newTestLogger(t) + svc := NewService("https://gitlab.com", NewNoopClient("https://gitlab.com"), AuthMethodNone, nil, log) + svc.SetStore(store) + return svc +} diff --git a/apps/backend/internal/gitlab/client.go b/apps/backend/internal/gitlab/client.go index 51c522d1f5..9511d98346 100644 --- a/apps/backend/internal/gitlab/client.go +++ b/apps/backend/internal/gitlab/client.go @@ -108,4 +108,28 @@ type Client interface { // GetIssueState returns the state of a single issue ("opened" or "closed"). GetIssueState(ctx context.Context, projectPath string, iid int) (string, error) + + // MergeMR accepts an MR. squash=true performs a squash merge regardless of + // project merge method. squashCommitMessage is used when squash=true. + MergeMR(ctx context.Context, projectPath string, iid int, squash bool, squashCommitMessage string) (*MR, error) + + // GetProjectMergeMethods reads the project's merge_method + squash_option + // settings. + GetProjectMergeMethods(ctx context.Context, projectPath string) (*ProjectMergeMethods, error) + + // GetProtectedBranch returns the protected-branch settings for a branch. + // Returns (nil, nil) when the branch isn't protected. + GetProtectedBranch(ctx context.Context, projectPath, branch string) (*ProtectedBranch, error) + + // ListUserProjects lists projects the authenticated user is a member of. + ListUserProjects(ctx context.Context) ([]Project, error) + + // SearchProjects searches all projects matching `query`. + SearchProjects(ctx context.Context, query string, limit int) ([]Project, error) + + // SetMRLabels replaces an MR's labels. + SetMRLabels(ctx context.Context, projectPath string, iid int, labels []string) error + + // SetMRAssignees replaces an MR's assignees (by user ID). + SetMRAssignees(ctx context.Context, projectPath string, iid int, assigneeIDs []int) error } diff --git a/apps/backend/internal/gitlab/controller.go b/apps/backend/internal/gitlab/controller.go index cb4da8f6a1..a5007485bb 100644 --- a/apps/backend/internal/gitlab/controller.go +++ b/apps/backend/internal/gitlab/controller.go @@ -41,6 +41,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/user/mrs", c.httpSearchUserMRs) api.GET("/user/issues", c.httpSearchUserIssues) + + c.RegisterWatchHTTPRoutes(router) } // RegisterRoutes is the package-level entrypoint mirroring github.RegisterRoutes. diff --git a/apps/backend/internal/gitlab/controller_watches.go b/apps/backend/internal/gitlab/controller_watches.go new file mode 100644 index 0000000000..8b0cb1005b --- /dev/null +++ b/apps/backend/internal/gitlab/controller_watches.go @@ -0,0 +1,521 @@ +package gitlab + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// httpRespondError maps known sentinel errors to their HTTP code and +// writes a JSON {"error": "..."} body. Returns true when a response was +// written so callers can early-return. +func httpRespondError(ctx *gin.Context, err error) bool { + if err == nil { + return false + } + switch { + case errors.Is(err, ErrWatchNotFound): + ctx.JSON(http.StatusNotFound, gin.H{responseErrorKey: err.Error()}) + default: + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + } + return true +} + +// RegisterWatchHTTPRoutes wires the watch + presets + write-action HTTP +// surface on top of the v1 routes. Called from RegisterHTTPRoutes after the +// base set is registered. +func (c *Controller) RegisterWatchHTTPRoutes(router *gin.Engine) { + api := router.Group("/api/v1/gitlab") + + // MR / Review / Issue watches + api.GET("/watches/mr", c.httpListMRWatches) + api.DELETE("/watches/mr/:id", c.httpDeleteMRWatch) + + api.GET("/watches/review", c.httpListReviewWatches) + api.POST("/watches/review", c.httpCreateReviewWatch) + api.PUT("/watches/review/:id", c.httpUpdateReviewWatch) + api.DELETE("/watches/review/:id", c.httpDeleteReviewWatch) + api.POST("/watches/review/:id/trigger", c.httpTriggerReviewWatch) + api.POST("/watches/review/trigger-all", c.httpTriggerAllReviewChecks) + + api.GET("/watches/issue", c.httpListIssueWatches) + api.POST("/watches/issue", c.httpCreateIssueWatch) + api.PUT("/watches/issue/:id", c.httpUpdateIssueWatch) + api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch) + api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch) + api.POST("/watches/issue/trigger-all", c.httpTriggerAllIssueChecks) + + // Cleanup + api.POST("/cleanup/review-tasks", c.httpCleanupReviewTasks) + api.POST("/cleanup/issue-tasks", c.httpCleanupIssueTasks) + + // Projects + branches autocomplete + api.GET("/projects", c.httpListUserProjects) + api.GET("/projects/search", c.httpSearchProjects) + api.GET("/projects/branches", c.httpListProjectBranches) + api.GET("/projects/merge-methods", c.httpGetProjectMergeMethods) + + // MR write actions + api.PUT("/mrs/merge", c.httpMergeMR) + api.POST("/mrs/approve", c.httpApproveMR) + api.POST("/mrs/unapprove", c.httpUnapproveMR) + api.PUT("/mrs/labels", c.httpSetMRLabels) + api.PUT("/mrs/assignees", c.httpSetMRAssignees) + api.GET("/mrs/files", c.httpGetMRFiles) + api.GET("/mrs/commits", c.httpGetMRCommits) + + // Action presets + api.GET("/action-presets", c.httpGetActionPresets) + api.PUT("/action-presets", c.httpUpdateActionPresets) + api.POST("/action-presets/reset", c.httpResetActionPresets) + + // Stats + api.GET("/stats", c.httpGetStats) +} + +// --- Helpers --- + +func bindJSON[T any](ctx *gin.Context, out *T) bool { + if err := ctx.ShouldBindJSON(out); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: errors.New("invalid payload").Error()}) + return false + } + return true +} + +// --- MR watches --- + +func (c *Controller) httpListMRWatches(ctx *gin.Context) { + sessionID := ctx.Query("session_id") + taskID := ctx.Query("task_id") + switch { + case sessionID != "": + watches, err := c.service.ListMRWatchesBySession(ctx.Request.Context(), sessionID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) + case taskID != "": + watches, err := c.service.ListMRWatchesByTask(ctx.Request.Context(), taskID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) + default: + watches, err := c.service.ListActiveMRWatches(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) + } +} + +func (c *Controller) httpDeleteMRWatch(ctx *gin.Context) { + id := ctx.Param("id") + if err := c.service.DeleteMRWatch(ctx.Request.Context(), id); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +// --- Review watches --- + +func (c *Controller) httpListReviewWatches(ctx *gin.Context) { + wsID := ctx.Query("workspace_id") + var ( + watches []*ReviewWatch + err error + ) + if wsID == "" { + watches, err = c.service.ListAllReviewWatches(ctx.Request.Context()) + } else { + watches, err = c.service.ListReviewWatches(ctx.Request.Context(), wsID) + } + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) +} + +func (c *Controller) httpCreateReviewWatch(ctx *gin.Context) { + var req CreateReviewWatchRequest + if !bindJSON(ctx, &req) { + return + } + rw, err := c.service.CreateReviewWatch(ctx.Request.Context(), &req) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, rw) +} + +func (c *Controller) httpUpdateReviewWatch(ctx *gin.Context) { + id := ctx.Param("id") + var req UpdateReviewWatchRequest + if !bindJSON(ctx, &req) { + return + } + if err := c.service.UpdateReviewWatch(ctx.Request.Context(), id, &req); httpRespondError(ctx, err) { + return + } + rw, err := c.service.GetReviewWatch(ctx.Request.Context(), id) + if err != nil || rw == nil { + ctx.JSON(http.StatusOK, gin.H{"updated": true}) + return + } + ctx.JSON(http.StatusOK, rw) +} + +func (c *Controller) httpDeleteReviewWatch(ctx *gin.Context) { + id := ctx.Param("id") + if err := c.service.DeleteReviewWatch(ctx.Request.Context(), id); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTriggerReviewWatch(ctx *gin.Context) { + id := ctx.Param("id") + mrs, err := c.service.TriggerReviewWatch(ctx.Request.Context(), id) + if httpRespondError(ctx, err) { + return + } + ctx.JSON(http.StatusOK, gin.H{"mrs": mrs, "count": len(mrs)}) +} + +func (c *Controller) httpTriggerAllReviewChecks(ctx *gin.Context) { + n, err := c.service.TriggerReviewWatchAll(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"count": n}) +} + +// --- Issue watches --- + +func (c *Controller) httpListIssueWatches(ctx *gin.Context) { + wsID := ctx.Query("workspace_id") + var ( + watches []*IssueWatch + err error + ) + if wsID == "" { + watches, err = c.service.ListAllIssueWatches(ctx.Request.Context()) + } else { + watches, err = c.service.ListIssueWatches(ctx.Request.Context(), wsID) + } + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) +} + +func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) { + var req CreateIssueWatchRequest + if !bindJSON(ctx, &req) { + return + } + iw, err := c.service.CreateIssueWatch(ctx.Request.Context(), &req) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, iw) +} + +func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + var req UpdateIssueWatchRequest + if !bindJSON(ctx, &req) { + return + } + if err := c.service.UpdateIssueWatch(ctx.Request.Context(), id, &req); httpRespondError(ctx, err) { + return + } + iw, err := c.service.GetIssueWatch(ctx.Request.Context(), id) + if err != nil || iw == nil { + ctx.JSON(http.StatusOK, gin.H{"updated": true}) + return + } + ctx.JSON(http.StatusOK, iw) +} + +func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + issues, err := c.service.TriggerIssueWatch(ctx.Request.Context(), id) + if httpRespondError(ctx, err) { + return + } + ctx.JSON(http.StatusOK, gin.H{"issues": issues, "count": len(issues)}) +} + +func (c *Controller) httpTriggerAllIssueChecks(ctx *gin.Context) { + n, err := c.service.TriggerIssueWatchAll(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"count": n}) +} + +// --- Cleanup --- + +func (c *Controller) httpCleanupReviewTasks(ctx *gin.Context) { + n, err := c.service.CleanupAllReviewTasks(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": n}) +} + +func (c *Controller) httpCleanupIssueTasks(ctx *gin.Context) { + n, err := c.service.CleanupAllIssueTasks(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": n}) +} + +// --- Projects --- + +func (c *Controller) httpListUserProjects(ctx *gin.Context) { + projects, err := c.service.ListUserProjects(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"projects": projects}) +} + +func (c *Controller) httpSearchProjects(ctx *gin.Context) { + query := ctx.Query("query") + projects, err := c.service.SearchProjects(ctx.Request.Context(), query, 50) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"projects": projects}) +} + +func (c *Controller) httpListProjectBranches(ctx *gin.Context) { + project := ctx.Query("project") + if project == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "project required"}) + return + } + branches, err := c.service.ListProjectBranches(ctx.Request.Context(), project) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"branches": branches}) +} + +func (c *Controller) httpGetProjectMergeMethods(ctx *gin.Context) { + project := ctx.Query("project") + if project == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "project required"}) + return + } + methods, err := c.service.GetProjectMergeMethods(ctx.Request.Context(), project) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, methods) +} + +// --- Write actions --- + +type mergeMRRequest struct { + Project string `json:"project" binding:"required"` + IID int `json:"iid" binding:"required"` + Method string `json:"method"` + SquashCommitMessage string `json:"squash_commit_message"` +} + +func (c *Controller) httpMergeMR(ctx *gin.Context) { + var req mergeMRRequest + if !bindJSON(ctx, &req) { + return + } + mr, err := c.service.MergeMR(ctx.Request.Context(), req.Project, req.IID, req.Method, req.SquashCommitMessage) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, mr) +} + +type approveMRRequest struct { + Project string `json:"project" binding:"required"` + IID int `json:"iid" binding:"required"` +} + +func (c *Controller) httpApproveMR(ctx *gin.Context) { + var req approveMRRequest + if !bindJSON(ctx, &req) { + return + } + if err := c.service.SubmitMRApproval(ctx.Request.Context(), req.Project, req.IID); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"approved": true}) +} + +func (c *Controller) httpUnapproveMR(ctx *gin.Context) { + var req approveMRRequest + if !bindJSON(ctx, &req) { + return + } + if err := c.service.SubmitMRUnapproval(ctx.Request.Context(), req.Project, req.IID); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"unapproved": true}) +} + +type setMRLabelsRequest struct { + Project string `json:"project" binding:"required"` + IID int `json:"iid" binding:"required"` + Labels []string `json:"labels"` +} + +func (c *Controller) httpSetMRLabels(ctx *gin.Context) { + var req setMRLabelsRequest + if !bindJSON(ctx, &req) { + return + } + if err := c.service.SetMRLabels(ctx.Request.Context(), req.Project, req.IID, req.Labels); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"updated": true}) +} + +type setMRAssigneesRequest struct { + Project string `json:"project" binding:"required"` + IID int `json:"iid" binding:"required"` + AssigneeIDs []int `json:"assignee_ids"` +} + +func (c *Controller) httpSetMRAssignees(ctx *gin.Context) { + var req setMRAssigneesRequest + if !bindJSON(ctx, &req) { + return + } + if err := c.service.SetMRAssignees(ctx.Request.Context(), req.Project, req.IID, req.AssigneeIDs); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"updated": true}) +} + +func (c *Controller) httpGetMRFiles(ctx *gin.Context) { + project, iid, err := parseProjectAndIID(ctx) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()}) + return + } + files, err := c.service.GetMRFiles(ctx.Request.Context(), project, iid) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"files": files}) +} + +func (c *Controller) httpGetMRCommits(ctx *gin.Context) { + project, iid, err := parseProjectAndIID(ctx) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()}) + return + } + commits, err := c.service.GetMRCommits(ctx.Request.Context(), project, iid) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"commits": commits}) +} + +// --- Action presets --- + +func (c *Controller) httpGetActionPresets(ctx *gin.Context) { + wsID := ctx.Query("workspace_id") + if wsID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id required"}) + return + } + presets, err := c.service.GetActionPresetsOrDefault(ctx.Request.Context(), wsID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, presets) +} + +func (c *Controller) httpUpdateActionPresets(ctx *gin.Context) { + var req UpdateActionPresetsRequest + if !bindJSON(ctx, &req) { + return + } + if req.WorkspaceID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id required"}) + return + } + presets, err := c.service.UpdateActionPresets(ctx.Request.Context(), &req) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, presets) +} + +func (c *Controller) httpResetActionPresets(ctx *gin.Context) { + wsID := ctx.Query("workspace_id") + if wsID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id required"}) + return + } + presets, err := c.service.ResetActionPresets(ctx.Request.Context(), wsID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, presets) +} + +// --- Stats --- + +func (c *Controller) httpGetStats(ctx *gin.Context) { + stats, err := c.service.GetStats(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()}) + return + } + ctx.JSON(http.StatusOK, stats) +} diff --git a/apps/backend/internal/gitlab/handlers.go b/apps/backend/internal/gitlab/handlers.go new file mode 100644 index 0000000000..72aecd7e5c --- /dev/null +++ b/apps/backend/internal/gitlab/handlers.go @@ -0,0 +1,734 @@ +package gitlab + +import ( + "context" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +const ( + errMsgInvalidPayload = "invalid payload" + errMsgIDRequired = "id required" + respKeyDeleted = "deleted" +) + +// RegisterRoutesWithDispatcher registers HTTP + WS routes for GitLab. +// Preserves the existing RegisterRoutes(router, svc, log) signature by +// adding a sibling function so callers can pass a dispatcher. +func RegisterRoutesWithDispatcher(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + NewController(svc, log).RegisterHTTPRoutes(router) + if dispatcher != nil { + registerWSHandlers(dispatcher, svc, log) + } +} + +// RegisterMockRoutes registers mock control endpoints if the underlying +// client is a MockClient. No-op otherwise. +func RegisterMockRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { + mock, ok := svc.Client().(*MockClient) + if !ok { + return + } + NewMockController(mock, svc, log).RegisterRoutes(router) + log.Info("registered GitLab mock control endpoints") +} + +// registerWSHandlers wires the GitLab WS action surface onto the dispatcher. +func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + dispatcher.RegisterFunc(ws.ActionGitLabStatus, wsStatus(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabTaskMRsList, wsListTaskMRs(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabTaskMRGet, wsGetTaskMR(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRFeedbackGet, wsGetMRFeedback(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabReviewWatchesList, wsListReviewWatches(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabReviewWatchCreate, wsCreateReviewWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabReviewWatchUpdate, wsUpdateReviewWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabReviewWatchDelete, wsDeleteReviewWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabReviewTrigger, wsTriggerReviewWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabReviewTriggerAll, wsTriggerAllReviewChecks(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRWatchesList, wsListMRWatches(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRWatchDelete, wsDeleteMRWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRFilesGet, wsGetMRFiles(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRCommitsGet, wsGetMRCommits(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabTaskMRSync, wsSyncTaskMR(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabStats, wsGetStats(svc, log)) + + dispatcher.RegisterFunc(ws.ActionGitLabMRMerge, wsMergeMR(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRApprove, wsApproveMR(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRUnapprove, wsUnapproveMR(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRSetLabels, wsSetMRLabels(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRSetAssignees, wsSetMRAssignees(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRDiscussionNew, wsNewDiscussionNote(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabMRDiscussionResolve, wsResolveDiscussion(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabProjectMergeMethodsGet, wsGetProjectMergeMethods(svc, log)) + + dispatcher.RegisterFunc(ws.ActionGitLabIssueWatchesList, wsListIssueWatches(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabIssueWatchCreate, wsCreateIssueWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabIssueWatchUpdate, wsUpdateIssueWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabIssueWatchDelete, wsDeleteIssueWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabIssueTrigger, wsTriggerIssueWatch(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabIssueTriggerAll, wsTriggerAllIssueChecks(svc, log)) + + dispatcher.RegisterFunc(ws.ActionGitLabActionPresetsList, wsListActionPresets(svc)) + dispatcher.RegisterFunc(ws.ActionGitLabActionPresetsUpdate, wsUpdateActionPresets(svc)) + dispatcher.RegisterFunc(ws.ActionGitLabActionPresetsReset, wsResetActionPresets(svc)) + + dispatcher.RegisterFunc(ws.ActionGitLabListUserProjects, wsListUserProjects(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabSearchProjects, wsSearchProjects(svc, log)) + dispatcher.RegisterFunc(ws.ActionGitLabProjectBranches, wsListProjectBranches(svc, log)) + + dispatcher.RegisterFunc(ws.ActionGitLabCleanupReviewTasks, wsCleanupReviewTasks(svc)) + dispatcher.RegisterFunc(ws.ActionGitLabCleanupIssueTasks, wsCleanupIssueTasks(svc)) +} + +// --- Generic helpers --- + +func parseInto[T any](msg *ws.Message, out *T) error { return msg.ParsePayload(out) } + +func badRequest(msg *ws.Message, reason string) (*ws.Message, error) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, reason, nil) +} + +func internalError(msg *ws.Message, err error) (*ws.Message, error) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) +} + +func okResponse(msg *ws.Message, payload interface{}) (*ws.Message, error) { + return ws.NewResponse(msg.ID, msg.Action, payload) +} + +// --- Status / list / get / sync --- + +func wsStatus(svc *Service, log *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + status, err := svc.GetStatus(ctx) + if err != nil { + log.Warn("gitlab status: " + err.Error()) + return internalError(msg, err) + } + return okResponse(msg, status) + } +} + +func wsListTaskMRs(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + WorkspaceID string `json:"workspace_id"` + TaskID string `json:"task_id"` + } + // Malformed payloads must not silently fall through to the "list + // everything in workspace" branch — that exposes more data than the + // caller intended. Empty payload (both fields absent) is rejected + // further down by the workspace_id check. + if err := parseInto(msg, &req); err != nil { + return badRequest(msg, errMsgInvalidPayload) + } + if req.TaskID != "" { + mrs, err := svc.ListTaskMRsByTask(ctx, req.TaskID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"task_mrs": mrs}) + } + if req.WorkspaceID == "" { + return badRequest(msg, "workspace_id or task_id required") + } + grouped, err := svc.ListTaskMRsByWorkspace(ctx, req.WorkspaceID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, TaskMRsResponse{TaskMRs: grouped}) + } +} + +func wsGetTaskMR(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := parseInto(msg, &req); err != nil || req.TaskID == "" { + return badRequest(msg, "task_id required") + } + mrs, err := svc.ListTaskMRsByTask(ctx, req.TaskID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"task_mrs": mrs}) + } +} + +func wsGetMRFeedback(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 { + return badRequest(msg, "project and iid required") + } + feedback, err := svc.GetMRFeedback(ctx, req.Project, req.IID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, feedback) + } +} + +func wsGetMRFiles(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 { + return badRequest(msg, "project and iid required") + } + files, err := svc.GetMRFiles(ctx, req.Project, req.IID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"files": files}) + } +} + +func wsGetMRCommits(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 { + return badRequest(msg, "project and iid required") + } + commits, err := svc.GetMRCommits(ctx, req.Project, req.IID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"commits": commits}) + } +} + +func wsSyncTaskMR(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := parseInto(msg, &req); err != nil || req.TaskID == "" { + return badRequest(msg, "task_id required") + } + rows, err := svc.TriggerMRSync(ctx, req.TaskID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"task_mrs": rows}) + } +} + +func wsGetStats(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + stats, err := svc.GetStats(ctx) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, stats) + } +} + +// --- Review watch handlers --- + +func wsListReviewWatches(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + WorkspaceID string `json:"workspace_id"` + } + if err := parseInto(msg, &req); err != nil { + return badRequest(msg, errMsgInvalidPayload) + } + var watches []*ReviewWatch + var err error + if req.WorkspaceID == "" { + watches, err = svc.ListAllReviewWatches(ctx) + } else { + watches, err = svc.ListReviewWatches(ctx, req.WorkspaceID) + } + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"watches": watches}) + } +} + +func wsCreateReviewWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req CreateReviewWatchRequest + if err := parseInto(msg, &req); err != nil { + return badRequest(msg, errMsgInvalidPayload) + } + w, err := svc.CreateReviewWatch(ctx, &req) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, w) + } +} + +func wsUpdateReviewWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + UpdateReviewWatchRequest + } + if err := parseInto(msg, &req); err != nil || req.ID == "" { + return badRequest(msg, errMsgIDRequired) + } + if err := svc.UpdateReviewWatch(ctx, req.ID, &req.UpdateReviewWatchRequest); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"updated": true}) + } +} + +func wsDeleteReviewWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + } + if err := parseInto(msg, &req); err != nil || req.ID == "" { + return badRequest(msg, errMsgIDRequired) + } + if err := svc.DeleteReviewWatch(ctx, req.ID); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{respKeyDeleted: true}) + } +} + +func wsTriggerReviewWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + } + if err := parseInto(msg, &req); err != nil || req.ID == "" { + return badRequest(msg, errMsgIDRequired) + } + mrs, err := svc.TriggerReviewWatch(ctx, req.ID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"mrs": mrs}) + } +} + +func wsTriggerAllReviewChecks(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + n, err := svc.TriggerReviewWatchAll(ctx) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"count": n}) + } +} + +// --- MR watch handlers --- + +func wsListMRWatches(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + } + if err := parseInto(msg, &req); err != nil { + return badRequest(msg, errMsgInvalidPayload) + } + switch { + case req.SessionID != "": + ws, err := svc.ListMRWatchesBySession(ctx, req.SessionID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"watches": ws}) + case req.TaskID != "": + ws, err := svc.ListMRWatchesByTask(ctx, req.TaskID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"watches": ws}) + default: + ws, err := svc.ListActiveMRWatches(ctx) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"watches": ws}) + } + } +} + +func wsDeleteMRWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + } + if err := parseInto(msg, &req); err != nil || req.ID == "" { + return badRequest(msg, errMsgIDRequired) + } + if err := svc.DeleteMRWatch(ctx, req.ID); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{respKeyDeleted: true}) + } +} + +// --- Issue watch handlers --- + +func wsListIssueWatches(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + WorkspaceID string `json:"workspace_id"` + } + if err := parseInto(msg, &req); err != nil { + return badRequest(msg, errMsgInvalidPayload) + } + var watches []*IssueWatch + var err error + if req.WorkspaceID == "" { + watches, err = svc.ListAllIssueWatches(ctx) + } else { + watches, err = svc.ListIssueWatches(ctx, req.WorkspaceID) + } + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"watches": watches}) + } +} + +func wsCreateIssueWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req CreateIssueWatchRequest + if err := parseInto(msg, &req); err != nil { + return badRequest(msg, errMsgInvalidPayload) + } + w, err := svc.CreateIssueWatch(ctx, &req) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, w) + } +} + +func wsUpdateIssueWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + UpdateIssueWatchRequest + } + if err := parseInto(msg, &req); err != nil || req.ID == "" { + return badRequest(msg, errMsgIDRequired) + } + if err := svc.UpdateIssueWatch(ctx, req.ID, &req.UpdateIssueWatchRequest); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"updated": true}) + } +} + +func wsDeleteIssueWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + } + if err := parseInto(msg, &req); err != nil || req.ID == "" { + return badRequest(msg, errMsgIDRequired) + } + if err := svc.DeleteIssueWatch(ctx, req.ID); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{respKeyDeleted: true}) + } +} + +func wsTriggerIssueWatch(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + ID string `json:"id"` + } + if err := parseInto(msg, &req); err != nil || req.ID == "" { + return badRequest(msg, errMsgIDRequired) + } + issues, err := svc.TriggerIssueWatch(ctx, req.ID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"issues": issues}) + } +} + +func wsTriggerAllIssueChecks(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + n, err := svc.TriggerIssueWatchAll(ctx) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"count": n}) + } +} + +// --- Write actions --- + +func wsMergeMR(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + Method string `json:"method"` + SquashCommitMessage string `json:"squash_commit_message"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 { + return badRequest(msg, "project and iid required") + } + mr, err := svc.MergeMR(ctx, req.Project, req.IID, req.Method, req.SquashCommitMessage) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, mr) + } +} + +func wsApproveMR(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 { + return badRequest(msg, "project and iid required") + } + if err := svc.SubmitMRApproval(ctx, req.Project, req.IID); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"approved": true}) + } +} + +func wsUnapproveMR(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 { + return badRequest(msg, "project and iid required") + } + if err := svc.SubmitMRUnapproval(ctx, req.Project, req.IID); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"unapproved": true}) + } +} + +func wsSetMRLabels(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + Labels []string `json:"labels"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 { + return badRequest(msg, "project and iid required") + } + if err := svc.SetMRLabels(ctx, req.Project, req.IID, req.Labels); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"updated": true}) + } +} + +func wsSetMRAssignees(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + AssigneeIDs []int `json:"assignee_ids"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 { + return badRequest(msg, "project and iid required") + } + if err := svc.SetMRAssignees(ctx, req.Project, req.IID, req.AssigneeIDs); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"updated": true}) + } +} + +func wsNewDiscussionNote(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + DiscussionID string `json:"discussion_id"` + Body string `json:"body"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 || req.DiscussionID == "" { + return badRequest(msg, "project, iid, discussion_id required") + } + if strings.TrimSpace(req.Body) == "" { + return badRequest(msg, "body required") + } + note, err := svc.CreateMRDiscussionNote(ctx, req.Project, req.IID, req.DiscussionID, req.Body) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, note) + } +} + +func wsResolveDiscussion(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + IID int `json:"iid"` + DiscussionID string `json:"discussion_id"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" || req.IID <= 0 || req.DiscussionID == "" { + return badRequest(msg, "project, iid, discussion_id required") + } + if err := svc.ResolveMRDiscussion(ctx, req.Project, req.IID, req.DiscussionID); err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"resolved": true}) + } +} + +func wsGetProjectMergeMethods(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" { + return badRequest(msg, "project required") + } + methods, err := svc.GetProjectMergeMethods(ctx, req.Project) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, methods) + } +} + +// --- Projects --- + +func wsListUserProjects(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + projects, err := svc.ListUserProjects(ctx) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"projects": projects}) + } +} + +func wsSearchProjects(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + if err := parseInto(msg, &req); err != nil { + return badRequest(msg, errMsgInvalidPayload) + } + projects, err := svc.SearchProjects(ctx, req.Query, req.Limit) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"projects": projects}) + } +} + +func wsListProjectBranches(svc *Service, _ *logger.Logger) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + Project string `json:"project"` + } + if err := parseInto(msg, &req); err != nil || req.Project == "" { + return badRequest(msg, "project required") + } + branches, err := svc.ListProjectBranches(ctx, req.Project) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]interface{}{"branches": branches}) + } +} + +// --- Action presets --- + +func wsListActionPresets(svc *Service) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + WorkspaceID string `json:"workspace_id"` + } + if err := parseInto(msg, &req); err != nil || req.WorkspaceID == "" { + return badRequest(msg, "workspace_id required") + } + presets, err := svc.GetActionPresetsOrDefault(ctx, req.WorkspaceID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, presets) + } +} + +func wsUpdateActionPresets(svc *Service) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req UpdateActionPresetsRequest + if err := parseInto(msg, &req); err != nil || req.WorkspaceID == "" { + return badRequest(msg, "workspace_id required") + } + presets, err := svc.UpdateActionPresets(ctx, &req) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, presets) + } +} + +func wsResetActionPresets(svc *Service) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + WorkspaceID string `json:"workspace_id"` + } + if err := parseInto(msg, &req); err != nil || req.WorkspaceID == "" { + return badRequest(msg, "workspace_id required") + } + presets, err := svc.ResetActionPresets(ctx, req.WorkspaceID) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, presets) + } +} + +// --- Cleanup --- + +func wsCleanupReviewTasks(svc *Service) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + n, err := svc.CleanupAllReviewTasks(ctx) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]int{respKeyDeleted: n}) + } +} + +func wsCleanupIssueTasks(svc *Service) ws.HandlerFunc { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + n, err := svc.CleanupAllIssueTasks(ctx) + if err != nil { + return internalError(msg, err) + } + return okResponse(msg, map[string]int{respKeyDeleted: n}) + } +} diff --git a/apps/backend/internal/gitlab/mock_client.go b/apps/backend/internal/gitlab/mock_client.go index 65aefd9db4..4db9214641 100644 --- a/apps/backend/internal/gitlab/mock_client.go +++ b/apps/backend/internal/gitlab/mock_client.go @@ -3,6 +3,7 @@ package gitlab import ( "context" "fmt" + "strings" "sync" "time" ) @@ -398,6 +399,61 @@ func (c *MockClient) GetIssueState(_ context.Context, projectPath string, iid in 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() diff --git a/apps/backend/internal/gitlab/mock_controller.go b/apps/backend/internal/gitlab/mock_controller.go new file mode 100644 index 0000000000..82ad68c0e8 --- /dev/null +++ b/apps/backend/internal/gitlab/mock_controller.go @@ -0,0 +1,143 @@ +package gitlab + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" +) + +// MockController exposes HTTP endpoints to seed the in-memory MockClient. +// Activated only when KANDEV_MOCK_GITLAB=true. +type MockController struct { + mock *MockClient + service *Service + logger *logger.Logger +} + +// NewMockController creates a new MockController. +func NewMockController(mock *MockClient, svc *Service, log *logger.Logger) *MockController { + return &MockController{mock: mock, service: svc, logger: log} +} + +// RegisterRoutes wires the mock control endpoints under /api/v1/gitlab/mock. +func (c *MockController) RegisterRoutes(router *gin.Engine) { + api := router.Group("/api/v1/gitlab/mock") + api.PUT("/user", c.setUser) + api.POST("/mrs", c.seedMRs) + api.POST("/issues", c.seedIssues) + api.POST("/pipelines", c.seedPipelines) + api.POST("/discussions", c.seedDiscussions) + api.POST("/approvals", c.seedApprovals) + api.POST("/branches", c.seedBranches) +} + +type mockMRRequest struct { + Project string `json:"project"` + MRs []MR `json:"mrs"` +} + +type mockIssueRequest struct { + Project string `json:"project"` + Issues []Issue `json:"issues"` +} + +type mockPipelineRequest struct { + Project string `json:"project"` + Pipelines []Pipeline `json:"pipelines"` +} + +type mockDiscussionRequest struct { + Project string `json:"project"` + IID int `json:"iid"` + Discussions []MRDiscussion `json:"discussions"` +} + +type mockApprovalRequest struct { + Project string `json:"project"` + IID int `json:"iid"` + Approvals []MRApproval `json:"approvals"` + Required int `json:"required"` +} + +type mockBranchesRequest struct { + Project string `json:"project"` + Branches []RepoBranch `json:"branches"` +} + +func (c *MockController) setUser(ctx *gin.Context) { + var req struct { + Username string `json:"username"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.SetUser(req.Username) + ctx.JSON(http.StatusOK, gin.H{"username": req.Username}) +} + +func (c *MockController) seedMRs(ctx *gin.Context) { + var req mockMRRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and mrs required"}) + return + } + for i := range req.MRs { + c.mock.SeedMR(req.Project, &req.MRs[i]) + } + ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.MRs)}) +} + +func (c *MockController) seedIssues(ctx *gin.Context) { + var req mockIssueRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and issues required"}) + return + } + for i := range req.Issues { + c.mock.SeedIssue(req.Project, &req.Issues[i]) + } + ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Issues)}) +} + +func (c *MockController) seedPipelines(ctx *gin.Context) { + var req mockPipelineRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and pipelines required"}) + return + } + c.mock.SeedPipelines(req.Project, req.Pipelines) + ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Pipelines)}) +} + +func (c *MockController) seedDiscussions(ctx *gin.Context) { + var req mockDiscussionRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" || req.IID <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "project, iid, discussions required"}) + return + } + c.mock.SeedDiscussions(req.Project, req.IID, req.Discussions) + ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Discussions)}) +} + +func (c *MockController) seedApprovals(ctx *gin.Context) { + var req mockApprovalRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" || req.IID <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and iid required"}) + return + } + c.mock.SeedApprovals(req.Project, req.IID, req.Approvals, req.Required) + ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Approvals), "required": req.Required}) +} + +func (c *MockController) seedBranches(ctx *gin.Context) { + var req mockBranchesRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and branches required"}) + return + } + c.mock.SeedBranches(req.Project, req.Branches) + ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Branches)}) +} diff --git a/apps/backend/internal/gitlab/noop_client.go b/apps/backend/internal/gitlab/noop_client.go index 164ec627a9..d8dde70e6e 100644 --- a/apps/backend/internal/gitlab/noop_client.go +++ b/apps/backend/internal/gitlab/noop_client.go @@ -130,3 +130,31 @@ func (c *NoopClient) SearchMRsPaged(context.Context, string, string, int, int) ( func (c *NoopClient) GetIssueState(context.Context, string, int) (string, error) { return "", ErrNoClient } + +func (c *NoopClient) MergeMR(context.Context, string, int, bool, string) (*MR, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) GetProjectMergeMethods(context.Context, string) (*ProjectMergeMethods, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) GetProtectedBranch(context.Context, string, string) (*ProtectedBranch, error) { + return nil, nil +} + +func (c *NoopClient) ListUserProjects(context.Context) ([]Project, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) SearchProjects(context.Context, string, int) ([]Project, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) SetMRLabels(context.Context, string, int, []string) error { + return ErrNoClient +} + +func (c *NoopClient) SetMRAssignees(context.Context, string, int, []int) error { + return ErrNoClient +} diff --git a/apps/backend/internal/gitlab/pat_client_actions.go b/apps/backend/internal/gitlab/pat_client_actions.go new file mode 100644 index 0000000000..966198a45c --- /dev/null +++ b/apps/backend/internal/gitlab/pat_client_actions.go @@ -0,0 +1,181 @@ +package gitlab + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" +) + +// --- Merge --- + +// MergeMRRequest is the JSON body for accepting an MR. +type MergeMRRequest struct { + MergeCommitMessage string `json:"merge_commit_message,omitempty"` + SquashCommitMessage string `json:"squash_commit_message,omitempty"` + Squash bool `json:"squash,omitempty"` + ShouldRemoveSourceBranch bool `json:"should_remove_source_branch,omitempty"` +} + +// MergeMR accepts an MR. GitLab supports three project-level merge methods +// (merge, rebase_merge, ff); the caller picks one and the project must allow +// it. Passing squash=true performs a squash merge regardless of method. +func (c *PATClient) MergeMR(ctx context.Context, projectPath string, iid int, squash bool, squashCommitMessage string) (*MR, error) { + endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d/merge", projectRef(projectPath), iid) + body, err := json.Marshal(MergeMRRequest{ + Squash: squash, + SquashCommitMessage: squashCommitMessage, + ShouldRemoveSourceBranch: false, + }) + if err != nil { + return nil, fmt.Errorf("encode merge request: %w", err) + } + var raw rawMR + if err := c.doWrite(ctx, "PUT", endpoint, body, &raw); err != nil { + return nil, fmt.Errorf("merge MR !%d: %w", iid, err) + } + return convertRawMR(&raw), nil +} + +// --- Project merge methods --- + +// GetProjectMergeMethods reads a project's `merge_method` and `squash_option` +// settings so the merge button UI can offer the right options. +func (c *PATClient) GetProjectMergeMethods(ctx context.Context, projectPath string) (*ProjectMergeMethods, error) { + var raw struct { + MergeMethod string `json:"merge_method"` // merge, rebase_merge, ff + SquashOption string `json:"squash_option"` // never, default_off, default_on, always + } + endpoint := fmt.Sprintf("/projects/%s", projectRef(projectPath)) + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, fmt.Errorf("get project merge methods: %w", err) + } + out := &ProjectMergeMethods{} + switch raw.MergeMethod { + case "merge": + out.Merge = true + case "rebase_merge": + out.RebaseMerge = true + case "ff": + out.FastForward = true + default: + out.Merge = true + } + if raw.SquashOption != "" && raw.SquashOption != "never" { + out.AllowSquash = true + } + return out, nil +} + +// --- Branch protection --- + +// ProtectedBranch represents a GitLab protected branch rule. +type ProtectedBranch struct { + Name string `json:"name"` + PushAccessLevel int `json:"push_access_level"` + MergeAccessLevel int `json:"merge_access_level"` + CodeOwnerApprovalRequired bool `json:"code_owner_approval_required"` +} + +// GetProtectedBranch returns the protected-branch settings for a branch (404 → nil). +func (c *PATClient) GetProtectedBranch(ctx context.Context, projectPath, branch string) (*ProtectedBranch, error) { + endpoint := fmt.Sprintf("/projects/%s/protected_branches/%s", + projectRef(projectPath), url.PathEscape(branch)) + var raw struct { + Name string `json:"name"` + PushAccessLevels []struct { + AccessLevel int `json:"access_level"` + } `json:"push_access_levels"` + MergeAccessLevels []struct { + AccessLevel int `json:"access_level"` + } `json:"merge_access_levels"` + CodeOwnerApprovalRequired bool `json:"code_owner_approval_required"` + } + if err := c.get(ctx, endpoint, &raw); err != nil { + // 404 means the branch isn't protected — return nil without error. + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + return nil, nil + } + return nil, fmt.Errorf("get protected branch: %w", err) + } + out := &ProtectedBranch{ + Name: raw.Name, + CodeOwnerApprovalRequired: raw.CodeOwnerApprovalRequired, + } + if len(raw.PushAccessLevels) > 0 { + out.PushAccessLevel = raw.PushAccessLevels[0].AccessLevel + } + if len(raw.MergeAccessLevels) > 0 { + out.MergeAccessLevel = raw.MergeAccessLevels[0].AccessLevel + } + return out, nil +} + +// --- Projects (autocomplete + global search) --- + +// ListUserProjects lists projects the authenticated user is a member of. +// GitLab equivalent of GitHub's ListUserOrgs + repo enumeration. +func (c *PATClient) ListUserProjects(ctx context.Context) ([]Project, error) { + var raw []rawProject + if err := c.get(ctx, "/projects?membership=true&simple=true&per_page=100&order_by=last_activity_at", &raw); err != nil { + return nil, fmt.Errorf("list user projects: %w", err) + } + out := make([]Project, len(raw)) + for i := range raw { + out[i] = convertRawProject(&raw[i]) + } + return out, nil +} + +// SearchProjects searches all projects matching `query`. Used by the +// project-autocomplete and watch-creation flows. +func (c *PATClient) SearchProjects(ctx context.Context, query string, limit int) ([]Project, error) { + if limit <= 0 { + limit = 20 + } + endpoint := fmt.Sprintf("/projects?membership=true&simple=true&per_page=%d", limit) + if strings.TrimSpace(query) != "" { + endpoint += "&search=" + url.QueryEscape(query) + } + var raw []rawProject + if err := c.get(ctx, endpoint, &raw); err != nil { + return nil, fmt.Errorf("search projects: %w", err) + } + out := make([]Project, len(raw)) + for i := range raw { + out[i] = convertRawProject(&raw[i]) + } + return out, nil +} + +// --- Labels --- + +// SetMRLabels replaces an MR's labels with the given set. +func (c *PATClient) SetMRLabels(ctx context.Context, projectPath string, iid int, labels []string) error { + endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d", projectRef(projectPath), iid) + body, err := json.Marshal(map[string]string{"labels": strings.Join(labels, ",")}) + if err != nil { + return fmt.Errorf("encode set-labels payload: %w", err) + } + if err := c.doWrite(ctx, "PUT", endpoint, body, nil); err != nil { + return fmt.Errorf("set MR labels: %w", err) + } + return nil +} + +// SetMRAssignees replaces an MR's assignees with the given set of user IDs. +// GitLab takes numeric user IDs (not usernames) for this endpoint. +func (c *PATClient) SetMRAssignees(ctx context.Context, projectPath string, iid int, assigneeIDs []int) error { + endpoint := fmt.Sprintf("/projects/%s/merge_requests/%d", projectRef(projectPath), iid) + body, err := json.Marshal(map[string][]int{"assignee_ids": assigneeIDs}) + if err != nil { + return fmt.Errorf("encode set-assignees payload: %w", err) + } + if err := c.doWrite(ctx, "PUT", endpoint, body, nil); err != nil { + return fmt.Errorf("set MR assignees: %w", err) + } + return nil +} diff --git a/apps/backend/internal/gitlab/poller.go b/apps/backend/internal/gitlab/poller.go new file mode 100644 index 0000000000..9eb7b9dcc2 --- /dev/null +++ b/apps/backend/internal/gitlab/poller.go @@ -0,0 +1,203 @@ +package gitlab + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" +) + +const ( + defaultMRPollInterval = 1 * time.Minute + defaultReviewPollInterval = 5 * time.Minute + defaultIssuePollInterval = 5 * time.Minute +) + +// Poller runs background loops for MR monitoring + review/issue queue checking. +type Poller struct { + service *Service + eventBus bus.EventBus + logger *logger.Logger + + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + started bool +} + +// NewPoller creates a new background poller. +func NewPoller(svc *Service, eventBus bus.EventBus, log *logger.Logger) *Poller { + return &Poller{service: svc, eventBus: eventBus, logger: log} +} + +// Start kicks off the polling loops. Repeated Start calls are no-ops. +// Safe to call concurrently with Stop. +func (p *Poller) Start(ctx context.Context) { + p.mu.Lock() + defer p.mu.Unlock() + if p.started { + return + } + p.started = true + ctx, p.cancel = context.WithCancel(ctx) + + p.wg.Add(3) + go p.mrMonitorLoop(ctx) + go p.reviewWatchLoop(ctx) + go p.issueWatchLoop(ctx) +} + +// Stop cancels all loops and waits for them to drain. Safe to call +// concurrently with Start. +func (p *Poller) Stop() { + p.mu.Lock() + if !p.started || p.cancel == nil { + p.mu.Unlock() + return + } + cancel := p.cancel + p.mu.Unlock() + cancel() + p.wg.Wait() + p.mu.Lock() + p.started = false + p.mu.Unlock() +} + +// --- MR watcher loop --- + +func (p *Poller) mrMonitorLoop(ctx context.Context) { + defer p.wg.Done() + ticker := time.NewTicker(defaultMRPollInterval) + defer ticker.Stop() + + // Initial run. + p.runMRMonitor(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.runMRMonitor(ctx) + } + } +} + +func (p *Poller) runMRMonitor(ctx context.Context) { + watches, err := p.service.ListActiveMRWatches(ctx) + if err != nil { + p.logger.Warn("gitlab poller: list MR watches", zap.Error(err)) + return + } + for _, w := range watches { + if ctx.Err() != nil { + return + } + if _, _, err := p.service.CheckMRWatch(ctx, w); err != nil { + p.logger.Debug("gitlab poller: check MR watch", + zap.String("watch_id", w.ID), zap.Error(err)) + } + } +} + +// --- Review watcher loop --- + +func (p *Poller) reviewWatchLoop(ctx context.Context) { + defer p.wg.Done() + ticker := time.NewTicker(defaultReviewPollInterval) + defer ticker.Stop() + + p.runReviewWatch(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.runReviewWatch(ctx) + } + } +} + +func (p *Poller) runReviewWatch(ctx context.Context) { + watches, err := p.service.ListAllReviewWatches(ctx) + if err != nil { + p.logger.Warn("gitlab poller: list review watches", zap.Error(err)) + return + } + now := time.Now().UTC() + for _, w := range watches { + if ctx.Err() != nil { + return + } + if !w.Enabled || !shouldPollWatch(w.LastPolledAt, w.PollIntervalSeconds, now) { + continue + } + newMRs, err := p.service.CheckReviewWatch(ctx, w) + if err != nil { + p.logger.Debug("gitlab poller: review watch check", + zap.String("watch_id", w.ID), zap.Error(err)) + continue + } + for _, mr := range newMRs { + p.service.publishNewReviewMREvent(ctx, w, mr) + } + } +} + +// --- Issue watcher loop --- + +func (p *Poller) issueWatchLoop(ctx context.Context) { + defer p.wg.Done() + ticker := time.NewTicker(defaultIssuePollInterval) + defer ticker.Stop() + + p.runIssueWatch(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.runIssueWatch(ctx) + } + } +} + +func (p *Poller) runIssueWatch(ctx context.Context) { + watches, err := p.service.ListAllIssueWatches(ctx) + if err != nil { + p.logger.Warn("gitlab poller: list issue watches", zap.Error(err)) + return + } + now := time.Now().UTC() + for _, w := range watches { + if ctx.Err() != nil { + return + } + if !w.Enabled || !shouldPollWatch(w.LastPolledAt, w.PollIntervalSeconds, now) { + continue + } + newIssues, err := p.service.CheckIssueWatch(ctx, w) + if err != nil { + p.logger.Debug("gitlab poller: issue watch check", + zap.String("watch_id", w.ID), zap.Error(err)) + continue + } + for _, issue := range newIssues { + p.service.publishNewIssueEvent(ctx, w, issue) + } + } +} + +func shouldPollWatch(last *time.Time, intervalSec int, now time.Time) bool { + if last == nil { + return true + } + if intervalSec <= 0 { + intervalSec = defaultWatchPollIntervalSec + } + return now.Sub(*last) >= time.Duration(intervalSec)*time.Second +} diff --git a/apps/backend/internal/gitlab/service.go b/apps/backend/internal/gitlab/service.go index 37081cbb02..07c686f096 100644 --- a/apps/backend/internal/gitlab/service.go +++ b/apps/backend/internal/gitlab/service.go @@ -12,6 +12,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" ) // secretNameToken is the canonical secret-store name for the GitLab PAT. @@ -37,20 +38,51 @@ type HostStore interface { SetHost(ctx context.Context, host string) error } -// Service coordinates GitLab integration operations. v1 surface is -// deliberately small: status, token configure/clear, host configure, MR -// feedback fetch, and MR discussion reply/resolve. Watches, presets, and -// stats are intentionally deferred to a follow-up. +// TaskDeleter deletes tasks by ID. Used for cleaning up merged MR tasks. +type TaskDeleter interface { + DeleteTask(ctx context.Context, taskID string) error +} + +// TaskSessionChecker checks whether the user genuinely engaged with a task. +type TaskSessionChecker interface { + HasUserAuthoredMessage(ctx context.Context, taskID string) (bool, error) +} + +// Service coordinates GitLab integration operations. type Service struct { - mu sync.RWMutex - host string - client Client - authMethod string - secrets SecretProvider - secretManager SecretManager - hostStore HostStore - store *Store - logger *logger.Logger + mu sync.RWMutex + host string + client Client + authMethod string + secrets SecretProvider + secretManager SecretManager + hostStore HostStore + store *Store + eventBus bus.EventBus + taskDeleter TaskDeleter + taskSessionChecker TaskSessionChecker + logger *logger.Logger +} + +// SetEventBus wires the event bus for publishing review/issue/feedback events. +func (s *Service) SetEventBus(b bus.EventBus) { + s.mu.Lock() + defer s.mu.Unlock() + s.eventBus = b +} + +// SetTaskDeleter wires the task-deletion dependency used by cleanup sweepers. +func (s *Service) SetTaskDeleter(d TaskDeleter) { + s.mu.Lock() + defer s.mu.Unlock() + s.taskDeleter = d +} + +// SetTaskSessionChecker wires the user-engagement check used by cleanup. +func (s *Service) SetTaskSessionChecker(c TaskSessionChecker) { + s.mu.Lock() + defer s.mu.Unlock() + s.taskSessionChecker = c } // SetStore wires the task↔MR persistence layer. Optional — when nil the diff --git a/apps/backend/internal/gitlab/service_cleanup.go b/apps/backend/internal/gitlab/service_cleanup.go new file mode 100644 index 0000000000..233864a3d6 --- /dev/null +++ b/apps/backend/internal/gitlab/service_cleanup.go @@ -0,0 +1,203 @@ +package gitlab + +import ( + "context" + "errors" + + "go.uber.org/zap" +) + +// CleanupAllReviewTasks deletes auto-created review tasks for merged/closed MRs, +// respecting each watch's cleanup policy. Returns the count of tasks deleted. +func (s *Service) CleanupAllReviewTasks(ctx context.Context) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + s.mu.RLock() + deleter := s.taskDeleter + checker := s.taskSessionChecker + s.mu.RUnlock() + if deleter == nil { + return 0, errors.New("task deleter not configured") + } + tasks, err := store.ListAllReviewMRTasks(ctx) + if err != nil { + return 0, err + } + return s.sweepReviewMRTasks(ctx, tasks, deleter, checker), nil +} + +func (s *Service) sweepReviewMRTasks(ctx context.Context, tasks []*ReviewMRTask, deleter TaskDeleter, checker TaskSessionChecker) int { + client := s.Client() + if client == nil { + return 0 + } + policies := s.resolveReviewWatchPolicies(ctx, tasks) + deleted := 0 + for _, t := range tasks { + if s.deleteReviewMRTaskIfTerminal(ctx, t, policies[t.ReviewWatchID], client, deleter, checker) { + deleted++ + } + } + return deleted +} + +func (s *Service) resolveReviewWatchPolicies(ctx context.Context, tasks []*ReviewMRTask) map[string]string { + policies := map[string]string{} + for _, t := range tasks { + if _, ok := policies[t.ReviewWatchID]; ok { + continue + } + policies[t.ReviewWatchID] = s.lookupReviewPolicy(ctx, t.ReviewWatchID) + } + return policies +} + +func (s *Service) lookupReviewPolicy(ctx context.Context, watchID string) string { + store := s.requireStore() + if store == nil { + return CleanupPolicyNever + } + w, err := store.GetReviewWatch(ctx, watchID) + if err == nil && w != nil { + return w.CleanupPolicy + } + // On transient DB error or genuinely-missing watch, fall back to + // CleanupPolicyNever so we never silently delete a task that the user + // might still care about. The genuine "watch was deleted" case is + // uncommon enough that requiring the user to manually delete the + // orphan tasks is the safer trade-off. + if err != nil { + s.logger.Warn("lookup review watch policy", zap.String("watch_id", watchID), zap.Error(err)) + } + return CleanupPolicyNever +} + +func (s *Service) deleteReviewMRTaskIfTerminal(ctx context.Context, t *ReviewMRTask, policy string, client Client, deleter TaskDeleter, checker TaskSessionChecker) bool { + if t.TaskID == "" || policy == CleanupPolicyNever { + return false + } + mrStatus, err := client.GetMRStatus(ctx, t.ProjectPath, t.MRIID) + if err != nil || mrStatus == nil || mrStatus.MR == nil { + return false + } + state := mrStatus.MR.State + if state != gitlabStateMerged && state != gitlabStateClosed { + return false + } + if policy == CleanupPolicyAuto && checker != nil { + // On a transient DB error preserve the task — silently deleting + // something the user might have engaged with is a much worse + // outcome than leaving an orphan to be reaped on the next sweep. + authored, err := checker.HasUserAuthoredMessage(ctx, t.TaskID) + if err != nil { + s.logger.Warn("check user authored message during cleanup", + zap.String("task_id", t.TaskID), zap.Error(err)) + return false + } + if authored { + return false + } + } + if err := deleter.DeleteTask(ctx, t.TaskID); err != nil { + s.logger.Warn("delete review task during cleanup", + zap.String("task_id", t.TaskID), zap.Error(err)) + return false + } + if store := s.requireStore(); store != nil { + _ = store.DeleteReviewMRTask(ctx, t.ID) + } + return true +} + +// CleanupAllIssueTasks deletes auto-created issue tasks for closed issues. +func (s *Service) CleanupAllIssueTasks(ctx context.Context) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + s.mu.RLock() + deleter := s.taskDeleter + checker := s.taskSessionChecker + s.mu.RUnlock() + if deleter == nil { + return 0, errors.New("task deleter not configured") + } + tasks, err := store.ListAllIssueWatchTasks(ctx) + if err != nil { + return 0, err + } + return s.sweepIssueWatchTasks(ctx, tasks, deleter, checker), nil +} + +func (s *Service) sweepIssueWatchTasks(ctx context.Context, tasks []*IssueWatchTask, deleter TaskDeleter, checker TaskSessionChecker) int { + client := s.Client() + if client == nil { + return 0 + } + policies := s.resolveIssueWatchPolicies(ctx, tasks) + deleted := 0 + for _, t := range tasks { + if s.deleteIssueWatchTaskIfTerminal(ctx, t, policies[t.IssueWatchID], client, deleter, checker) { + deleted++ + } + } + return deleted +} + +func (s *Service) resolveIssueWatchPolicies(ctx context.Context, tasks []*IssueWatchTask) map[string]string { + policies := map[string]string{} + for _, t := range tasks { + if _, ok := policies[t.IssueWatchID]; ok { + continue + } + policies[t.IssueWatchID] = s.lookupIssuePolicy(ctx, t.IssueWatchID) + } + return policies +} + +func (s *Service) lookupIssuePolicy(ctx context.Context, watchID string) string { + store := s.requireStore() + if store == nil { + return CleanupPolicyNever + } + w, err := store.GetIssueWatch(ctx, watchID) + if err == nil && w != nil { + return w.CleanupPolicy + } + if err != nil { + s.logger.Warn("lookup issue watch policy", zap.String("watch_id", watchID), zap.Error(err)) + } + return CleanupPolicyNever +} + +func (s *Service) deleteIssueWatchTaskIfTerminal(ctx context.Context, t *IssueWatchTask, policy string, client Client, deleter TaskDeleter, checker TaskSessionChecker) bool { + if t.TaskID == "" || policy == CleanupPolicyNever { + return false + } + state, err := client.GetIssueState(ctx, t.ProjectPath, t.IssueIID) + if err != nil || state != gitlabStateClosed { + return false + } + if policy == CleanupPolicyAuto && checker != nil { + authored, err := checker.HasUserAuthoredMessage(ctx, t.TaskID) + if err != nil { + s.logger.Warn("check user authored message during cleanup", + zap.String("task_id", t.TaskID), zap.Error(err)) + return false + } + if authored { + return false + } + } + if err := deleter.DeleteTask(ctx, t.TaskID); err != nil { + s.logger.Warn("delete issue task during cleanup", + zap.String("task_id", t.TaskID), zap.Error(err)) + return false + } + if store := s.requireStore(); store != nil { + _ = store.DeleteIssueWatchTask(ctx, t.ID) + } + return true +} diff --git a/apps/backend/internal/gitlab/service_events.go b/apps/backend/internal/gitlab/service_events.go new file mode 100644 index 0000000000..86e8bf9af0 --- /dev/null +++ b/apps/backend/internal/gitlab/service_events.go @@ -0,0 +1,100 @@ +package gitlab + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// publishMRFeedbackEvent fires when a watched MR's pipeline or approval +// state changes. Used by the UI to surface a toast or refresh the task. +func (s *Service) publishMRFeedbackEvent(ctx context.Context, watch *MRWatch, status *MRStatus) { + s.mu.RLock() + eb := s.eventBus + s.mu.RUnlock() + if eb == nil { + return + } + ev := &MRFeedbackEvent{ + SessionID: watch.SessionID, + TaskID: watch.TaskID, + ProjectPath: watch.ProjectPath, + MRIID: watch.MRIID, + NewPipelineState: status.PipelineState, + NewApprovalState: status.ApprovalState, + } + if err := eb.Publish(ctx, events.GitLabMRFeedback, bus.NewEvent(events.GitLabMRFeedback, eventSource, ev)); err != nil { + s.logger.Warn("publish gitlab feedback event", zap.Error(err)) + } +} + +// publishNewReviewMREvent fires when a review watch finds a new MR. +func (s *Service) publishNewReviewMREvent(ctx context.Context, watch *ReviewWatch, mr *MR) { + s.mu.RLock() + eb := s.eventBus + s.mu.RUnlock() + if eb == nil { + return + } + ev := &NewReviewMREvent{ + ReviewWatchID: watch.ID, + WorkspaceID: watch.WorkspaceID, + WorkflowID: watch.WorkflowID, + WorkflowStepID: watch.WorkflowStepID, + AgentProfileID: watch.AgentProfileID, + ExecutorProfileID: watch.ExecutorProfileID, + Prompt: watch.Prompt, + MR: mr, + } + if err := eb.Publish(ctx, events.GitLabNewReviewMR, bus.NewEvent(events.GitLabNewReviewMR, eventSource, ev)); err != nil { + s.logger.Warn("publish gitlab new review MR event", zap.Error(err)) + } +} + +// publishNewIssueEvent fires when an issue watch finds a new issue. +func (s *Service) publishNewIssueEvent(ctx context.Context, watch *IssueWatch, issue *Issue) { + s.mu.RLock() + eb := s.eventBus + s.mu.RUnlock() + if eb == nil { + return + } + ev := &NewIssueEvent{ + IssueWatchID: watch.ID, + WorkspaceID: watch.WorkspaceID, + WorkflowID: watch.WorkflowID, + WorkflowStepID: watch.WorkflowStepID, + AgentProfileID: watch.AgentProfileID, + ExecutorProfileID: watch.ExecutorProfileID, + Prompt: watch.Prompt, + Issue: issue, + } + if err := eb.Publish(ctx, events.GitLabNewIssue, bus.NewEvent(events.GitLabNewIssue, eventSource, ev)); err != nil { + s.logger.Warn("publish gitlab new issue event", zap.Error(err)) + } +} + +// publishWatchEvent fires when a watch is created/deleted (UI list refresh). +func (s *Service) publishWatchEvent(ctx context.Context, kind, id, sessionID, taskID string) { + s.mu.RLock() + eb := s.eventBus + s.mu.RUnlock() + if eb == nil { + return + } + payload := map[string]string{ + "kind": kind, + "id": id, + "session_id": sessionID, + "task_id": taskID, + } + if err := eb.Publish(ctx, events.GitLabWatchEvent, bus.NewEvent(events.GitLabWatchEvent, eventSource, payload)); err != nil { + s.logger.Warn("publish gitlab watch event", + zap.String("kind", kind), + zap.String("id", id), + zap.Error(err)) + } +} diff --git a/apps/backend/internal/gitlab/service_issue_watches.go b/apps/backend/internal/gitlab/service_issue_watches.go new file mode 100644 index 0000000000..60171cfab5 --- /dev/null +++ b/apps/backend/internal/gitlab/service_issue_watches.go @@ -0,0 +1,305 @@ +package gitlab + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "go.uber.org/zap" +) + +// CreateIssueWatch persists a new issue watch. +func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) { + if req == nil { + return nil, fmt.Errorf("nil request") + } + if !IsValidCleanupPolicy(req.CleanupPolicy) { + return nil, fmt.Errorf("invalid cleanup_policy: %q", req.CleanupPolicy) + } + iw := &IssueWatch{ + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + Projects: normalizeProjectFilters(req.Projects), + AgentProfileID: req.AgentProfileID, + ExecutorProfileID: req.ExecutorProfileID, + Prompt: req.Prompt, + Labels: req.Labels, + CustomQuery: req.CustomQuery, + Enabled: true, + PollIntervalSeconds: clampPollInterval(req.PollIntervalSeconds), + CleanupPolicy: NormalizeCleanupPolicy(req.CleanupPolicy), + } + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + if err := store.CreateIssueWatch(ctx, iw); err != nil { + return nil, fmt.Errorf("create issue watch: %w", err) + } + go s.initialIssueCheck(context.Background(), iw) + return iw, nil +} + +func (s *Service) initialIssueCheck(ctx context.Context, watch *IssueWatch) { + issues, err := s.CheckIssueWatch(ctx, watch) + if err != nil { + s.logger.Debug("initial gitlab issue check failed", + zap.String("watch_id", watch.ID), zap.Error(err)) + return + } + for _, issue := range issues { + s.publishNewIssueEvent(ctx, watch, issue) + } +} + +// GetIssueWatch returns an issue watch by id. +func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.GetIssueWatch(ctx, id) +} + +// ListIssueWatches lists issue watches in a workspace. +func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListIssueWatches(ctx, workspaceID) +} + +// ListAllIssueWatches returns every issue watch. +func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListAllIssueWatches(ctx) +} + +// UpdateIssueWatch applies a partial update. +func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) error { + if req == nil { + return fmt.Errorf("nil request") + } + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + iw, err := store.GetIssueWatch(ctx, id) + if err != nil { + return err + } + if iw == nil { + return fmt.Errorf("%w: issue watch %s", ErrWatchNotFound, id) + } + applyIssueWatchPatch(iw, req) + if req.CleanupPolicy != nil && !IsValidCleanupPolicy(*req.CleanupPolicy) { + return fmt.Errorf("invalid cleanup_policy: %q", *req.CleanupPolicy) + } + return store.UpdateIssueWatch(ctx, iw) +} + +//nolint:dupl // see applyReviewWatchPatch — same shape, different domain. +func applyIssueWatchPatch(iw *IssueWatch, req *UpdateIssueWatchRequest) { + if req.WorkflowID != nil { + iw.WorkflowID = *req.WorkflowID + } + if req.WorkflowStepID != nil { + iw.WorkflowStepID = *req.WorkflowStepID + } + if req.Projects != nil { + iw.Projects = normalizeProjectFilters(*req.Projects) + } + if req.AgentProfileID != nil { + iw.AgentProfileID = *req.AgentProfileID + } + if req.ExecutorProfileID != nil { + iw.ExecutorProfileID = *req.ExecutorProfileID + } + if req.Prompt != nil { + iw.Prompt = *req.Prompt + } + if req.Labels != nil { + iw.Labels = *req.Labels + } + if req.CustomQuery != nil { + iw.CustomQuery = *req.CustomQuery + } + if req.Enabled != nil { + iw.Enabled = *req.Enabled + } + if req.PollIntervalSeconds != nil { + iw.PollIntervalSeconds = clampPollInterval(*req.PollIntervalSeconds) + } + if req.CleanupPolicy != nil { + iw.CleanupPolicy = NormalizeCleanupPolicy(*req.CleanupPolicy) + } +} + +// DeleteIssueWatch removes an issue watch and best-effort reaps tasks. +func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + s.mu.RLock() + deleter := s.taskDeleter + s.mu.RUnlock() + if deleter != nil { + tasks, err := store.ListIssueWatchTasksByWatch(ctx, id) + if err != nil { + s.logger.Warn("failed to list issue tasks for pre-delete sweep", + zap.String("watch_id", id), zap.Error(err)) + } else { + s.sweepIssueWatchTasksOnDelete(ctx, id, tasks, deleter) + } + } + return store.DeleteIssueWatch(ctx, id) +} + +func (s *Service) sweepIssueWatchTasksOnDelete(ctx context.Context, watchID string, tasks []*IssueWatchTask, deleter TaskDeleter) { + for _, t := range tasks { + if t.TaskID == "" { + continue + } + if err := deleter.DeleteTask(ctx, t.TaskID); err != nil { + s.logger.Warn("failed to delete issue task during watch cleanup", + zap.String("watch_id", watchID), + zap.String("task_id", t.TaskID), + zap.Error(err)) + } + } +} + +// CheckIssueWatch polls a single watch and returns new issues. +// +//nolint:dupl // see CheckReviewWatch — same shape, different domain types. +func (s *Service) CheckIssueWatch(ctx context.Context, watch *IssueWatch) ([]*Issue, error) { + if watch == nil { + return nil, fmt.Errorf("watch is nil") + } + if !watch.Enabled { + return nil, nil + } + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + issues, err := s.fetchIssues(ctx, watch) + if err != nil { + return nil, err + } + out := make([]*Issue, 0, len(issues)) + for _, issue := range issues { + exists, err := store.HasIssueWatchTask(ctx, watch.ID, issue.ProjectPath, issue.IID) + if err != nil { + s.logger.Error("check issue watch dedup", zap.Error(err)) + continue + } + if !exists { + out = append(out, issue) + } + } + now := time.Now().UTC() + if err := store.RecordIssueWatchPoll(ctx, watch.ID, now); err != nil { + s.logger.Warn("record issue watch poll", zap.String("watch_id", watch.ID), zap.Error(err)) + } + return out, nil +} + +func (s *Service) fetchIssues(ctx context.Context, watch *IssueWatch) ([]*Issue, error) { + client := s.Client() + username, err := client.GetAuthenticatedUser(ctx) + if err != nil { + return nil, fmt.Errorf("resolve gitlab username: %w", err) + } + // When a custom_query is set, labels need to be folded into it (the + // client's buildIssueSearchQuery returns customQuery verbatim and + // ignores the auxiliary `filter` arg). Otherwise build a default + // "assigned to me" filter and append labels there. + filter := "" + customQuery := watch.CustomQuery + switch { + case customQuery != "": + if len(watch.Labels) > 0 { + customQuery = appendLabelsToQuery(customQuery, watch.Labels) + } + default: + filter = "assignee_username=" + url.QueryEscape(username) + if len(watch.Labels) > 0 { + filter += "&labels=" + url.QueryEscape(strings.Join(watch.Labels, ",")) + } + } + issues, err := client.ListIssues(ctx, filter, customQuery) + if err != nil { + return nil, fmt.Errorf("list issues: %w", err) + } + if len(watch.Projects) == 0 { + return issues, nil + } + allowed := make(map[string]bool, len(watch.Projects)) + for _, p := range watch.Projects { + allowed[strings.ToLower(strings.TrimSpace(p.Path))] = true + } + out := issues[:0] + for _, i := range issues { + if allowed[strings.ToLower(i.ProjectPath)] { + out = append(out, i) + } + } + return out, nil +} + +// TriggerIssueWatch runs the watch once on demand. +func (s *Service) TriggerIssueWatch(ctx context.Context, id string) ([]*Issue, error) { + iw, err := s.GetIssueWatch(ctx, id) + if err != nil { + return nil, err + } + if iw == nil { + return nil, fmt.Errorf("%w: issue watch %s", ErrWatchNotFound, id) + } + found, err := s.CheckIssueWatch(ctx, iw) + if err != nil { + return nil, err + } + for _, issue := range found { + s.publishNewIssueEvent(ctx, iw, issue) + } + return found, nil +} + +// TriggerIssueWatchAll runs every enabled watch. +func (s *Service) TriggerIssueWatchAll(ctx context.Context) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + watches, err := store.ListEnabledIssueWatches(ctx) + if err != nil { + return 0, err + } + total := 0 + for _, iw := range watches { + found, err := s.CheckIssueWatch(ctx, iw) + if err != nil { + s.logger.Warn("trigger issue watch all", zap.String("watch_id", iw.ID), zap.Error(err)) + continue + } + for _, issue := range found { + s.publishNewIssueEvent(ctx, iw, issue) + } + total += len(found) + } + return total, nil +} diff --git a/apps/backend/internal/gitlab/service_merge.go b/apps/backend/internal/gitlab/service_merge.go new file mode 100644 index 0000000000..fc1f729ab8 --- /dev/null +++ b/apps/backend/internal/gitlab/service_merge.go @@ -0,0 +1,149 @@ +package gitlab + +import ( + "context" + "fmt" + + "go.uber.org/zap" +) + +// MergeMR accepts a merge request, validating against the project's allowed +// merge methods. `method` is one of "merge", "rebase_merge", "ff", or "squash" +// (squash performs a squash regardless of project method). +func (s *Service) MergeMR(ctx context.Context, projectPath string, iid int, method, squashCommitMessage string) (*MR, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + methods, err := client.GetProjectMergeMethods(ctx, projectPath) + if err != nil { + return nil, fmt.Errorf("get project merge methods: %w", err) + } + squash := false + switch method { + case "merge": + if !methods.Merge { + return nil, fmt.Errorf("project does not allow merge commits") + } + case "rebase_merge": + if !methods.RebaseMerge { + return nil, fmt.Errorf("project does not allow rebase merge") + } + case "ff": + if !methods.FastForward { + return nil, fmt.Errorf("project does not allow fast-forward merge") + } + case "squash": + if !methods.AllowSquash { + return nil, fmt.Errorf("project does not allow squash merge") + } + squash = true + case "": + // caller didn't pick — use squash if available, else default. + squash = methods.AllowSquash + default: + return nil, fmt.Errorf("unknown merge method: %q", method) + } + mr, err := client.MergeMR(ctx, projectPath, iid, squash, squashCommitMessage) + if err != nil { + return nil, err + } + s.logger.Info("merged GitLab MR", zap.String("project", projectPath), zap.Int("iid", iid)) + return mr, nil +} + +// GetProjectMergeMethods proxies to the client and reports allowed merge methods. +func (s *Service) GetProjectMergeMethods(ctx context.Context, projectPath string) (*ProjectMergeMethods, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.GetProjectMergeMethods(ctx, projectPath) +} + +// SubmitMRApproval approves an MR. +func (s *Service) SubmitMRApproval(ctx context.Context, projectPath string, iid int) error { + client := s.Client() + if client == nil { + return ErrNoClient + } + if err := client.SubmitMRApproval(ctx, projectPath, iid); err != nil { + return fmt.Errorf("approve MR: %w", err) + } + return nil +} + +// SubmitMRUnapproval revokes the authenticated user's approval of an MR. +func (s *Service) SubmitMRUnapproval(ctx context.Context, projectPath string, iid int) error { + client := s.Client() + if client == nil { + return ErrNoClient + } + if err := client.SubmitMRUnapproval(ctx, projectPath, iid); err != nil { + return fmt.Errorf("unapprove MR: %w", err) + } + return nil +} + +// SetMRLabels replaces the labels on an MR. +func (s *Service) SetMRLabels(ctx context.Context, projectPath string, iid int, labels []string) error { + client := s.Client() + if client == nil { + return ErrNoClient + } + return client.SetMRLabels(ctx, projectPath, iid, labels) +} + +// SetMRAssignees replaces the assignees on an MR. +func (s *Service) SetMRAssignees(ctx context.Context, projectPath string, iid int, assigneeIDs []int) error { + client := s.Client() + if client == nil { + return ErrNoClient + } + return client.SetMRAssignees(ctx, projectPath, iid, assigneeIDs) +} + +// GetMRFiles lists files changed in an MR. +func (s *Service) GetMRFiles(ctx context.Context, projectPath string, iid int) ([]MRFile, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.ListMRFiles(ctx, projectPath, iid) +} + +// GetMRCommits lists commits in an MR. +func (s *Service) GetMRCommits(ctx context.Context, projectPath string, iid int) ([]MRCommitInfo, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.ListMRCommits(ctx, projectPath, iid) +} + +// ListUserProjects lists projects the user is a member of. +func (s *Service) ListUserProjects(ctx context.Context) ([]Project, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.ListUserProjects(ctx) +} + +// SearchProjects searches all visible projects by name. +func (s *Service) SearchProjects(ctx context.Context, query string, limit int) ([]Project, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.SearchProjects(ctx, query, limit) +} + +// ListProjectBranches lists branches for a project. +func (s *Service) ListProjectBranches(ctx context.Context, projectPath string) ([]RepoBranch, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.ListProjectBranches(ctx, projectPath) +} diff --git a/apps/backend/internal/gitlab/service_reservations.go b/apps/backend/internal/gitlab/service_reservations.go new file mode 100644 index 0000000000..1b7c6be13f --- /dev/null +++ b/apps/backend/internal/gitlab/service_reservations.go @@ -0,0 +1,59 @@ +package gitlab + +import "context" + +// ReserveReviewMRTask atomically claims an MR for task creation. Returns +// (true, nil) when this caller wins the race, (false, nil) when another +// caller has already reserved (or task created). +func (s *Service) ReserveReviewMRTask(ctx context.Context, watchID, projectPath string, iid int, mrURL string) (bool, error) { + store := s.requireStore() + if store == nil { + return false, errStoreUnavailable + } + return store.ReserveReviewMRTask(ctx, watchID, projectPath, iid, mrURL) +} + +// AssignReviewMRTaskID stamps the claim with the freshly-created task id. +func (s *Service) AssignReviewMRTaskID(ctx context.Context, watchID, projectPath string, iid int, taskID string) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + return store.AssignReviewMRTaskID(ctx, watchID, projectPath, iid, taskID) +} + +// ReleaseReviewMRTask undoes a reservation (used after task-create failure). +func (s *Service) ReleaseReviewMRTask(ctx context.Context, watchID, projectPath string, iid int) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + return store.ReleaseReviewMRTask(ctx, watchID, projectPath, iid) +} + +// ReserveIssueWatchTask atomically claims an issue for task creation. +func (s *Service) ReserveIssueWatchTask(ctx context.Context, watchID, projectPath string, iid int, issueURL string) (bool, error) { + store := s.requireStore() + if store == nil { + return false, errStoreUnavailable + } + return store.ReserveIssueWatchTask(ctx, watchID, projectPath, iid, issueURL) +} + +// AssignIssueWatchTaskID stamps the claim with the freshly-created task id. +func (s *Service) AssignIssueWatchTaskID(ctx context.Context, watchID, projectPath string, iid int, taskID string) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + return store.AssignIssueWatchTaskID(ctx, watchID, projectPath, iid, taskID) +} + +// ReleaseIssueWatchTask undoes a reservation. +func (s *Service) ReleaseIssueWatchTask(ctx context.Context, watchID, projectPath string, iid int) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + return store.ReleaseIssueWatchTask(ctx, watchID, projectPath, iid) +} diff --git a/apps/backend/internal/gitlab/service_search.go b/apps/backend/internal/gitlab/service_search.go new file mode 100644 index 0000000000..7fddd61f43 --- /dev/null +++ b/apps/backend/internal/gitlab/service_search.go @@ -0,0 +1,88 @@ +package gitlab + +import ( + "context" + + "go.uber.org/zap" +) + +// SearchUserMRs returns MRs matching a filter for the configured user. +func (s *Service) SearchUserMRs(ctx context.Context, filter, customQuery string) ([]*MR, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.SearchMRs(ctx, filter, customQuery) +} + +// SearchUserMRsPaged returns paginated MRs. +func (s *Service) SearchUserMRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*MRSearchPage, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.SearchMRsPaged(ctx, filter, customQuery, page, perPage) +} + +// SearchUserIssues returns issues matching a filter for the configured user. +func (s *Service) SearchUserIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.ListIssues(ctx, filter, customQuery) +} + +// SearchUserIssuesPaged returns paginated issues. +func (s *Service) SearchUserIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) { + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + return client.ListIssuesPaged(ctx, filter, customQuery, page, perPage) +} + +// GetStats aggregates open MR / awaiting-review / open-issue counts. +// Per-count failures are logged and degraded to zero so the dashboard +// renders partial data instead of failing the entire request — but the +// log gives ops something to triage when counts look wrong. +func (s *Service) GetStats(ctx context.Context) (*Stats, error) { + client := s.Client() + if client == nil { + return &Stats{}, nil + } + username, err := client.GetAuthenticatedUser(ctx) + if err != nil { + s.logger.Warn("gitlab stats: resolve authenticated user", zap.Error(err)) + return &Stats{}, nil + } + if username == "" { + return &Stats{}, nil + } + open, err := client.SearchMRsPaged(ctx, "scope=created_by_me&state=opened", "", 1, 1) + openMRs := 0 + if err != nil { + s.logger.Warn("gitlab stats: open MRs", zap.Error(err)) + } else if open != nil { + openMRs = open.TotalCount + } + awaiting, err := client.SearchMRsPaged(ctx, "reviewer_username="+username+"&state=opened", "", 1, 1) + awaitingMRs := 0 + if err != nil { + s.logger.Warn("gitlab stats: MRs awaiting review", zap.Error(err)) + } else if awaiting != nil { + awaitingMRs = awaiting.TotalCount + } + issues, err := client.ListIssuesPaged(ctx, "scope=assigned_to_me&state=opened", "", 1, 1) + openIssues := 0 + if err != nil { + s.logger.Warn("gitlab stats: open issues", zap.Error(err)) + } else if issues != nil { + openIssues = issues.TotalCount + } + return &Stats{ + OpenMRs: openMRs, + MRsAwaitingMyReview: awaitingMRs, + OpenIssuesAssignedMe: openIssues, + }, nil +} diff --git a/apps/backend/internal/gitlab/service_sync.go b/apps/backend/internal/gitlab/service_sync.go new file mode 100644 index 0000000000..cbc7914bbf --- /dev/null +++ b/apps/backend/internal/gitlab/service_sync.go @@ -0,0 +1,35 @@ +package gitlab + +import ( + "context" + "fmt" + + "go.uber.org/zap" +) + +// TriggerMRSync re-fetches every linked MR for a task and updates the stored +// rows. Returns the refreshed rows for the caller to broadcast. +func (s *Service) TriggerMRSync(ctx context.Context, taskID string) ([]*TaskMR, error) { + store := s.requireStore() + if store == nil { + return nil, fmt.Errorf("gitlab store not configured") + } + rows, err := store.ListTaskMRsByTask(ctx, taskID) + if err != nil { + return nil, err + } + out := make([]*TaskMR, 0, len(rows)) + for _, r := range rows { + updated, err := s.SyncTaskMR(ctx, r.TaskID, r.RepositoryID, r.ProjectPath, r.MRIID) + if err != nil { + s.logger.Warn("trigger MR sync", + zap.String("task_id", r.TaskID), + zap.String("project", r.ProjectPath), + zap.Int("iid", r.MRIID), + zap.Error(err)) + continue + } + out = append(out, updated) + } + return out, nil +} diff --git a/apps/backend/internal/gitlab/service_watches.go b/apps/backend/internal/gitlab/service_watches.go new file mode 100644 index 0000000000..5efd8e37ed --- /dev/null +++ b/apps/backend/internal/gitlab/service_watches.go @@ -0,0 +1,518 @@ +package gitlab + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "go.uber.org/zap" +) + +// eventSource is the `source` field on every bus.Event published by this +// package; consumed by event_handlers_gitlab.go-style subscribers. +const eventSource = "gitlab" + +const ( + defaultWatchPollIntervalSec = 300 + minWatchPollIntervalSec = 60 +) + +// --- MR Watch --- + +// CreateMRWatch records a session→MR watch row. The poller (and topbar) use it +// to discover a freshly-pushed MR off the agent's source branch. +func (s *Service) CreateMRWatch(ctx context.Context, sessionID, taskID, repositoryID, projectPath string, iid int, branch string) (*MRWatch, error) { + s.mu.RLock() + store := s.store + s.mu.RUnlock() + if store == nil { + return nil, fmt.Errorf("gitlab store not configured") + } + w := &MRWatch{ + SessionID: sessionID, + TaskID: taskID, + RepositoryID: repositoryID, + ProjectPath: projectPath, + MRIID: iid, + Branch: branch, + } + if err := store.CreateMRWatch(ctx, w); err != nil { + return nil, fmt.Errorf("create MR watch: %w", err) + } + s.publishWatchEvent(ctx, "mr_watch_created", w.ID, sessionID, taskID) + return w, nil +} + +// GetMRWatchBySession fetches the legacy single-repo watch. +func (s *Service) GetMRWatchBySession(ctx context.Context, sessionID string) (*MRWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.GetMRWatchBySession(ctx, sessionID) +} + +// GetMRWatchBySessionAndRepo fetches a watch keyed by (session, repo). +func (s *Service) GetMRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*MRWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.GetMRWatchBySessionAndRepo(ctx, sessionID, repositoryID) +} + +// ListMRWatchesBySession lists every MR watch on a session. +func (s *Service) ListMRWatchesBySession(ctx context.Context, sessionID string) ([]*MRWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListMRWatchesBySession(ctx, sessionID) +} + +// ListMRWatchesByTask lists every MR watch on a task. +func (s *Service) ListMRWatchesByTask(ctx context.Context, taskID string) ([]*MRWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListMRWatchesByTask(ctx, taskID) +} + +// ListActiveMRWatches returns every MR watch (used by the poller). +func (s *Service) ListActiveMRWatches(ctx context.Context) ([]*MRWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListActiveMRWatches(ctx) +} + +// DeleteMRWatch removes a single MR watch. +func (s *Service) DeleteMRWatch(ctx context.Context, id string) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + return store.DeleteMRWatch(ctx, id) +} + +// errStoreUnavailable is returned by Service methods that require the +// SQLite store to be wired but the runtime didn't manage to create it +// (table migration failure on boot). Distinct error so callers can render +// a "GitLab unconfigured" UI instead of "500 internal error". +var errStoreUnavailable = fmt.Errorf("gitlab store not configured") + +// ErrWatchNotFound is returned by Update/Trigger methods when the watch id +// doesn't exist. Sentinel so the HTTP controller can map it to 404 rather +// than 500. +var ErrWatchNotFound = fmt.Errorf("watch not found") + +// CheckMRWatch polls a watch once: returns the latest MR status and whether +// the underlying MR moved into a state worth notifying about (new note, +// pipeline transition, approval transition). +func (s *Service) CheckMRWatch(ctx context.Context, watch *MRWatch) (*MRStatus, bool, error) { + if watch == nil { + return nil, false, fmt.Errorf("watch is nil") + } + client := s.Client() + if client == nil { + return nil, false, ErrNoClient + } + store := s.requireStore() + if store == nil { + return nil, false, errStoreUnavailable + } + // If we don't yet know an iid, try to find it from the branch. + if watch.MRIID <= 0 { + mr, err := client.FindMRByBranch(ctx, watch.ProjectPath, watch.Branch) + if err != nil || mr == nil { + now := time.Now().UTC() + _ = store.UpdateMRWatchTimestamps(ctx, watch.ID, now, watch.LastNoteAt, watch.LastPipelineState, watch.LastApprovalState) + return nil, false, err + } + if err := store.UpdateMRWatchMRIID(ctx, watch.ID, mr.IID); err != nil { + return nil, false, fmt.Errorf("update MR watch iid: %w", err) + } + watch.MRIID = mr.IID + } + status, err := client.GetMRStatus(ctx, watch.ProjectPath, watch.MRIID) + if err != nil { + return nil, false, err + } + notable := watch.LastPipelineState != status.PipelineState || + watch.LastApprovalState != status.ApprovalState + now := time.Now().UTC() + if err := store.UpdateMRWatchTimestamps(ctx, watch.ID, now, watch.LastNoteAt, status.PipelineState, status.ApprovalState); err != nil { + return nil, false, fmt.Errorf("record MR watch poll: %w", err) + } + if notable { + s.publishMRFeedbackEvent(ctx, watch, status) + } + return status, notable, nil +} + +// --- Review Watch --- + +// CreateReviewWatch persists a new review watch. +func (s *Service) CreateReviewWatch(ctx context.Context, req *CreateReviewWatchRequest) (*ReviewWatch, error) { + if req == nil { + return nil, fmt.Errorf("nil request") + } + if !IsValidCleanupPolicy(req.CleanupPolicy) { + return nil, fmt.Errorf("invalid cleanup_policy: %q", req.CleanupPolicy) + } + interval := req.PollIntervalSeconds + if interval <= 0 { + interval = defaultWatchPollIntervalSec + } + if interval < minWatchPollIntervalSec { + interval = minWatchPollIntervalSec + } + scope := req.ReviewScope + if scope == "" { + scope = ReviewScopeUserAndTeams + } + rw := &ReviewWatch{ + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + Projects: normalizeProjectFilters(req.Projects), + AgentProfileID: req.AgentProfileID, + ExecutorProfileID: req.ExecutorProfileID, + Prompt: req.Prompt, + ReviewScope: scope, + CustomQuery: req.CustomQuery, + Enabled: true, + PollIntervalSeconds: interval, + CleanupPolicy: NormalizeCleanupPolicy(req.CleanupPolicy), + } + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + if err := store.CreateReviewWatch(ctx, rw); err != nil { + return nil, fmt.Errorf("create review watch: %w", err) + } + go s.initialReviewCheck(context.Background(), rw) + return rw, nil +} + +func (s *Service) initialReviewCheck(ctx context.Context, watch *ReviewWatch) { + mrs, err := s.CheckReviewWatch(ctx, watch) + if err != nil { + s.logger.Debug("initial gitlab review check failed", + zap.String("watch_id", watch.ID), zap.Error(err)) + return + } + for _, mr := range mrs { + s.publishNewReviewMREvent(ctx, watch, mr) + } +} + +// GetReviewWatch returns a review watch by id. +func (s *Service) GetReviewWatch(ctx context.Context, id string) (*ReviewWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.GetReviewWatch(ctx, id) +} + +// ListReviewWatches lists review watches in a workspace. +func (s *Service) ListReviewWatches(ctx context.Context, workspaceID string) ([]*ReviewWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListReviewWatches(ctx, workspaceID) +} + +// ListAllReviewWatches returns every review watch. +func (s *Service) ListAllReviewWatches(ctx context.Context) ([]*ReviewWatch, error) { + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + return store.ListAllReviewWatches(ctx) +} + +// UpdateReviewWatch applies a partial update to a review watch. +func (s *Service) UpdateReviewWatch(ctx context.Context, id string, req *UpdateReviewWatchRequest) error { + if req == nil { + return fmt.Errorf("nil request") + } + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + rw, err := store.GetReviewWatch(ctx, id) + if err != nil { + return err + } + if rw == nil { + return fmt.Errorf("%w: review watch %s", ErrWatchNotFound, id) + } + applyReviewWatchPatch(rw, req) + if req.CleanupPolicy != nil && !IsValidCleanupPolicy(*req.CleanupPolicy) { + return fmt.Errorf("invalid cleanup_policy: %q", *req.CleanupPolicy) + } + return store.UpdateReviewWatch(ctx, rw) +} + +// the ReviewWatch shape (with ReviewScope instead of Labels). The two are +// kept as separate functions so each domain's fields are explicit; merging +// them via generics would obscure the per-type validation that lives in +// CreateXxxWatch. +// +//nolint:dupl // structurally similar to applyIssueWatchPatch but operates on +func applyReviewWatchPatch(rw *ReviewWatch, req *UpdateReviewWatchRequest) { + if req.WorkflowID != nil { + rw.WorkflowID = *req.WorkflowID + } + if req.WorkflowStepID != nil { + rw.WorkflowStepID = *req.WorkflowStepID + } + if req.Projects != nil { + rw.Projects = normalizeProjectFilters(*req.Projects) + } + if req.AgentProfileID != nil { + rw.AgentProfileID = *req.AgentProfileID + } + if req.ExecutorProfileID != nil { + rw.ExecutorProfileID = *req.ExecutorProfileID + } + if req.Prompt != nil { + rw.Prompt = *req.Prompt + } + if req.ReviewScope != nil { + rw.ReviewScope = *req.ReviewScope + } + if req.CustomQuery != nil { + rw.CustomQuery = *req.CustomQuery + } + if req.Enabled != nil { + rw.Enabled = *req.Enabled + } + if req.PollIntervalSeconds != nil { + rw.PollIntervalSeconds = clampPollInterval(*req.PollIntervalSeconds) + } + if req.CleanupPolicy != nil { + rw.CleanupPolicy = NormalizeCleanupPolicy(*req.CleanupPolicy) + } +} + +// clampPollInterval enforces the same bounds the create path applies (0 → default, +// below minimum → minimum). Used by both review-watch and issue-watch update paths. +func clampPollInterval(seconds int) int { + if seconds <= 0 { + return defaultWatchPollIntervalSec + } + if seconds < minWatchPollIntervalSec { + return minWatchPollIntervalSec + } + return seconds +} + +// DeleteReviewWatch removes a review watch and best-effort reaps any tasks +// it owned (tasks survive when the dedup row dies, so pre-sweep first). +func (s *Service) DeleteReviewWatch(ctx context.Context, id string) error { + store := s.requireStore() + if store == nil { + return errStoreUnavailable + } + s.mu.RLock() + deleter := s.taskDeleter + s.mu.RUnlock() + if deleter != nil { + mrTasks, err := store.ListReviewMRTasksByWatch(ctx, id) + if err != nil { + s.logger.Warn("failed to list review MR tasks for pre-delete sweep", + zap.String("watch_id", id), zap.Error(err)) + } else { + s.sweepReviewWatchTasks(ctx, id, mrTasks, deleter) + } + } + return store.DeleteReviewWatch(ctx, id) +} + +func (s *Service) sweepReviewWatchTasks(ctx context.Context, watchID string, tasks []*ReviewMRTask, deleter TaskDeleter) { + for _, t := range tasks { + if t.TaskID == "" { + continue + } + if err := deleter.DeleteTask(ctx, t.TaskID); err != nil { + s.logger.Warn("failed to delete review task during watch cleanup", + zap.String("watch_id", watchID), + zap.String("task_id", t.TaskID), + zap.Error(err)) + } + } +} + +// CheckReviewWatch polls a single review watch and returns newly observed MRs +// not yet tracked. Dedup happens against gitlab_review_mr_tasks. +// +// different domain type (MR vs Issue); extracting a generic helper would +// require type parameters across the dedup-check + store-poll API which +// gives up more clarity than it saves. +// +//nolint:dupl // structurally similar to CheckIssueWatch but operates on a +func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]*MR, error) { + if watch == nil { + return nil, fmt.Errorf("watch is nil") + } + if !watch.Enabled { + return nil, nil + } + client := s.Client() + if client == nil { + return nil, ErrNoClient + } + store := s.requireStore() + if store == nil { + return nil, errStoreUnavailable + } + mrs, err := s.fetchReviewMRs(ctx, watch) + if err != nil { + return nil, err + } + out := make([]*MR, 0, len(mrs)) + for _, mr := range mrs { + exists, err := store.HasReviewMRTask(ctx, watch.ID, mr.ProjectPath, mr.IID) + if err != nil { + s.logger.Error("check review MR dedup", zap.Error(err)) + continue + } + if !exists { + out = append(out, mr) + } + } + now := time.Now().UTC() + if err := store.RecordReviewWatchPoll(ctx, watch.ID, now); err != nil { + s.logger.Warn("record review watch poll", zap.String("watch_id", watch.ID), zap.Error(err)) + } + return out, nil +} + +func (s *Service) fetchReviewMRs(ctx context.Context, watch *ReviewWatch) ([]*MR, error) { + client := s.Client() + username, err := client.GetAuthenticatedUser(ctx) + if err != nil { + return nil, fmt.Errorf("resolve gitlab username: %w", err) + } + if username == "" { + return nil, fmt.Errorf("no authenticated gitlab user") + } + // SearchMRs's buildMRSearchQuery returns customQuery verbatim when + // non-empty (ignoring filter), so only build the default filter when + // the watch has no customQuery to pass through. + filter := "" + if watch.CustomQuery == "" { + filter = "reviewer_username=" + url.QueryEscape(username) + } + mrs, err := client.SearchMRs(ctx, filter, watch.CustomQuery) + if err != nil { + return nil, fmt.Errorf("search MRs: %w", err) + } + if len(watch.Projects) == 0 { + return mrs, nil + } + allowed := make(map[string]bool, len(watch.Projects)) + for _, p := range watch.Projects { + allowed[strings.ToLower(strings.TrimSpace(p.Path))] = true + } + out := mrs[:0] + for _, mr := range mrs { + if allowed[strings.ToLower(mr.ProjectPath)] { + out = append(out, mr) + } + } + return out, nil +} + +// TriggerReviewWatch runs the watch once on demand. Returns matching MRs. +func (s *Service) TriggerReviewWatch(ctx context.Context, id string) ([]*MR, error) { + rw, err := s.GetReviewWatch(ctx, id) + if err != nil { + return nil, err + } + if rw == nil { + return nil, fmt.Errorf("%w: review watch %s", ErrWatchNotFound, id) + } + mrs, err := s.CheckReviewWatch(ctx, rw) + if err != nil { + return nil, err + } + for _, mr := range mrs { + s.publishNewReviewMREvent(ctx, rw, mr) + } + return mrs, nil +} + +// TriggerReviewWatchAll runs every enabled watch and aggregates new MRs. +func (s *Service) TriggerReviewWatchAll(ctx context.Context) (int, error) { + store := s.requireStore() + if store == nil { + return 0, errStoreUnavailable + } + watches, err := store.ListEnabledReviewWatches(ctx) + if err != nil { + return 0, err + } + total := 0 + for _, rw := range watches { + found, err := s.CheckReviewWatch(ctx, rw) + if err != nil { + s.logger.Warn("trigger review watch all", zap.String("watch_id", rw.ID), zap.Error(err)) + continue + } + for _, mr := range found { + s.publishNewReviewMREvent(ctx, rw, mr) + } + total += len(found) + } + return total, nil +} + +// --- Helpers --- + +func (s *Service) requireStore() *Store { + s.mu.RLock() + store := s.store + s.mu.RUnlock() + return store +} + +// appendLabelsToQuery merges a label list into an existing customQuery string. +// If the query already has a `labels` key the caller's value is kept (we +// don't want to silently double-up). url.ParseQuery is used for an exact +// key match — strings.Contains("labels=") would false-positive on keys +// like `mylabels=` and silently drop the watch's labels. +func appendLabelsToQuery(customQuery string, labels []string) string { + if parsed, err := url.ParseQuery(customQuery); err == nil && parsed.Has("labels") { + return customQuery + } + encoded := url.QueryEscape(strings.Join(labels, ",")) + if customQuery == "" { + return "labels=" + encoded + } + return customQuery + "&labels=" + encoded +} + +func normalizeProjectFilters(in []ProjectFilter) []ProjectFilter { + if in == nil { + return []ProjectFilter{} + } + out := make([]ProjectFilter, 0, len(in)) + for _, p := range in { + p.Path = strings.TrimSpace(p.Path) + if p.Path == "" { + continue + } + out = append(out, p) + } + return out +} diff --git a/apps/backend/internal/gitlab/service_watches_test.go b/apps/backend/internal/gitlab/service_watches_test.go new file mode 100644 index 0000000000..174ba34e81 --- /dev/null +++ b/apps/backend/internal/gitlab/service_watches_test.go @@ -0,0 +1,85 @@ +package gitlab + +import "testing" + +func TestAppendLabelsToQuery_EmptyQuery(t *testing.T) { + got := appendLabelsToQuery("", []string{"bug", "p1"}) + want := "labels=bug%2Cp1" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestAppendLabelsToQuery_ExistingQuery(t *testing.T) { + got := appendLabelsToQuery("state=opened&scope=all", []string{"bug"}) + want := "state=opened&scope=all&labels=bug" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestAppendLabelsToQuery_PreservesExistingLabelsClause(t *testing.T) { + // If the user already specified labels in their customQuery we leave it + // alone — silently double-appending would change query semantics. + got := appendLabelsToQuery("labels=critical", []string{"bug"}) + want := "labels=critical" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestAppendLabelsToQuery_DoesNotMatchSimilarKeys(t *testing.T) { + // A key like `mylabels` must not false-trigger the "already has labels" + // guard; the watch's labels should still be appended. + got := appendLabelsToQuery("mylabels=foo&state=opened", []string{"bug"}) + want := "mylabels=foo&state=opened&labels=bug" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestIsValidCleanupPolicy(t *testing.T) { + cases := map[string]bool{ + "": true, + "auto": true, + "always": true, + "never": true, + "unknown": false, + "AUTO": false, + } + for in, want := range cases { + if got := IsValidCleanupPolicy(in); got != want { + t.Errorf("IsValidCleanupPolicy(%q) = %v, want %v", in, got, want) + } + } +} + +func TestNormalizeCleanupPolicy(t *testing.T) { + if got := NormalizeCleanupPolicy(""); got != CleanupPolicyAuto { + t.Errorf("empty should normalize to auto, got %q", got) + } + if got := NormalizeCleanupPolicy("never"); got != "never" { + t.Errorf("never should pass through, got %q", got) + } +} + +func TestNormalizeProjectFilters(t *testing.T) { + in := []ProjectFilter{ + {Path: " group/project "}, + {Path: ""}, + {Path: "group/other"}, + } + got := normalizeProjectFilters(in) + if len(got) != 2 { + t.Fatalf("expected 2 entries, got %d", len(got)) + } + if got[0].Path != "group/project" || got[1].Path != "group/other" { + t.Fatalf("trim/empty-drop broken: %+v", got) + } +} + +func TestNormalizeProjectFilters_NilInput(t *testing.T) { + if got := normalizeProjectFilters(nil); got == nil || len(got) != 0 { + t.Fatalf("nil input should return empty slice, got %+v", got) + } +} diff --git a/apps/backend/internal/gitlab/store.go b/apps/backend/internal/gitlab/store.go index e6b6a40a1b..38aad2bd94 100644 --- a/apps/backend/internal/gitlab/store.go +++ b/apps/backend/internal/gitlab/store.go @@ -64,6 +64,93 @@ const createTablesSQL = ` UNIQUE(task_id, repository_id, project_path, mr_iid) ); CREATE INDEX IF NOT EXISTS idx_gitlab_task_mrs_task_id ON gitlab_task_mrs(task_id); + + CREATE TABLE IF NOT EXISTS gitlab_mr_watches ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', + project_path TEXT NOT NULL, + mr_iid INTEGER NOT NULL, + branch TEXT NOT NULL, + last_checked_at DATETIME, + last_note_at DATETIME, + last_pipeline_state TEXT DEFAULT '', + last_approval_state TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE(session_id, repository_id) + ); + CREATE INDEX IF NOT EXISTS idx_gitlab_mr_watches_task_id ON gitlab_mr_watches(task_id); + + CREATE TABLE IF NOT EXISTS gitlab_review_watches ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, + workflow_step_id TEXT NOT NULL, + projects TEXT NOT NULL DEFAULT '[]', + agent_profile_id TEXT NOT NULL, + executor_profile_id TEXT NOT NULL, + prompt TEXT DEFAULT '', + review_scope TEXT NOT NULL DEFAULT 'user_and_teams', + custom_query TEXT NOT NULL DEFAULT '', + enabled BOOLEAN DEFAULT 1, + poll_interval_seconds INTEGER DEFAULT 300, + cleanup_policy TEXT NOT NULL DEFAULT 'auto', + last_polled_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_gitlab_review_watches_workspace_id ON gitlab_review_watches(workspace_id); + + CREATE TABLE IF NOT EXISTS gitlab_review_mr_tasks ( + id TEXT PRIMARY KEY, + review_watch_id TEXT NOT NULL, + project_path TEXT NOT NULL DEFAULT '', + mr_iid INTEGER NOT NULL, + mr_url TEXT NOT NULL, + task_id TEXT NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE(review_watch_id, project_path, mr_iid) + ); + + CREATE TABLE IF NOT EXISTS gitlab_issue_watches ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, + workflow_step_id TEXT NOT NULL, + projects TEXT NOT NULL DEFAULT '[]', + agent_profile_id TEXT NOT NULL, + executor_profile_id TEXT NOT NULL, + prompt TEXT DEFAULT '', + labels TEXT NOT NULL DEFAULT '[]', + custom_query TEXT NOT NULL DEFAULT '', + enabled BOOLEAN DEFAULT 1, + poll_interval_seconds INTEGER DEFAULT 300, + cleanup_policy TEXT NOT NULL DEFAULT 'auto', + last_polled_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_gitlab_issue_watches_workspace_id ON gitlab_issue_watches(workspace_id); + + CREATE TABLE IF NOT EXISTS gitlab_issue_watch_tasks ( + id TEXT PRIMARY KEY, + issue_watch_id TEXT NOT NULL, + project_path TEXT NOT NULL DEFAULT '', + issue_iid INTEGER NOT NULL, + issue_url TEXT NOT NULL, + task_id TEXT NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE(issue_watch_id, project_path, issue_iid) + ); + + CREATE TABLE IF NOT EXISTS gitlab_action_presets ( + workspace_id TEXT PRIMARY KEY, + mr_presets TEXT NOT NULL DEFAULT '[]', + issue_presets TEXT NOT NULL DEFAULT '[]', + updated_at DATETIME NOT NULL + ); ` func (s *Store) createTables() error { diff --git a/apps/backend/internal/gitlab/store_watches.go b/apps/backend/internal/gitlab/store_watches.go new file mode 100644 index 0000000000..49cad9f1fe --- /dev/null +++ b/apps/backend/internal/gitlab/store_watches.go @@ -0,0 +1,739 @@ +package gitlab + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// --- MR Watch CRUD --- + +// CreateMRWatch inserts a new MR watch row. +func (s *Store) CreateMRWatch(ctx context.Context, w *MRWatch) error { + if w.ID == "" { + w.ID = uuid.New().String() + } + now := time.Now().UTC() + if w.CreatedAt.IsZero() { + w.CreatedAt = now + } + w.UpdatedAt = now + _, err := s.db.NamedExecContext(ctx, ` + INSERT INTO gitlab_mr_watches ( + id, session_id, task_id, repository_id, project_path, mr_iid, branch, + last_checked_at, last_note_at, last_pipeline_state, last_approval_state, + created_at, updated_at + ) VALUES ( + :id, :session_id, :task_id, :repository_id, :project_path, :mr_iid, :branch, + :last_checked_at, :last_note_at, :last_pipeline_state, :last_approval_state, + :created_at, :updated_at + )`, w) + return err +} + +const mrWatchSelectCols = `id, session_id, task_id, repository_id, project_path, + mr_iid, branch, last_checked_at, last_note_at, last_pipeline_state, + last_approval_state, created_at, updated_at` + +// GetMRWatchBySession returns the MR watch for a session (legacy single-repo). +func (s *Store) GetMRWatchBySession(ctx context.Context, sessionID string) (*MRWatch, error) { + return s.GetMRWatchBySessionAndRepo(ctx, sessionID, "") +} + +// GetMRWatchBySessionAndRepo returns the MR watch keyed by (session, repository). +func (s *Store) GetMRWatchBySessionAndRepo(ctx context.Context, sessionID, repositoryID string) (*MRWatch, error) { + var w MRWatch + err := s.ro.GetContext(ctx, &w, + `SELECT `+mrWatchSelectCols+` FROM gitlab_mr_watches + WHERE session_id = ? AND repository_id = ? LIMIT 1`, sessionID, repositoryID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &w, nil +} + +// ListMRWatchesBySession returns every MR watch for a session. +func (s *Store) ListMRWatchesBySession(ctx context.Context, sessionID string) ([]*MRWatch, error) { + var ws []MRWatch + if err := s.ro.SelectContext(ctx, &ws, + `SELECT `+mrWatchSelectCols+` FROM gitlab_mr_watches + WHERE session_id = ? ORDER BY created_at ASC`, sessionID); err != nil { + return nil, err + } + out := make([]*MRWatch, 0, len(ws)) + for i := range ws { + out = append(out, &ws[i]) + } + return out, nil +} + +// ListMRWatchesByTask returns every MR watch for a task. +func (s *Store) ListMRWatchesByTask(ctx context.Context, taskID string) ([]*MRWatch, error) { + var ws []MRWatch + if err := s.ro.SelectContext(ctx, &ws, + `SELECT `+mrWatchSelectCols+` FROM gitlab_mr_watches + WHERE task_id = ? ORDER BY created_at ASC`, taskID); err != nil { + return nil, err + } + out := make([]*MRWatch, 0, len(ws)) + for i := range ws { + out = append(out, &ws[i]) + } + return out, nil +} + +// ListActiveMRWatches returns every persisted MR watch (used by the poller). +func (s *Store) ListActiveMRWatches(ctx context.Context) ([]*MRWatch, error) { + var ws []MRWatch + if err := s.ro.SelectContext(ctx, &ws, + `SELECT `+mrWatchSelectCols+` FROM gitlab_mr_watches ORDER BY created_at ASC`); err != nil { + return nil, err + } + out := make([]*MRWatch, 0, len(ws)) + for i := range ws { + out = append(out, &ws[i]) + } + return out, nil +} + +// UpdateMRWatchTimestamps records the last poll cycle's observation. +func (s *Store) UpdateMRWatchTimestamps(ctx context.Context, id string, checkedAt time.Time, noteAt *time.Time, pipelineState, approvalState string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE gitlab_mr_watches SET + last_checked_at = ?, last_note_at = ?, + last_pipeline_state = ?, last_approval_state = ?, updated_at = ? + WHERE id = ?`, checkedAt, noteAt, pipelineState, approvalState, time.Now().UTC(), id) + return err +} + +// UpdateMRWatchMRIID stamps the watch with the MR iid once detected. +func (s *Store) UpdateMRWatchMRIID(ctx context.Context, id string, iid int) error { + _, err := s.db.ExecContext(ctx, + `UPDATE gitlab_mr_watches SET mr_iid = ?, updated_at = ? WHERE id = ?`, + iid, time.Now().UTC(), id) + return err +} + +// DeleteMRWatch removes a single MR watch by id. +func (s *Store) DeleteMRWatch(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_mr_watches WHERE id = ?`, id) + return err +} + +// DeleteMRWatchesByTaskID removes all MR watches associated with a task. +func (s *Store) DeleteMRWatchesByTaskID(ctx context.Context, taskID string) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM gitlab_mr_watches WHERE task_id = ?`, taskID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// --- Review Watch CRUD --- + +const reviewWatchSelectCols = `id, workspace_id, workflow_id, workflow_step_id, + projects, agent_profile_id, executor_profile_id, prompt, review_scope, + custom_query, enabled, poll_interval_seconds, cleanup_policy, + last_polled_at, created_at, updated_at` + +// CreateReviewWatch inserts a review watch and serializes Projects to JSON. +func (s *Store) CreateReviewWatch(ctx context.Context, rw *ReviewWatch) error { + if rw.ID == "" { + rw.ID = uuid.New().String() + } + now := time.Now().UTC() + if rw.CreatedAt.IsZero() { + rw.CreatedAt = now + } + rw.UpdatedAt = now + projectsJSON, err := encodeProjects(rw.Projects) + if err != nil { + return err + } + rw.ProjectsJSON = projectsJSON + if rw.PollIntervalSeconds <= 0 { + rw.PollIntervalSeconds = 300 + } + if rw.ReviewScope == "" { + rw.ReviewScope = ReviewScopeUserAndTeams + } + rw.CleanupPolicy = NormalizeCleanupPolicy(rw.CleanupPolicy) + _, err = s.db.NamedExecContext(ctx, ` + INSERT INTO gitlab_review_watches ( + id, workspace_id, workflow_id, workflow_step_id, projects, + agent_profile_id, executor_profile_id, prompt, review_scope, custom_query, + enabled, poll_interval_seconds, cleanup_policy, last_polled_at, + created_at, updated_at + ) VALUES ( + :id, :workspace_id, :workflow_id, :workflow_step_id, :projects, + :agent_profile_id, :executor_profile_id, :prompt, :review_scope, :custom_query, + :enabled, :poll_interval_seconds, :cleanup_policy, :last_polled_at, + :created_at, :updated_at + )`, rw) + return err +} + +// GetReviewWatch returns the review watch row by id. +func (s *Store) GetReviewWatch(ctx context.Context, id string) (*ReviewWatch, error) { + var rw ReviewWatch + err := s.ro.GetContext(ctx, &rw, + `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if err := decodeProjectsInto(&rw); err != nil { + return nil, err + } + return &rw, nil +} + +// ListReviewWatches lists review watches in a workspace. +func (s *Store) ListReviewWatches(ctx context.Context, workspaceID string) ([]*ReviewWatch, error) { + return s.listReviewWatches(ctx, + `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches + WHERE workspace_id = ? ORDER BY created_at ASC`, workspaceID) +} + +// ListAllReviewWatches lists every review watch (used by the poller). +func (s *Store) ListAllReviewWatches(ctx context.Context) ([]*ReviewWatch, error) { + return s.listReviewWatches(ctx, + `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches ORDER BY created_at ASC`) +} + +// ListEnabledReviewWatches lists every enabled review watch. +func (s *Store) ListEnabledReviewWatches(ctx context.Context) ([]*ReviewWatch, error) { + return s.listReviewWatches(ctx, + `SELECT `+reviewWatchSelectCols+` FROM gitlab_review_watches + WHERE enabled = 1 ORDER BY created_at ASC`) +} + +func (s *Store) listReviewWatches(ctx context.Context, query string, args ...interface{}) ([]*ReviewWatch, error) { + var rows []ReviewWatch + if err := s.ro.SelectContext(ctx, &rows, query, args...); err != nil { + return nil, err + } + out := make([]*ReviewWatch, 0, len(rows)) + for i := range rows { + if err := decodeProjectsInto(&rows[i]); err != nil { + return nil, err + } + out = append(out, &rows[i]) + } + return out, nil +} + +// UpdateReviewWatch persists changes to a review watch (full replacement). +func (s *Store) UpdateReviewWatch(ctx context.Context, rw *ReviewWatch) error { + projectsJSON, err := encodeProjects(rw.Projects) + if err != nil { + return err + } + rw.ProjectsJSON = projectsJSON + rw.UpdatedAt = time.Now().UTC() + rw.CleanupPolicy = NormalizeCleanupPolicy(rw.CleanupPolicy) + res, err := s.db.NamedExecContext(ctx, ` + UPDATE gitlab_review_watches SET + workflow_id = :workflow_id, workflow_step_id = :workflow_step_id, + projects = :projects, agent_profile_id = :agent_profile_id, + executor_profile_id = :executor_profile_id, prompt = :prompt, + review_scope = :review_scope, custom_query = :custom_query, + enabled = :enabled, poll_interval_seconds = :poll_interval_seconds, + cleanup_policy = :cleanup_policy, last_polled_at = :last_polled_at, + updated_at = :updated_at + WHERE id = :id`, rw) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("review watch %s not found", rw.ID) + } + return nil +} + +// RecordReviewWatchPoll stamps last_polled_at without touching other fields. +func (s *Store) RecordReviewWatchPoll(ctx context.Context, id string, polledAt time.Time) error { + _, err := s.db.ExecContext(ctx, + `UPDATE gitlab_review_watches SET last_polled_at = ?, updated_at = ? WHERE id = ?`, + polledAt, time.Now().UTC(), id) + return err +} + +// DeleteReviewWatch removes a review watch by id. +func (s *Store) DeleteReviewWatch(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_review_watches WHERE id = ?`, id) + return err +} + +// --- Review MR Task dedup --- + +const reviewMRTaskSelectCols = `id, review_watch_id, project_path, mr_iid, + mr_url, task_id, created_at` + +// HasReviewMRTask reports whether the given (watch, project, iid) is already +// dedup-claimed. +func (s *Store) HasReviewMRTask(ctx context.Context, reviewWatchID, projectPath string, iid int) (bool, error) { + var count int + err := s.ro.GetContext(ctx, &count, + `SELECT COUNT(*) FROM gitlab_review_mr_tasks + WHERE review_watch_id = ? AND project_path = ? AND mr_iid = ?`, + reviewWatchID, projectPath, iid) + if err != nil { + return false, err + } + return count > 0, nil +} + +// ReserveReviewMRTask inserts a placeholder row (task_id="") for an MR; returns +// true if reservation succeeded (this caller wins the race), false if another +// caller already reserved. +func (s *Store) ReserveReviewMRTask(ctx context.Context, reviewWatchID, projectPath string, iid int, mrURL string) (bool, error) { + id := uuid.New().String() + now := time.Now().UTC() + _, err := s.db.ExecContext(ctx, ` + INSERT INTO gitlab_review_mr_tasks (id, review_watch_id, project_path, mr_iid, mr_url, task_id, created_at) + VALUES (?, ?, ?, ?, ?, '', ?) + ON CONFLICT(review_watch_id, project_path, mr_iid) DO NOTHING`, + id, reviewWatchID, projectPath, iid, mrURL, now) + if err != nil { + return false, err + } + // Check if our row landed. + var foundID string + err = s.ro.GetContext(ctx, &foundID, + `SELECT id FROM gitlab_review_mr_tasks + WHERE review_watch_id = ? AND project_path = ? AND mr_iid = ?`, + reviewWatchID, projectPath, iid) + if err != nil { + return false, err + } + return foundID == id, nil +} + +// AssignReviewMRTaskID swaps the reserved placeholder task_id for the real id. +func (s *Store) AssignReviewMRTaskID(ctx context.Context, reviewWatchID, projectPath string, iid int, taskID string) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE gitlab_review_mr_tasks SET task_id = ? + WHERE review_watch_id = ? AND project_path = ? AND mr_iid = ?`, + taskID, reviewWatchID, projectPath, iid) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("review MR task not found: watch=%s project=%s iid=%d", reviewWatchID, projectPath, iid) + } + return nil +} + +// ReleaseReviewMRTask removes the (failed) reservation so future polls can +// retry on the same MR. +func (s *Store) ReleaseReviewMRTask(ctx context.Context, reviewWatchID, projectPath string, iid int) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM gitlab_review_mr_tasks + WHERE review_watch_id = ? AND project_path = ? AND mr_iid = ?`, + reviewWatchID, projectPath, iid) + return err +} + +// ListReviewMRTasksByWatch returns dedup rows for a given watch. +func (s *Store) ListReviewMRTasksByWatch(ctx context.Context, watchID string) ([]*ReviewMRTask, error) { + var rows []ReviewMRTask + if err := s.ro.SelectContext(ctx, &rows, + `SELECT `+reviewMRTaskSelectCols+` FROM gitlab_review_mr_tasks + WHERE review_watch_id = ? ORDER BY created_at ASC`, watchID); err != nil { + return nil, err + } + out := make([]*ReviewMRTask, 0, len(rows)) + for i := range rows { + out = append(out, &rows[i]) + } + return out, nil +} + +// ListAllReviewMRTasks returns every dedup row (used by cleanup sweepers). +func (s *Store) ListAllReviewMRTasks(ctx context.Context) ([]*ReviewMRTask, error) { + var rows []ReviewMRTask + if err := s.ro.SelectContext(ctx, &rows, + `SELECT `+reviewMRTaskSelectCols+` FROM gitlab_review_mr_tasks ORDER BY created_at ASC`); err != nil { + return nil, err + } + out := make([]*ReviewMRTask, 0, len(rows)) + for i := range rows { + out = append(out, &rows[i]) + } + return out, nil +} + +// DeleteReviewMRTask removes a dedup row by id. +func (s *Store) DeleteReviewMRTask(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_review_mr_tasks WHERE id = ?`, id) + return err +} + +// --- Issue Watch CRUD --- + +const issueWatchSelectCols = `id, workspace_id, workflow_id, workflow_step_id, + projects, agent_profile_id, executor_profile_id, prompt, labels, custom_query, + enabled, poll_interval_seconds, cleanup_policy, last_polled_at, created_at, updated_at` + +// CreateIssueWatch inserts an issue watch and serializes Projects + Labels. +func (s *Store) CreateIssueWatch(ctx context.Context, iw *IssueWatch) error { + if iw.ID == "" { + iw.ID = uuid.New().String() + } + now := time.Now().UTC() + if iw.CreatedAt.IsZero() { + iw.CreatedAt = now + } + iw.UpdatedAt = now + projectsJSON, err := encodeProjects(iw.Projects) + if err != nil { + return err + } + iw.ProjectsJSON = projectsJSON + labelsJSON, err := encodeStringList(iw.Labels) + if err != nil { + return err + } + iw.LabelsJSON = labelsJSON + if iw.PollIntervalSeconds <= 0 { + iw.PollIntervalSeconds = 300 + } + iw.CleanupPolicy = NormalizeCleanupPolicy(iw.CleanupPolicy) + _, err = s.db.NamedExecContext(ctx, ` + INSERT INTO gitlab_issue_watches ( + id, workspace_id, workflow_id, workflow_step_id, projects, + agent_profile_id, executor_profile_id, prompt, labels, custom_query, + enabled, poll_interval_seconds, cleanup_policy, last_polled_at, + created_at, updated_at + ) VALUES ( + :id, :workspace_id, :workflow_id, :workflow_step_id, :projects, + :agent_profile_id, :executor_profile_id, :prompt, :labels, :custom_query, + :enabled, :poll_interval_seconds, :cleanup_policy, :last_polled_at, + :created_at, :updated_at + )`, iw) + return err +} + +// GetIssueWatch returns an issue watch by id. +func (s *Store) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { + var iw IssueWatch + err := s.ro.GetContext(ctx, &iw, + `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if err := decodeIssueWatch(&iw); err != nil { + return nil, err + } + return &iw, nil +} + +// ListIssueWatches lists issue watches in a workspace. +func (s *Store) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { + return s.listIssueWatches(ctx, + `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches + WHERE workspace_id = ? ORDER BY created_at ASC`, workspaceID) +} + +// ListAllIssueWatches lists every issue watch. +func (s *Store) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + return s.listIssueWatches(ctx, + `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches ORDER BY created_at ASC`) +} + +// ListEnabledIssueWatches lists every enabled issue watch. +func (s *Store) ListEnabledIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + return s.listIssueWatches(ctx, + `SELECT `+issueWatchSelectCols+` FROM gitlab_issue_watches + WHERE enabled = 1 ORDER BY created_at ASC`) +} + +func (s *Store) listIssueWatches(ctx context.Context, query string, args ...interface{}) ([]*IssueWatch, error) { + var rows []IssueWatch + if err := s.ro.SelectContext(ctx, &rows, query, args...); err != nil { + return nil, err + } + out := make([]*IssueWatch, 0, len(rows)) + for i := range rows { + if err := decodeIssueWatch(&rows[i]); err != nil { + return nil, err + } + out = append(out, &rows[i]) + } + return out, nil +} + +// UpdateIssueWatch persists changes to an issue watch. +func (s *Store) UpdateIssueWatch(ctx context.Context, iw *IssueWatch) error { + projectsJSON, err := encodeProjects(iw.Projects) + if err != nil { + return err + } + iw.ProjectsJSON = projectsJSON + labelsJSON, err := encodeStringList(iw.Labels) + if err != nil { + return err + } + iw.LabelsJSON = labelsJSON + iw.UpdatedAt = time.Now().UTC() + iw.CleanupPolicy = NormalizeCleanupPolicy(iw.CleanupPolicy) + res, err := s.db.NamedExecContext(ctx, ` + UPDATE gitlab_issue_watches SET + workflow_id = :workflow_id, workflow_step_id = :workflow_step_id, + projects = :projects, agent_profile_id = :agent_profile_id, + executor_profile_id = :executor_profile_id, prompt = :prompt, + labels = :labels, custom_query = :custom_query, + enabled = :enabled, poll_interval_seconds = :poll_interval_seconds, + cleanup_policy = :cleanup_policy, last_polled_at = :last_polled_at, + updated_at = :updated_at + WHERE id = :id`, iw) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("issue watch %s not found", iw.ID) + } + return nil +} + +// RecordIssueWatchPoll stamps last_polled_at on the watch. +func (s *Store) RecordIssueWatchPoll(ctx context.Context, id string, polledAt time.Time) error { + _, err := s.db.ExecContext(ctx, + `UPDATE gitlab_issue_watches SET last_polled_at = ?, updated_at = ? WHERE id = ?`, + polledAt, time.Now().UTC(), id) + return err +} + +// DeleteIssueWatch removes an issue watch. +func (s *Store) DeleteIssueWatch(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_issue_watches WHERE id = ?`, id) + return err +} + +// --- Issue Watch Task dedup --- + +const issueWatchTaskSelectCols = `id, issue_watch_id, project_path, issue_iid, + issue_url, task_id, created_at` + +// HasIssueWatchTask reports whether the (watch, project, iid) is dedup-claimed. +func (s *Store) HasIssueWatchTask(ctx context.Context, issueWatchID, projectPath string, iid int) (bool, error) { + var count int + err := s.ro.GetContext(ctx, &count, + `SELECT COUNT(*) FROM gitlab_issue_watch_tasks + WHERE issue_watch_id = ? AND project_path = ? AND issue_iid = ?`, + issueWatchID, projectPath, iid) + if err != nil { + return false, err + } + return count > 0, nil +} + +// ReserveIssueWatchTask inserts a placeholder row claiming dedup. +func (s *Store) ReserveIssueWatchTask(ctx context.Context, issueWatchID, projectPath string, iid int, issueURL string) (bool, error) { + id := uuid.New().String() + now := time.Now().UTC() + _, err := s.db.ExecContext(ctx, ` + INSERT INTO gitlab_issue_watch_tasks (id, issue_watch_id, project_path, issue_iid, issue_url, task_id, created_at) + VALUES (?, ?, ?, ?, ?, '', ?) + ON CONFLICT(issue_watch_id, project_path, issue_iid) DO NOTHING`, + id, issueWatchID, projectPath, iid, issueURL, now) + if err != nil { + return false, err + } + var foundID string + err = s.ro.GetContext(ctx, &foundID, + `SELECT id FROM gitlab_issue_watch_tasks + WHERE issue_watch_id = ? AND project_path = ? AND issue_iid = ?`, + issueWatchID, projectPath, iid) + if err != nil { + return false, err + } + return foundID == id, nil +} + +// AssignIssueWatchTaskID swaps the placeholder for the real task_id. +func (s *Store) AssignIssueWatchTaskID(ctx context.Context, issueWatchID, projectPath string, iid int, taskID string) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE gitlab_issue_watch_tasks SET task_id = ? + WHERE issue_watch_id = ? AND project_path = ? AND issue_iid = ?`, + taskID, issueWatchID, projectPath, iid) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("issue watch task not found: watch=%s project=%s iid=%d", issueWatchID, projectPath, iid) + } + return nil +} + +// ReleaseIssueWatchTask removes a failed reservation. +func (s *Store) ReleaseIssueWatchTask(ctx context.Context, issueWatchID, projectPath string, iid int) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM gitlab_issue_watch_tasks + WHERE issue_watch_id = ? AND project_path = ? AND issue_iid = ?`, + issueWatchID, projectPath, iid) + return err +} + +// ListIssueWatchTasksByWatch lists dedup rows for an issue watch. +func (s *Store) ListIssueWatchTasksByWatch(ctx context.Context, watchID string) ([]*IssueWatchTask, error) { + var rows []IssueWatchTask + if err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchTaskSelectCols+` FROM gitlab_issue_watch_tasks + WHERE issue_watch_id = ? ORDER BY created_at ASC`, watchID); err != nil { + return nil, err + } + out := make([]*IssueWatchTask, 0, len(rows)) + for i := range rows { + out = append(out, &rows[i]) + } + return out, nil +} + +// ListAllIssueWatchTasks returns every dedup row. +func (s *Store) ListAllIssueWatchTasks(ctx context.Context) ([]*IssueWatchTask, error) { + var rows []IssueWatchTask + if err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchTaskSelectCols+` FROM gitlab_issue_watch_tasks ORDER BY created_at ASC`); err != nil { + return nil, err + } + out := make([]*IssueWatchTask, 0, len(rows)) + for i := range rows { + out = append(out, &rows[i]) + } + return out, nil +} + +// DeleteIssueWatchTask removes a dedup row by id. +func (s *Store) DeleteIssueWatchTask(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM gitlab_issue_watch_tasks WHERE id = ?`, id) + return err +} + +// --- Action Presets --- + +// GetActionPresets returns the workspace's stored presets, or empty struct +// (caller substitutes defaults) when none are stored. +func (s *Store) GetActionPresets(ctx context.Context, workspaceID string) (*ActionPresets, error) { + var row struct { + WorkspaceID string `db:"workspace_id"` + MRPresets string `db:"mr_presets"` + IssuePresets string `db:"issue_presets"` + UpdatedAt time.Time `db:"updated_at"` + } + err := s.ro.GetContext(ctx, &row, + `SELECT workspace_id, mr_presets, issue_presets, updated_at + FROM gitlab_action_presets WHERE workspace_id = ?`, workspaceID) + if errors.Is(err, sql.ErrNoRows) { + return &ActionPresets{WorkspaceID: workspaceID}, nil + } + if err != nil { + return nil, err + } + out := &ActionPresets{WorkspaceID: row.WorkspaceID} + if err := json.Unmarshal([]byte(row.MRPresets), &out.MR); err != nil { + return nil, fmt.Errorf("decode mr_presets: %w", err) + } + if err := json.Unmarshal([]byte(row.IssuePresets), &out.Issue); err != nil { + return nil, fmt.Errorf("decode issue_presets: %w", err) + } + return out, nil +} + +// UpsertActionPresets writes (or replaces) a workspace's preset row. +func (s *Store) UpsertActionPresets(ctx context.Context, presets *ActionPresets) error { + mrJSON, err := json.Marshal(presets.MR) + if err != nil { + return fmt.Errorf("encode mr_presets: %w", err) + } + issueJSON, err := json.Marshal(presets.Issue) + if err != nil { + return fmt.Errorf("encode issue_presets: %w", err) + } + now := time.Now().UTC() + _, err = s.db.ExecContext(ctx, ` + INSERT INTO gitlab_action_presets (workspace_id, mr_presets, issue_presets, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + mr_presets = excluded.mr_presets, + issue_presets = excluded.issue_presets, + updated_at = excluded.updated_at`, + presets.WorkspaceID, string(mrJSON), string(issueJSON), now) + return err +} + +// DeleteActionPresets resets a workspace to the default presets. +func (s *Store) DeleteActionPresets(ctx context.Context, workspaceID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM gitlab_action_presets WHERE workspace_id = ?`, workspaceID) + return err +} + +// --- JSON helpers --- + +func encodeProjects(projects []ProjectFilter) (string, error) { + if len(projects) == 0 { + return "[]", nil + } + b, err := json.Marshal(projects) + if err != nil { + return "", fmt.Errorf("encode projects: %w", err) + } + return string(b), nil +} + +func decodeProjectsInto(rw *ReviewWatch) error { + if strings.TrimSpace(rw.ProjectsJSON) == "" { + rw.Projects = nil + return nil + } + if err := json.Unmarshal([]byte(rw.ProjectsJSON), &rw.Projects); err != nil { + return fmt.Errorf("decode review watch projects: %w", err) + } + return nil +} + +func decodeIssueWatch(iw *IssueWatch) error { + if strings.TrimSpace(iw.ProjectsJSON) != "" { + if err := json.Unmarshal([]byte(iw.ProjectsJSON), &iw.Projects); err != nil { + return fmt.Errorf("decode issue watch projects: %w", err) + } + } + if strings.TrimSpace(iw.LabelsJSON) != "" { + if err := json.Unmarshal([]byte(iw.LabelsJSON), &iw.Labels); err != nil { + return fmt.Errorf("decode issue watch labels: %w", err) + } + } + return nil +} + +func encodeStringList(items []string) (string, error) { + if len(items) == 0 { + return "[]", nil + } + b, err := json.Marshal(items) + if err != nil { + return "", fmt.Errorf("encode string list: %w", err) + } + return string(b), nil +} diff --git a/apps/backend/internal/gitlab/store_watches_test.go b/apps/backend/internal/gitlab/store_watches_test.go new file mode 100644 index 0000000000..6fad387011 --- /dev/null +++ b/apps/backend/internal/gitlab/store_watches_test.go @@ -0,0 +1,152 @@ +package gitlab + +import ( + "context" + "testing" +) + +func TestStore_CreateMRWatch_RoundTrip(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + w := &MRWatch{ + SessionID: "sess-1", + TaskID: "task-1", + RepositoryID: "", + ProjectPath: "group/project", + MRIID: 42, + Branch: "feature/x", + } + if err := store.CreateMRWatch(ctx, w); err != nil { + t.Fatalf("CreateMRWatch: %v", err) + } + if w.ID == "" { + t.Fatalf("CreateMRWatch did not assign an ID") + } + got, err := store.GetMRWatchBySession(ctx, "sess-1") + if err != nil { + t.Fatalf("GetMRWatchBySession: %v", err) + } + if got == nil || got.ID != w.ID || got.ProjectPath != "group/project" || got.MRIID != 42 { + t.Fatalf("unexpected watch: %+v", got) + } +} + +func TestStore_ReviewWatch_CRUD(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + rw := &ReviewWatch{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "wfs-1", + Projects: []ProjectFilter{{Path: "group/project"}}, + AgentProfileID: "ag-1", + ExecutorProfileID: "ex-1", + Prompt: "Review", + ReviewScope: ReviewScopeUserAndTeams, + Enabled: true, + CleanupPolicy: CleanupPolicyAuto, + } + if err := store.CreateReviewWatch(ctx, rw); err != nil { + t.Fatalf("CreateReviewWatch: %v", err) + } + got, err := store.GetReviewWatch(ctx, rw.ID) + if err != nil { + t.Fatalf("GetReviewWatch: %v", err) + } + if got == nil || got.WorkspaceID != "ws-1" || len(got.Projects) != 1 || got.Projects[0].Path != "group/project" { + t.Fatalf("unexpected review watch: %+v", got) + } + listed, err := store.ListReviewWatches(ctx, "ws-1") + if err != nil { + t.Fatalf("ListReviewWatches: %v", err) + } + if len(listed) != 1 || listed[0].ID != rw.ID { + t.Fatalf("expected 1 watch in workspace, got %d", len(listed)) + } + rw.Prompt = "Review carefully" + rw.CleanupPolicy = CleanupPolicyAlways + if err := store.UpdateReviewWatch(ctx, rw); err != nil { + t.Fatalf("UpdateReviewWatch: %v", err) + } + got2, _ := store.GetReviewWatch(ctx, rw.ID) + if got2 == nil || got2.Prompt != "Review carefully" || got2.CleanupPolicy != CleanupPolicyAlways { + t.Fatalf("update did not stick: %+v", got2) + } + if err := store.DeleteReviewWatch(ctx, rw.ID); err != nil { + t.Fatalf("DeleteReviewWatch: %v", err) + } + got3, _ := store.GetReviewWatch(ctx, rw.ID) + if got3 != nil { + t.Fatalf("expected nil after delete, got %+v", got3) + } +} + +func TestStore_ReviewMRTask_ReserveAndAssign(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + ok, err := store.ReserveReviewMRTask(ctx, "watch-1", "group/project", 42, "https://x") + if err != nil || !ok { + t.Fatalf("first reserve: ok=%v err=%v", ok, err) + } + ok2, _ := store.ReserveReviewMRTask(ctx, "watch-1", "group/project", 42, "https://x") + if ok2 { + t.Fatalf("second reserve should fail, got ok=true") + } + if err := store.AssignReviewMRTaskID(ctx, "watch-1", "group/project", 42, "task-99"); err != nil { + t.Fatalf("AssignReviewMRTaskID: %v", err) + } + has, err := store.HasReviewMRTask(ctx, "watch-1", "group/project", 42) + if err != nil || !has { + t.Fatalf("HasReviewMRTask: has=%v err=%v", has, err) + } +} + +func TestStore_IssueWatch_CRUD(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + iw := &IssueWatch{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "wfs-1", + Projects: []ProjectFilter{{Path: "group/project"}}, + AgentProfileID: "ag-1", + ExecutorProfileID: "ex-1", + Prompt: "Fix", + Labels: []string{"bug", "p1"}, + Enabled: true, + CleanupPolicy: CleanupPolicyAuto, + } + if err := store.CreateIssueWatch(ctx, iw); err != nil { + t.Fatalf("CreateIssueWatch: %v", err) + } + got, err := store.GetIssueWatch(ctx, iw.ID) + if err != nil || got == nil { + t.Fatalf("GetIssueWatch: got=%v err=%v", got, err) + } + if len(got.Labels) != 2 || got.Labels[0] != "bug" { + t.Fatalf("labels round-trip broken: %+v", got.Labels) + } + if len(got.Projects) != 1 || got.Projects[0].Path != "group/project" { + t.Fatalf("projects round-trip broken: %+v", got.Projects) + } +} + +func TestStore_ActionPresets_RoundTrip(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + presets := &ActionPresets{ + WorkspaceID: "ws-1", + MR: DefaultMRActionPresets(), + Issue: DefaultIssueActionPresets(), + } + if err := store.UpsertActionPresets(ctx, presets); err != nil { + t.Fatalf("UpsertActionPresets: %v", err) + } + got, err := store.GetActionPresets(ctx, "ws-1") + if err != nil { + t.Fatalf("GetActionPresets: %v", err) + } + if len(got.MR) != len(presets.MR) || got.MR[0].ID != presets.MR[0].ID { + t.Fatalf("MR presets round-trip broken: %+v", got.MR) + } +} diff --git a/apps/backend/internal/gitlab/watch_models.go b/apps/backend/internal/gitlab/watch_models.go new file mode 100644 index 0000000000..00d7b35fd0 --- /dev/null +++ b/apps/backend/internal/gitlab/watch_models.go @@ -0,0 +1,346 @@ +package gitlab + +import "time" + +// ProjectFilter identifies a GitLab project for review/issue watch filtering. +// Path is the namespace/path slug, e.g. "group/sub/project". +type ProjectFilter struct { + Path string `json:"path"` +} + +// ReviewScope controls which reviewer_username scope is used for the review +// watch. GitLab does not have team-level review requests, so the scope set is +// smaller than GitHub's, but we keep the constant set parallel for symmetry. +const ( + // ReviewScopeUser matches MRs where the user is explicitly the reviewer. + ReviewScopeUser = "user" + // ReviewScopeUserAndTeams is treated identically to ReviewScopeUser on + // GitLab (no team review requests) — kept for cross-integration symmetry. + ReviewScopeUserAndTeams = "user_and_teams" +) + +// CleanupPolicy controls how a review or issue watch handles its auto-created +// tasks once the underlying MR / issue reaches a terminal state. +const ( + // CleanupPolicyAuto deletes the task once the MR/issue is merged or closed + // UNLESS the user authored at least one message in the task (the agent's + // auto-start prompt does not count). + CleanupPolicyAuto = "auto" + // CleanupPolicyAlways deletes the task on terminal state regardless of + // user interaction. + CleanupPolicyAlways = "always" + // CleanupPolicyNever disables automatic cleanup. + CleanupPolicyNever = "never" +) + +// IsValidCleanupPolicy reports whether s is one of the recognized policies. +// Empty string is treated as valid so legacy rows default to "auto". +func IsValidCleanupPolicy(s string) bool { + switch s { + case "", CleanupPolicyAuto, CleanupPolicyAlways, CleanupPolicyNever: + return true + } + return false +} + +// NormalizeCleanupPolicy maps the empty string to CleanupPolicyAuto. +func NormalizeCleanupPolicy(s string) string { + if s == "" { + return CleanupPolicyAuto + } + return s +} + +// MRWatch tracks active MR monitoring (session → MR). RepositoryID identifies +// which task repository the watched MR belongs to (multi-repo support; empty +// for single-repo tasks). +type MRWatch struct { + ID string `json:"id" db:"id"` + SessionID string `json:"session_id" db:"session_id"` + TaskID string `json:"task_id" db:"task_id"` + RepositoryID string `json:"repository_id,omitempty" db:"repository_id"` + ProjectPath string `json:"project_path" db:"project_path"` + MRIID int `json:"mr_iid" db:"mr_iid"` + Branch string `json:"branch" db:"branch"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty" db:"last_checked_at"` + LastNoteAt *time.Time `json:"last_note_at,omitempty" db:"last_note_at"` + LastPipelineState string `json:"last_pipeline_state" db:"last_pipeline_state"` + LastApprovalState string `json:"last_approval_state" db:"last_approval_state"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// ReviewWatch configures periodic polling for MRs needing the user's review. +// Projects holds the list of projects to monitor. An empty list means all +// projects the user has access to. +type ReviewWatch struct { + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + WorkflowID string `json:"workflow_id" db:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"` + Projects []ProjectFilter `json:"projects" db:"-"` + ProjectsJSON string `json:"-" db:"projects"` + AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"` + Prompt string `json:"prompt" db:"prompt"` + ReviewScope string `json:"review_scope" db:"review_scope"` + CustomQuery string `json:"custom_query" db:"custom_query"` + Enabled bool `json:"enabled" db:"enabled"` + PollIntervalSeconds int `json:"poll_interval_seconds" db:"poll_interval_seconds"` + CleanupPolicy string `json:"cleanup_policy" db:"cleanup_policy"` + LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// ReviewMRTask records which MRs have already had tasks created (dedup). +type ReviewMRTask struct { + ID string `json:"id" db:"id"` + ReviewWatchID string `json:"review_watch_id" db:"review_watch_id"` + ProjectPath string `json:"project_path" db:"project_path"` + MRIID int `json:"mr_iid" db:"mr_iid"` + MRURL string `json:"mr_url" db:"mr_url"` + TaskID string `json:"task_id" db:"task_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// IssueWatch configures periodic polling for GitLab issues matching a query. +type IssueWatch struct { + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + WorkflowID string `json:"workflow_id" db:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"` + Projects []ProjectFilter `json:"projects" db:"-"` + ProjectsJSON string `json:"-" db:"projects"` + AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"` + Prompt string `json:"prompt" db:"prompt"` + Labels []string `json:"labels" db:"-"` + LabelsJSON string `json:"-" db:"labels"` + CustomQuery string `json:"custom_query" db:"custom_query"` + Enabled bool `json:"enabled" db:"enabled"` + PollIntervalSeconds int `json:"poll_interval_seconds" db:"poll_interval_seconds"` + CleanupPolicy string `json:"cleanup_policy" db:"cleanup_policy"` + LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// IssueWatchTask records which issues have had tasks created (dedup). +type IssueWatchTask struct { + ID string `json:"id" db:"id"` + IssueWatchID string `json:"issue_watch_id" db:"issue_watch_id"` + ProjectPath string `json:"project_path" db:"project_path"` + IssueIID int `json:"issue_iid" db:"issue_iid"` + IssueURL string `json:"issue_url" db:"issue_url"` + TaskID string `json:"task_id" db:"task_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// CreateReviewWatchRequest is the request body for creating a review watch. +type CreateReviewWatchRequest struct { + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Projects []ProjectFilter `json:"projects"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` + Prompt string `json:"prompt"` + ReviewScope string `json:"review_scope"` + CustomQuery string `json:"custom_query"` + PollIntervalSeconds int `json:"poll_interval_seconds"` + CleanupPolicy string `json:"cleanup_policy"` +} + +// UpdateReviewWatchRequest is the request body for updating a review watch. +type UpdateReviewWatchRequest struct { + WorkflowID *string `json:"workflow_id,omitempty"` + WorkflowStepID *string `json:"workflow_step_id,omitempty"` + Projects *[]ProjectFilter `json:"projects,omitempty"` + AgentProfileID *string `json:"agent_profile_id,omitempty"` + ExecutorProfileID *string `json:"executor_profile_id,omitempty"` + Prompt *string `json:"prompt,omitempty"` + ReviewScope *string `json:"review_scope,omitempty"` + CustomQuery *string `json:"custom_query,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"` + CleanupPolicy *string `json:"cleanup_policy,omitempty"` +} + +// CreateIssueWatchRequest is the request body for creating an issue watch. +type CreateIssueWatchRequest struct { + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Projects []ProjectFilter `json:"projects"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` + Prompt string `json:"prompt"` + Labels []string `json:"labels"` + CustomQuery string `json:"custom_query"` + PollIntervalSeconds int `json:"poll_interval_seconds"` + CleanupPolicy string `json:"cleanup_policy"` +} + +// UpdateIssueWatchRequest is the request body for updating an issue watch. +type UpdateIssueWatchRequest struct { + WorkflowID *string `json:"workflow_id,omitempty"` + WorkflowStepID *string `json:"workflow_step_id,omitempty"` + Projects *[]ProjectFilter `json:"projects,omitempty"` + AgentProfileID *string `json:"agent_profile_id,omitempty"` + ExecutorProfileID *string `json:"executor_profile_id,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Labels *[]string `json:"labels,omitempty"` + CustomQuery *string `json:"custom_query,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"` + CleanupPolicy *string `json:"cleanup_policy,omitempty"` +} + +// MRFeedbackEvent is published when an MR has new feedback (new discussion, +// pipeline state change, approval state change). Used by the frontend to +// surface a toast / refresh the task. +type MRFeedbackEvent struct { + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + ProjectPath string `json:"project_path"` + MRIID int `json:"mr_iid"` + NewPipelineState string `json:"new_pipeline_state"` + NewApprovalState string `json:"new_approval_state"` +} + +// NewReviewMREvent is published when a new MR needing review is found by a +// review watch and a task has been auto-created. +type NewReviewMREvent struct { + ReviewWatchID string `json:"review_watch_id"` + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` + Prompt string `json:"prompt"` + MR *MR `json:"mr"` +} + +// NewIssueEvent is published when a new issue matching a watch is found. +type NewIssueEvent struct { + IssueWatchID string `json:"issue_watch_id"` + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` + Prompt string `json:"prompt"` + Issue *Issue `json:"issue"` +} + +// --- Stats --- + +// Stats aggregates GitLab activity counts surfaced on the /gitlab page. +type Stats struct { + OpenMRs int `json:"open_mrs"` + MRsAwaitingMyReview int `json:"mrs_awaiting_my_review"` + OpenIssuesAssignedMe int `json:"open_issues_assigned_me"` +} + +// --- Merge methods --- + +// ProjectMergeMethods reports which merge methods a GitLab project allows. +// GitLab projects have a single `merge_method` setting; we expose it as a +// boolean set parallel to GitHub's allow_*_merge for the frontend. +type ProjectMergeMethods struct { + Merge bool `json:"merge"` // GitLab `merge_method=merge` + RebaseMerge bool `json:"rebase_merge"` // GitLab `merge_method=rebase_merge` + FastForward bool `json:"fast_forward"` // GitLab `merge_method=ff` + AllowSquash bool `json:"allow_squash"` // squash option on the merge request +} + +// --- Action presets --- + +// ActionPresetKind enumerates the two lists of quick-launch presets. +const ( + ActionPresetKindMR = "mr" + ActionPresetKindIssue = "issue" +) + +// ActionPreset is a single configurable quick-task launcher entry. +// PromptTemplate supports `{{url}}` and `{{title}}` placeholders substituted +// client-side when the dialog is opened. +type ActionPreset struct { + ID string `json:"id"` + Label string `json:"label"` + Hint string `json:"hint"` + Icon string `json:"icon"` + PromptTemplate string `json:"prompt_template"` +} + +// ActionPresets groups the MR and Issue preset lists for a workspace. +type ActionPresets struct { + WorkspaceID string `json:"workspace_id"` + MR []ActionPreset `json:"mr"` + Issue []ActionPreset `json:"issue"` +} + +// UpdateActionPresetsRequest replaces one or both preset lists for a workspace. +// Nil fields leave the corresponding list unchanged. +type UpdateActionPresetsRequest struct { + WorkspaceID string `json:"workspace_id"` + MR *[]ActionPreset `json:"mr,omitempty"` + Issue *[]ActionPreset `json:"issue,omitempty"` +} + +// DefaultMRActionPresets returns the built-in MR presets used when a workspace +// has no stored overrides. +func DefaultMRActionPresets() []ActionPreset { + return []ActionPreset{ + { + ID: "review", + Label: "Review", + Hint: "Read the diff, flag issues", + Icon: "eye", + PromptTemplate: "Review the merge request at {{url}}. Provide feedback on code quality, correctness, and suggest improvements.", + }, + { + ID: "address_feedback", + Label: "Address feedback", + Hint: "Apply review comments", + Icon: "message", + PromptTemplate: "Review the feedback on the merge request at {{url}}. Evaluate each comment critically — apply changes that improve the code, push back on suggestions that are unnecessary or harmful, and explain your reasoning. Push the changes when done.", + }, + { + ID: "fix_ci", + Label: "Fix pipeline", + Hint: "Diagnose failing jobs", + Icon: "tool", + PromptTemplate: "Investigate and fix the failing pipeline jobs and merge conflicts on the merge request at {{url}}. Run the failing jobs locally, resolve any conflicts, diagnose issues, and push fixes.", + }, + } +} + +// DefaultIssueActionPresets returns the built-in Issue presets used when a +// workspace has no stored overrides. +func DefaultIssueActionPresets() []ActionPreset { + return []ActionPreset{ + { + ID: "implement", + Label: "Implement", + Hint: "Build and open an MR", + Icon: "code", + PromptTemplate: `Implement the changes described in the GitLab issue at {{url}} (title: "{{title}}"). Open a merge request when complete.`, + }, + { + ID: "investigate", + Label: "Investigate", + Hint: "Find the root cause", + Icon: "search", + PromptTemplate: `Investigate the GitLab issue at {{url}} (title: "{{title}}"). Identify root cause and summarize findings.`, + }, + { + ID: "reproduce", + Label: "Reproduce", + Hint: "Document repro steps", + Icon: "bug", + PromptTemplate: `Reproduce the bug described in the GitLab issue at {{url}} (title: "{{title}}"). Document the reproduction steps.`, + }, + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_gitlab.go b/apps/backend/internal/orchestrator/event_handlers_gitlab.go new file mode 100644 index 0000000000..dc96bc7d21 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_gitlab.go @@ -0,0 +1,166 @@ +package orchestrator + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/gitlab" +) + +// GitLabIssueTaskCreator creates a task from a GitLab issue + watch context. +// Mirrors github.IssueTaskCreator. Returns the created task id or an error. +type GitLabIssueTaskCreator interface { + CreateGitLabIssueTask(ctx context.Context, evt *gitlab.NewIssueEvent) (string, error) +} + +// GitLabReviewTaskCreator creates a task from a GitLab MR + watch context. +type GitLabReviewTaskCreator interface { + CreateGitLabReviewTask(ctx context.Context, evt *gitlab.NewReviewMREvent) (string, error) +} + +// SetGitLabIssueTaskCreator wires the issue→task creator used by the +// `gitlab.new_issue` handler. When nil, watches still fire but no tasks +// are auto-created (events are logged for observability). +func (s *Service) SetGitLabIssueTaskCreator(c GitLabIssueTaskCreator) { + s.gitlabIssueTaskCreator = c +} + +// SetGitLabReviewTaskCreator wires the review→task creator. +func (s *Service) SetGitLabReviewTaskCreator(c GitLabReviewTaskCreator) { + s.gitlabReviewTaskCreator = c +} + +// SetGitLabService is the entry point for wiring the GitLab service into the +// orchestrator so the event handlers can reserve dedup rows. Mirrors +// SetGitHubService / SetJiraService. +func (s *Service) SetGitLabService(svc *gitlab.Service) { + s.gitlabService = svc +} + +// subscribeGitLabEvents wires bus subscriptions for the GitLab integration +// events. Idempotent — safe to call once per orchestrator boot. +func (s *Service) subscribeGitLabEvents() { + if s.eventBus == nil { + return + } + if _, err := s.eventBus.Subscribe(events.GitLabNewReviewMR, s.handleGitLabNewReviewMR); err != nil { + s.logger.Error("subscribe gitlab.new_mr_to_review", zap.Error(err)) + } + if _, err := s.eventBus.Subscribe(events.GitLabNewIssue, s.handleGitLabNewIssue); err != nil { + s.logger.Error("subscribe gitlab.new_issue", zap.Error(err)) + } +} + +// handleGitLabNewReviewMR turns a new-MR-to-review event into a Kandev task. +// When no review-task creator is configured the event is logged and dropped +// (matches the GitHub flow when a workspace has no task creator wired). +func (s *Service) handleGitLabNewReviewMR(ctx context.Context, event *bus.Event) error { + evt, ok := event.Data.(*gitlab.NewReviewMREvent) + if !ok || evt == nil || evt.MR == nil { + return nil + } + s.logger.Info("new gitlab MR detected from review watch", + zap.String("review_watch_id", evt.ReviewWatchID), + zap.String("project", evt.MR.ProjectPath), + zap.Int("iid", evt.MR.IID)) + if s.gitlabReviewTaskCreator == nil { + s.logger.Debug("gitlab review task creator not configured; skipping task creation") + return nil + } + go s.createGitLabReviewTask(context.Background(), evt) + return nil +} + +// a different domain type (MR vs Issue); merging them would obscure the +// per-event publishing contract. +// +//nolint:dupl // structurally similar to createGitLabIssueTask but operates on +func (s *Service) createGitLabReviewTask(ctx context.Context, evt *gitlab.NewReviewMREvent) { + if s.gitlabService == nil { + s.logger.Warn("gitlab service not configured; cannot reserve dedup") + return + } + mr := evt.MR + claimed, err := s.gitlabService.ReserveReviewMRTask(ctx, evt.ReviewWatchID, mr.ProjectPath, mr.IID, mr.WebURL) + if err != nil || !claimed { + if err != nil { + s.logger.Warn("reserve gitlab review MR task", zap.Error(err)) + } + return + } + taskID, err := s.gitlabReviewTaskCreator.CreateGitLabReviewTask(ctx, evt) + if err != nil { + s.logger.Warn("create gitlab review task", zap.Error(err)) + if relErr := s.gitlabService.ReleaseReviewMRTask(ctx, evt.ReviewWatchID, mr.ProjectPath, mr.IID); relErr != nil { + s.logger.Warn("release gitlab review MR dedup row after task-create failure", + zap.String("review_watch_id", evt.ReviewWatchID), + zap.String("project", mr.ProjectPath), + zap.Int("iid", mr.IID), + zap.Error(relErr)) + } + return + } + if err := s.gitlabService.AssignReviewMRTaskID(ctx, evt.ReviewWatchID, mr.ProjectPath, mr.IID, taskID); err != nil { + s.logger.Warn("assign gitlab review MR task id", zap.Error(err)) + } + s.logger.Info("gitlab review MR task created", + zap.String("task_id", taskID), + zap.String("project", mr.ProjectPath), + zap.Int("iid", mr.IID)) +} + +// handleGitLabNewIssue mirrors handleGitLabNewReviewMR for issue events. +func (s *Service) handleGitLabNewIssue(ctx context.Context, event *bus.Event) error { + evt, ok := event.Data.(*gitlab.NewIssueEvent) + if !ok || evt == nil || evt.Issue == nil { + return nil + } + s.logger.Info("new gitlab issue detected from issue watch", + zap.String("issue_watch_id", evt.IssueWatchID), + zap.String("project", evt.Issue.ProjectPath), + zap.Int("iid", evt.Issue.IID)) + if s.gitlabIssueTaskCreator == nil { + s.logger.Debug("gitlab issue task creator not configured; skipping task creation") + return nil + } + go s.createGitLabIssueTask(context.Background(), evt) + return nil +} + +//nolint:dupl // see createGitLabReviewTask — same shape, different domain types. +func (s *Service) createGitLabIssueTask(ctx context.Context, evt *gitlab.NewIssueEvent) { + if s.gitlabService == nil { + s.logger.Warn("gitlab service not configured; cannot reserve dedup") + return + } + issue := evt.Issue + claimed, err := s.gitlabService.ReserveIssueWatchTask(ctx, evt.IssueWatchID, issue.ProjectPath, issue.IID, issue.WebURL) + if err != nil || !claimed { + if err != nil { + s.logger.Warn("reserve gitlab issue task", zap.Error(err)) + } + return + } + taskID, err := s.gitlabIssueTaskCreator.CreateGitLabIssueTask(ctx, evt) + if err != nil { + s.logger.Warn("create gitlab issue task", zap.Error(err)) + if relErr := s.gitlabService.ReleaseIssueWatchTask(ctx, evt.IssueWatchID, issue.ProjectPath, issue.IID); relErr != nil { + s.logger.Warn("release gitlab issue dedup row after task-create failure", + zap.String("issue_watch_id", evt.IssueWatchID), + zap.String("project", issue.ProjectPath), + zap.Int("iid", issue.IID), + zap.Error(relErr)) + } + return + } + if err := s.gitlabService.AssignIssueWatchTaskID(ctx, evt.IssueWatchID, issue.ProjectPath, issue.IID, taskID); err != nil { + s.logger.Warn("assign gitlab issue task id", zap.Error(err)) + } + s.logger.Info("gitlab issue task created", + zap.String("task_id", taskID), + zap.String("project", issue.ProjectPath), + zap.Int("iid", issue.IID)) +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 0893ea97f8..6cfde550c6 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -23,6 +23,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/gitlab" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/orchestrator/queue" @@ -293,6 +294,14 @@ type Service struct { // SetLinearService so handlers don't allocate per bus event. linearSource *LinearWatcherSource + // GitLab service + task creators for auto-creating tasks from review / + // issue watch events. When the task creators are nil the events are + // logged but no tasks are created — matches the GitHub flow when a + // workspace has no task creator wired. + gitlabService *gitlab.Service + gitlabReviewTaskCreator GitLabReviewTaskCreator + gitlabIssueTaskCreator GitLabIssueTaskCreator + // Repository resolver for cloning + finding/creating repos for review tasks repositoryResolver RepositoryResolver @@ -821,6 +830,9 @@ func (s *Service) Start(ctx context.Context) error { // Subscribe to GitHub integration events s.subscribeGitHubEvents() + // Subscribe to GitLab integration events + s.subscribeGitLabEvents() + // Subscribe to JIRA integration events s.subscribeJiraEvents() diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index eb203af194..370599aca0 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -417,6 +417,61 @@ const ( ActionGitHubCleanupIssueTasks = "github.cleanup.issue_tasks" ) +// GitLab integration actions +const ( + ActionGitLabStatus = "gitlab.status" + ActionGitLabTaskMRsList = "gitlab.task_mrs.list" + ActionGitLabTaskMRGet = "gitlab.task_mr.get" + ActionGitLabMRFeedbackGet = "gitlab.mr_feedback.get" + ActionGitLabReviewWatchesList = "gitlab.review_watches.list" + ActionGitLabReviewWatchCreate = "gitlab.review_watches.create" + ActionGitLabReviewWatchUpdate = "gitlab.review_watches.update" + ActionGitLabReviewWatchDelete = "gitlab.review_watches.delete" + ActionGitLabReviewTrigger = "gitlab.review_watches.trigger" + ActionGitLabReviewTriggerAll = "gitlab.review_watches.trigger_all" + ActionGitLabMRWatchesList = "gitlab.mr_watches.list" + ActionGitLabMRWatchDelete = "gitlab.mr_watches.delete" + ActionGitLabMRFilesGet = "gitlab.mr_files.get" + ActionGitLabMRCommitsGet = "gitlab.mr_commits.get" + ActionGitLabTaskMRUpdated = "gitlab.task_mr.updated" // Notification + ActionGitLabMRFeedbackNotify = "gitlab.mr_feedback.notify" // Notification + ActionGitLabNewReviewMRNotify = "gitlab.new_review_mr.notify" // Notification + ActionGitLabTaskMRSync = "gitlab.task_mr.sync" + ActionGitLabStats = "gitlab.stats" + + ActionGitLabMRMerge = "gitlab.mr.merge" + ActionGitLabMRApprove = "gitlab.mr.approve" + ActionGitLabMRUnapprove = "gitlab.mr.unapprove" + ActionGitLabMRSetLabels = "gitlab.mr.set_labels" + ActionGitLabMRSetAssignees = "gitlab.mr.set_assignees" + ActionGitLabMRDiscussionNew = "gitlab.mr.discussion.new" + ActionGitLabMRDiscussionResolve = "gitlab.mr.discussion.resolve" + ActionGitLabProjectMergeMethodsGet = "gitlab.project.merge_methods.get" + + // Issue watch actions + ActionGitLabIssueWatchesList = "gitlab.issue_watches.list" + ActionGitLabIssueWatchCreate = "gitlab.issue_watches.create" + ActionGitLabIssueWatchUpdate = "gitlab.issue_watches.update" + ActionGitLabIssueWatchDelete = "gitlab.issue_watches.delete" + ActionGitLabIssueTrigger = "gitlab.issue_watches.trigger" + ActionGitLabIssueTriggerAll = "gitlab.issue_watches.trigger_all" + ActionGitLabNewIssueNotify = "gitlab.new_issue.notify" // Notification + + // Action preset actions for the /gitlab page quick-launch prompts. + ActionGitLabActionPresetsList = "gitlab.action_presets.list" + ActionGitLabActionPresetsUpdate = "gitlab.action_presets.update" + ActionGitLabActionPresetsReset = "gitlab.action_presets.reset" + + // Project discovery / autocomplete. + ActionGitLabListUserProjects = "gitlab.projects.list" + ActionGitLabSearchProjects = "gitlab.projects.search" + ActionGitLabProjectBranches = "gitlab.project.branches" + + // Manual cleanup sweeps. + ActionGitLabCleanupReviewTasks = "gitlab.cleanup.review_tasks" + ActionGitLabCleanupIssueTasks = "gitlab.cleanup.issue_tasks" +) + // Jira integration actions const ( ActionJiraConfigGet = "jira.config.get" diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts b/apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts new file mode 100644 index 0000000000..1354e02171 --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useCallback, useRef } from "react"; +import { + getActionPresets, + updateActionPresets, + resetActionPresets, +} from "@/lib/api/domains/gitlab-api"; +import { useAppStore } from "@/components/state-provider"; +import type { GitLabActionPreset } from "@/lib/types/gitlab"; + +/** + * useGitLabActionPresets fetches the workspace's stored presets (falling back + * to defaults server-side) and exposes update/reset helpers. The per-workspace + * attempted set guards against an infinite re-fetch loop when the API is + * unreachable. + */ +export function useGitLabActionPresets(workspaceId: string | null | undefined) { + const presets = useAppStore((state) => + workspaceId ? state.gitlabActionPresets.byWorkspaceId[workspaceId] : null, + ); + const loading = useAppStore((state) => state.gitlabActionPresets.loading); + const set = useAppStore((state) => state.setGitLabActionPresets); + const setLoading = useAppStore((state) => state.setGitLabActionPresetsLoading); + const attemptedRef = useRef>(new Set()); + + useEffect(() => { + if (!workspaceId || presets || loading) return; + if (attemptedRef.current.has(workspaceId)) return; + attemptedRef.current.add(workspaceId); + setLoading(true); + getActionPresets(workspaceId) + .then((res) => { + if (res) set(workspaceId, res); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [workspaceId, presets, loading, set, setLoading]); + + const update = useCallback( + async (body: { mr?: GitLabActionPreset[]; issue?: GitLabActionPreset[] }) => { + if (!workspaceId) return null; + const result = await updateActionPresets(workspaceId, body); + if (result) set(workspaceId, result); + return result; + }, + [workspaceId, set], + ); + + const reset = useCallback(async () => { + if (!workspaceId) return null; + const result = await resetActionPresets(workspaceId); + if (result) set(workspaceId, result); + return result; + }, [workspaceId, set]); + + return { presets, loading, update, reset }; +} diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts b/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts new file mode 100644 index 0000000000..d11b246744 --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useCallback, useRef } from "react"; +import { + listIssueWatches, + createIssueWatch, + updateIssueWatch, + deleteIssueWatch, + triggerIssueWatch, + triggerAllIssueWatches, + type CreateIssueWatchRequest, + type UpdateIssueWatchRequest, +} from "@/lib/api/domains/gitlab-api"; +import { useAppStore } from "@/components/state-provider"; + +/** + * useGitLabIssueWatches mirrors useGitLabReviewWatches — the per-instance + * lastFetchedRef triggers a refetch on workspace switch, working around the + * fact that the slice-level `loaded` flag is shared across all consumers. + */ +export function useGitLabIssueWatches(workspaceId?: string | null) { + const items = useAppStore((state) => state.gitlabIssueWatches.items); + const loaded = useAppStore((state) => state.gitlabIssueWatches.loaded); + const loading = useAppStore((state) => state.gitlabIssueWatches.loading); + const set = useAppStore((state) => state.setGitLabIssueWatches); + const setLoading = useAppStore((state) => state.setGitLabIssueWatchesLoading); + const add = useAppStore((state) => state.addGitLabIssueWatch); + const upd = useAppStore((state) => state.updateGitLabIssueWatchInStore); + const rm = useAppStore((state) => state.removeGitLabIssueWatch); + const lastFetchedRef = useRef(undefined); + + useEffect(() => { + if (workspaceId === null || loading) return; + if (loaded && lastFetchedRef.current === workspaceId) return; + lastFetchedRef.current = workspaceId; + setLoading(true); + listIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) + .then((response) => set(response?.watches ?? [])) + .catch(() => set([])) + .finally(() => setLoading(false)); + }, [workspaceId, loaded, loading, set, setLoading]); + + const create = useCallback( + async (req: CreateIssueWatchRequest) => { + const watch = await createIssueWatch(req); + add(watch); + return watch; + }, + [add], + ); + + const update = useCallback( + async (id: string, req: UpdateIssueWatchRequest) => { + const watch = await updateIssueWatch(id, req); + upd(watch); + return watch; + }, + [upd], + ); + + const remove = useCallback( + async (id: string) => { + await deleteIssueWatch(id); + rm(id); + }, + [rm], + ); + + const trigger = useCallback((id: string) => triggerIssueWatch(id), []); + const triggerAll = useCallback(() => triggerAllIssueWatches(), []); + + return { items, loaded, loading, create, update, remove, trigger, triggerAll }; +} diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts b/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts new file mode 100644 index 0000000000..d36b8019a4 --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useCallback, useRef } from "react"; +import { + listReviewWatches, + createReviewWatch, + updateReviewWatch, + deleteReviewWatch, + triggerReviewWatch, + triggerAllReviewWatches, + type CreateReviewWatchRequest, + type UpdateReviewWatchRequest, +} from "@/lib/api/domains/gitlab-api"; +import { useAppStore } from "@/components/state-provider"; + +/** + * useGitLabReviewWatches — three modes: + * - workspaceId: string → fetch watches scoped to one workspace + * - workspaceId: undefined → fetch watches across all workspaces + * - workspaceId: null → don't fetch (caller hasn't resolved a workspace yet) + * + * The internal `loaded` flag lives on the slice and is shared across all + * useGitLabReviewWatches instances, so it can't double as a per-workspace + * cache key. We track the last-fetched workspace key here and re-fetch when + * it changes (workspace switch). + */ +export function useGitLabReviewWatches(workspaceId?: string | null) { + const items = useAppStore((state) => state.gitlabReviewWatches.items); + const loaded = useAppStore((state) => state.gitlabReviewWatches.loaded); + const loading = useAppStore((state) => state.gitlabReviewWatches.loading); + const set = useAppStore((state) => state.setGitLabReviewWatches); + const setLoading = useAppStore((state) => state.setGitLabReviewWatchesLoading); + const add = useAppStore((state) => state.addGitLabReviewWatch); + const upd = useAppStore((state) => state.updateGitLabReviewWatchInStore); + const rm = useAppStore((state) => state.removeGitLabReviewWatch); + const lastFetchedRef = useRef(undefined); + + useEffect(() => { + if (workspaceId === null || loading) return; + if (loaded && lastFetchedRef.current === workspaceId) return; + lastFetchedRef.current = workspaceId; + setLoading(true); + listReviewWatches(workspaceId ?? undefined, { cache: "no-store" }) + .then((response) => set(response?.watches ?? [])) + .catch(() => set([])) + .finally(() => setLoading(false)); + }, [workspaceId, loaded, loading, set, setLoading]); + + const create = useCallback( + async (req: CreateReviewWatchRequest) => { + const watch = await createReviewWatch(req); + add(watch); + return watch; + }, + [add], + ); + + const update = useCallback( + async (id: string, req: UpdateReviewWatchRequest) => { + const watch = await updateReviewWatch(id, req); + upd(watch); + return watch; + }, + [upd], + ); + + const remove = useCallback( + async (id: string) => { + await deleteReviewWatch(id); + rm(id); + }, + [rm], + ); + + const trigger = useCallback((id: string) => triggerReviewWatch(id), []); + const triggerAll = useCallback(() => triggerAllReviewWatches(), []); + + return { items, loaded, loading, create, update, remove, trigger, triggerAll }; +} diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-stats.ts b/apps/web/hooks/domains/gitlab/use-gitlab-stats.ts new file mode 100644 index 0000000000..eb00599a15 --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-gitlab-stats.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { fetchGitLabStats } from "@/lib/api/domains/gitlab-api"; +import { useAppStore } from "@/components/state-provider"; + +/** + * useGitLabStats subscribes to the open-MRs / awaiting-review / open-issues + * counts surfaced on the /gitlab page header. Per-mount attempted flag + * prevents an infinite re-fetch loop when GitLab is unreachable. + */ +export function useGitLabStats() { + const stats = useAppStore((state) => state.gitlabStats.data); + const loading = useAppStore((state) => state.gitlabStats.loading); + const loadedAt = useAppStore((state) => state.gitlabStats.loadedAt); + const setStats = useAppStore((state) => state.setGitLabStats); + const setStatsLoading = useAppStore((state) => state.setGitLabStatsLoading); + const attemptedRef = useRef(false); + + useEffect(() => { + if (loading || loadedAt !== null || attemptedRef.current) return; + attemptedRef.current = true; + setStatsLoading(true); + fetchGitLabStats() + .then((res) => setStats(res ?? null)) + .catch(() => setStats(null)) + .finally(() => setStatsLoading(false)); + }, [loading, loadedAt, setStats, setStatsLoading]); + + return { stats, loading }; +} diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-status.ts b/apps/web/hooks/domains/gitlab/use-gitlab-status.ts new file mode 100644 index 0000000000..fc000b5b64 --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-gitlab-status.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api"; +import { useAppStore } from "@/components/state-provider"; + +/** + * useGitLabStatus subscribes the slice to the latest GitLab connection status. + * Fetches on mount, retries are caller-driven via the returned `refresh`. + * + * Guards against an infinite re-fetch loop when GitLab is unreachable: a + * fetch failure leaves `status` null, so without a per-mount attempted flag + * the effect would re-run every render and hammer the backend. + */ +export function useGitLabStatus() { + const status = useAppStore((state) => state.gitlabStatus.data); + const loading = useAppStore((state) => state.gitlabStatus.loading); + const loadedAt = useAppStore((state) => state.gitlabStatus.loadedAt); + const setStatus = useAppStore((state) => state.setGitLabStatus); + const setStatusLoading = useAppStore((state) => state.setGitLabStatusLoading); + const attemptedRef = useRef(false); + + useEffect(() => { + if (loading || loadedAt !== null || attemptedRef.current) return; + attemptedRef.current = true; + setStatusLoading(true); + fetchGitLabStatus({ cache: "no-store" }) + .then((res) => setStatus(res ?? null)) + .catch(() => setStatus(null)) + .finally(() => setStatusLoading(false)); + }, [loading, loadedAt, setStatus, setStatusLoading]); + + const refresh = async () => { + setStatusLoading(true); + try { + const res = await fetchGitLabStatus({ cache: "no-store" }); + setStatus(res ?? null); + } catch { + setStatus(null); + } finally { + setStatusLoading(false); + } + }; + + return { status, loading, refresh }; +} diff --git a/apps/web/lib/api/domains/gitlab-api.ts b/apps/web/lib/api/domains/gitlab-api.ts index a3414a9eab..ec3102633e 100644 --- a/apps/web/lib/api/domains/gitlab-api.ts +++ b/apps/web/lib/api/domains/gitlab-api.ts @@ -99,3 +99,287 @@ export async function searchUserIssues(params: { } export type { MR, Issue, MRSearchPage, IssueSearchPage }; + +// --------------------------------------------------------------------------- +// Watches / presets / write actions (parity with GitHub) +// --------------------------------------------------------------------------- + +import type { + ReviewWatch, + IssueWatch, + MRWatch, + GitLabStats, + GitLabActionPresets, + GitLabProject, + ProjectMergeMethods, + GitLabMRFeedback, + GitLabMRFile, + GitLabMRCommit, + GitLabRepoBranch, +} from "@/lib/types/gitlab"; + +// --- Watches --- + +export async function listMRWatches( + filters?: { sessionId?: string; taskId?: string }, + options?: ApiRequestOptions, +) { + const qs = new URLSearchParams(); + if (filters?.sessionId) qs.set("session_id", filters.sessionId); + if (filters?.taskId) qs.set("task_id", filters.taskId); + return fetchJson<{ watches: MRWatch[] }>( + `/api/v1/gitlab/watches/mr${qs.size > 0 ? `?${qs.toString()}` : ""}`, + options, + ); +} + +export async function deleteMRWatch(id: string) { + return fetchJson<{ deleted: boolean }>(`/api/v1/gitlab/watches/mr/${encodeURIComponent(id)}`, { + init: { method: "DELETE" }, + }); +} + +export type CreateReviewWatchRequest = Omit< + ReviewWatch, + "id" | "enabled" | "last_polled_at" | "created_at" | "updated_at" +>; +// workspace_id is fixed at creation; the backend update schema ignores it. +// Excluding it here keeps the type honest so callers don't think they can +// move a watch between workspaces by sending a different id. +export type UpdateReviewWatchRequest = Partial> & { + enabled?: boolean; +}; + +export async function listReviewWatches(workspaceId?: string, options?: ApiRequestOptions) { + const qs = new URLSearchParams(); + if (workspaceId) qs.set("workspace_id", workspaceId); + return fetchJson<{ watches: ReviewWatch[] }>( + `/api/v1/gitlab/watches/review${qs.size > 0 ? `?${qs.toString()}` : ""}`, + options, + ); +} + +export async function createReviewWatch(req: CreateReviewWatchRequest) { + return fetchJson(`/api/v1/gitlab/watches/review`, { + init: { method: "POST", body: JSON.stringify(req) }, + }); +} + +export async function updateReviewWatch(id: string, req: UpdateReviewWatchRequest) { + return fetchJson(`/api/v1/gitlab/watches/review/${encodeURIComponent(id)}`, { + init: { method: "PUT", body: JSON.stringify(req) }, + }); +} + +export async function deleteReviewWatch(id: string) { + return fetchJson<{ deleted: boolean }>( + `/api/v1/gitlab/watches/review/${encodeURIComponent(id)}`, + { init: { method: "DELETE" } }, + ); +} + +export async function triggerReviewWatch(id: string) { + return fetchJson<{ mrs: MR[]; count: number }>( + `/api/v1/gitlab/watches/review/${encodeURIComponent(id)}/trigger`, + { init: { method: "POST" } }, + ); +} + +export async function triggerAllReviewWatches() { + return fetchJson<{ count: number }>(`/api/v1/gitlab/watches/review/trigger-all`, { + init: { method: "POST" }, + }); +} + +export type CreateIssueWatchRequest = Omit< + IssueWatch, + "id" | "enabled" | "last_polled_at" | "created_at" | "updated_at" +>; +// See UpdateReviewWatchRequest for the workspace_id rationale. +export type UpdateIssueWatchRequest = Partial> & { + enabled?: boolean; +}; + +export async function listIssueWatches(workspaceId?: string, options?: ApiRequestOptions) { + const qs = new URLSearchParams(); + if (workspaceId) qs.set("workspace_id", workspaceId); + return fetchJson<{ watches: IssueWatch[] }>( + `/api/v1/gitlab/watches/issue${qs.size > 0 ? `?${qs.toString()}` : ""}`, + options, + ); +} + +export async function createIssueWatch(req: CreateIssueWatchRequest) { + return fetchJson(`/api/v1/gitlab/watches/issue`, { + init: { method: "POST", body: JSON.stringify(req) }, + }); +} + +export async function updateIssueWatch(id: string, req: UpdateIssueWatchRequest) { + return fetchJson(`/api/v1/gitlab/watches/issue/${encodeURIComponent(id)}`, { + init: { method: "PUT", body: JSON.stringify(req) }, + }); +} + +export async function deleteIssueWatch(id: string) { + return fetchJson<{ deleted: boolean }>(`/api/v1/gitlab/watches/issue/${encodeURIComponent(id)}`, { + init: { method: "DELETE" }, + }); +} + +export async function triggerIssueWatch(id: string) { + return fetchJson<{ issues: Issue[]; count: number }>( + `/api/v1/gitlab/watches/issue/${encodeURIComponent(id)}/trigger`, + { init: { method: "POST" } }, + ); +} + +export async function triggerAllIssueWatches() { + return fetchJson<{ count: number }>(`/api/v1/gitlab/watches/issue/trigger-all`, { + init: { method: "POST" }, + }); +} + +// --- Cleanup --- + +export async function cleanupReviewTasks() { + return fetchJson<{ deleted: number }>(`/api/v1/gitlab/cleanup/review-tasks`, { + init: { method: "POST" }, + }); +} + +export async function cleanupIssueTasks() { + return fetchJson<{ deleted: number }>(`/api/v1/gitlab/cleanup/issue-tasks`, { + init: { method: "POST" }, + }); +} + +// --- Projects --- + +export async function listUserProjects(options?: ApiRequestOptions) { + return fetchJson<{ projects: GitLabProject[] }>(`/api/v1/gitlab/projects`, options); +} + +export async function searchProjects(query: string) { + const qs = new URLSearchParams(); + qs.set("query", query); + return fetchJson<{ projects: GitLabProject[] }>( + `/api/v1/gitlab/projects/search?${qs.toString()}`, + ); +} + +export async function listProjectBranches(project: string) { + const qs = new URLSearchParams(); + qs.set("project", project); + return fetchJson<{ branches: GitLabRepoBranch[] }>( + `/api/v1/gitlab/projects/branches?${qs.toString()}`, + ); +} + +export async function getProjectMergeMethods(project: string) { + const qs = new URLSearchParams(); + qs.set("project", project); + return fetchJson(`/api/v1/gitlab/projects/merge-methods?${qs.toString()}`); +} + +// --- MR write actions --- + +export async function mergeMR( + project: string, + iid: number, + method?: string, + squashCommitMessage?: string, +) { + return fetchJson(`/api/v1/gitlab/mrs/merge`, { + init: { + method: "PUT", + body: JSON.stringify({ + project, + iid, + method, + squash_commit_message: squashCommitMessage, + }), + }, + }); +} + +export async function approveMR(project: string, iid: number) { + return fetchJson<{ approved: boolean }>(`/api/v1/gitlab/mrs/approve`, { + init: { method: "POST", body: JSON.stringify({ project, iid }) }, + }); +} + +export async function unapproveMR(project: string, iid: number) { + return fetchJson<{ unapproved: boolean }>(`/api/v1/gitlab/mrs/unapprove`, { + init: { method: "POST", body: JSON.stringify({ project, iid }) }, + }); +} + +export async function setMRLabels(project: string, iid: number, labels: string[]) { + return fetchJson<{ updated: boolean }>(`/api/v1/gitlab/mrs/labels`, { + init: { method: "PUT", body: JSON.stringify({ project, iid, labels }) }, + }); +} + +export async function setMRAssignees(project: string, iid: number, assigneeIDs: number[]) { + return fetchJson<{ updated: boolean }>(`/api/v1/gitlab/mrs/assignees`, { + init: { method: "PUT", body: JSON.stringify({ project, iid, assignee_ids: assigneeIDs }) }, + }); +} + +export async function getMRFiles(project: string, iid: number) { + const qs = new URLSearchParams(); + qs.set("project", project); + qs.set("iid", String(iid)); + return fetchJson<{ files: GitLabMRFile[] }>(`/api/v1/gitlab/mrs/files?${qs.toString()}`); +} + +export async function getMRCommits(project: string, iid: number) { + const qs = new URLSearchParams(); + qs.set("project", project); + qs.set("iid", String(iid)); + return fetchJson<{ commits: GitLabMRCommit[] }>(`/api/v1/gitlab/mrs/commits?${qs.toString()}`); +} + +export async function getMRFeedback(project: string, iid: number) { + const qs = new URLSearchParams(); + qs.set("project", project); + qs.set("iid", String(iid)); + return fetchJson(`/api/v1/gitlab/mrs/feedback?${qs.toString()}`, { + cache: "no-store", + }); +} + +// --- Action presets --- + +export async function getActionPresets(workspaceId: string) { + const qs = new URLSearchParams(); + qs.set("workspace_id", workspaceId); + return fetchJson(`/api/v1/gitlab/action-presets?${qs.toString()}`); +} + +export async function updateActionPresets( + workspaceId: string, + body: { mr?: GitLabActionPresets["mr"]; issue?: GitLabActionPresets["issue"] }, +) { + return fetchJson(`/api/v1/gitlab/action-presets`, { + init: { + method: "PUT", + body: JSON.stringify({ workspace_id: workspaceId, ...body }), + }, + }); +} + +export async function resetActionPresets(workspaceId: string) { + const qs = new URLSearchParams(); + qs.set("workspace_id", workspaceId); + return fetchJson(`/api/v1/gitlab/action-presets/reset?${qs.toString()}`, { + init: { method: "POST" }, + }); +} + +// --- Stats --- + +export async function fetchGitLabStats() { + return fetchJson(`/api/v1/gitlab/stats`, { cache: "no-store" }); +} diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index 5a6f303c82..af1c8e8339 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -71,6 +71,12 @@ export const defaultState = { issueWatches: defaultGitHubState.issueWatches, actionPresets: defaultGitHubState.actionPresets, taskMRs: defaultGitLabState.taskMRs, + gitlabReviewWatches: defaultGitLabState.gitlabReviewWatches, + gitlabIssueWatches: defaultGitLabState.gitlabIssueWatches, + gitlabMRWatches: defaultGitLabState.gitlabMRWatches, + gitlabActionPresets: defaultGitLabState.gitlabActionPresets, + gitlabStats: defaultGitLabState.gitlabStats, + gitlabStatus: defaultGitLabState.gitlabStatus, jiraIssueWatches: defaultJiraState.jiraIssueWatches, linearIssueWatches: defaultLinearState.linearIssueWatches, office: defaultOfficeState.office, @@ -98,6 +104,30 @@ export const defaultState = { export type DefaultState = typeof defaultState; +function mergeGitLabFields( + d: DefaultState, + s: Partial, +): Pick< + DefaultState, + | "taskMRs" + | "gitlabReviewWatches" + | "gitlabIssueWatches" + | "gitlabMRWatches" + | "gitlabActionPresets" + | "gitlabStats" + | "gitlabStatus" +> { + return { + taskMRs: { ...d.taskMRs, ...s.taskMRs }, + gitlabReviewWatches: { ...d.gitlabReviewWatches, ...s.gitlabReviewWatches }, + gitlabIssueWatches: { ...d.gitlabIssueWatches, ...s.gitlabIssueWatches }, + gitlabMRWatches: { ...d.gitlabMRWatches, ...s.gitlabMRWatches }, + gitlabActionPresets: { ...d.gitlabActionPresets, ...s.gitlabActionPresets }, + gitlabStats: { ...d.gitlabStats, ...s.gitlabStats }, + gitlabStatus: { ...d.gitlabStatus, ...s.gitlabStatus }, + }; +} + export function mergeInitialState(initialState?: Partial): DefaultState { if (!initialState) return defaultState; @@ -162,7 +192,7 @@ export function mergeInitialState(initialState?: Partial): Default reviewWatches: { ...defaultState.reviewWatches, ...initialState.reviewWatches }, issueWatches: { ...defaultState.issueWatches, ...initialState.issueWatches }, actionPresets: { ...defaultState.actionPresets, ...initialState.actionPresets }, - taskMRs: { ...defaultState.taskMRs, ...initialState.taskMRs }, + ...mergeGitLabFields(defaultState, initialState), jiraIssueWatches: { ...defaultState.jiraIssueWatches, ...initialState.jiraIssueWatches }, linearIssueWatches: { ...defaultState.linearIssueWatches, diff --git a/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts b/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts index e83be2a55d..62028eac4b 100644 --- a/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts +++ b/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts @@ -130,3 +130,91 @@ describe("resetTaskMRs", () => { expect(store.getState().taskMRs.byTaskId).toEqual({}); }); }); + +describe("review watches", () => { + it("set + add + update + remove round-trip", () => { + const store = makeStore(); + const watch = { + id: "w-1", + workspace_id: "ws-1", + workflow_id: "", + workflow_step_id: "", + projects: [{ path: "group/proj" }], + agent_profile_id: "", + executor_profile_id: "", + prompt: "", + review_scope: "user", + custom_query: "", + enabled: true, + poll_interval_seconds: 300, + cleanup_policy: "auto", + created_at: "", + updated_at: "", + }; + store.getState().setGitLabReviewWatches([watch]); + expect(store.getState().gitlabReviewWatches.items).toHaveLength(1); + expect(store.getState().gitlabReviewWatches.loaded).toBe(true); + + const watch2 = { ...watch, id: "w-2" }; + store.getState().addGitLabReviewWatch(watch2); + expect(store.getState().gitlabReviewWatches.items).toHaveLength(2); + + store.getState().updateGitLabReviewWatchInStore({ ...watch, prompt: "updated" }); + expect(store.getState().gitlabReviewWatches.items.find((w) => w.id === "w-1")?.prompt).toBe( + "updated", + ); + + store.getState().removeGitLabReviewWatch("w-1"); + expect(store.getState().gitlabReviewWatches.items).toHaveLength(1); + expect(store.getState().gitlabReviewWatches.items[0].id).toBe("w-2"); + }); +}); + +describe("issue watches", () => { + it("set + add + remove round-trip", () => { + const store = makeStore(); + const watch = { + id: "iw-1", + workspace_id: "ws-1", + workflow_id: "", + workflow_step_id: "", + projects: [], + agent_profile_id: "", + executor_profile_id: "", + prompt: "", + labels: ["bug"], + custom_query: "", + enabled: true, + poll_interval_seconds: 300, + cleanup_policy: "auto", + created_at: "", + updated_at: "", + }; + store.getState().setGitLabIssueWatches([watch]); + expect(store.getState().gitlabIssueWatches.items).toHaveLength(1); + + store.getState().removeGitLabIssueWatch("iw-1"); + expect(store.getState().gitlabIssueWatches.items).toHaveLength(0); + }); +}); + +describe("action presets + stats", () => { + it("upserts presets by workspace and stores stats with loadedAt timestamp", () => { + const store = makeStore(); + const presets = { + workspace_id: "ws-1", + mr: [{ id: "x", label: "x", hint: "", icon: "", prompt_template: "" }], + issue: [], + }; + store.getState().setGitLabActionPresets("ws-1", presets); + expect(store.getState().gitlabActionPresets.byWorkspaceId["ws-1"]).toEqual(presets); + + store.getState().setGitLabStats({ + open_mrs: 5, + mrs_awaiting_my_review: 2, + open_issues_assigned_me: 3, + }); + expect(store.getState().gitlabStats.data?.open_mrs).toBe(5); + expect(store.getState().gitlabStats.loadedAt).not.toBeNull(); + }); +}); diff --git a/apps/web/lib/state/slices/gitlab/gitlab-slice.ts b/apps/web/lib/state/slices/gitlab/gitlab-slice.ts index 7612df21ea..aedd41c1b4 100644 --- a/apps/web/lib/state/slices/gitlab/gitlab-slice.ts +++ b/apps/web/lib/state/slices/gitlab/gitlab-slice.ts @@ -3,42 +3,178 @@ import type { GitLabSlice, GitLabSliceState } from "./types"; export const defaultGitLabState: GitLabSliceState = { taskMRs: { byTaskId: {} }, + gitlabReviewWatches: { items: [], loaded: false, loading: false }, + gitlabIssueWatches: { items: [], loaded: false, loading: false }, + gitlabMRWatches: { items: [], loaded: false, loading: false }, + gitlabActionPresets: { byWorkspaceId: {}, loading: false }, + gitlabStats: { data: null, loading: false, loadedAt: null }, + gitlabStatus: { data: null, loading: false, loadedAt: null }, }; type ImmerSet = Parameters< StateCreator >[0]; -export const createGitLabSlice: StateCreator< - GitLabSlice, - [["zustand/immer", never]], - [], - GitLabSlice -> = (set: ImmerSet) => ({ +type GitLabSliceCreator = StateCreator; + +export const createGitLabSlice: GitLabSliceCreator = (set: ImmerSet) => ({ ...defaultGitLabState, - setTaskMRs: (mrs) => - set((draft) => { - draft.taskMRs.byTaskId = mrs; - }), - setTaskMR: (taskId, mr) => - set((draft) => { - const existing = draft.taskMRs.byTaskId[taskId] ?? []; - // Key by (repository_id, project_path, mr_iid) so multi-repo and - // multiple MRs for the same task coexist. Mirrors the backend's - // UNIQUE constraint on gitlab_task_mrs. - const repoKey = mr.repository_id ?? ""; - const idx = existing.findIndex( - (m) => - (m.repository_id ?? "") === repoKey && - m.project_path === mr.project_path && - m.mr_iid === mr.mr_iid, - ); - if (idx >= 0) existing[idx] = mr; - else existing.push(mr); - draft.taskMRs.byTaskId[taskId] = existing; - }), - resetTaskMRs: () => - set((draft) => { - draft.taskMRs.byTaskId = {}; - }), + ...taskMRActions(set), + ...reviewWatchActions(set), + ...issueWatchActions(set), + ...mrWatchActions(set), + ...presetActions(set), + ...statsActions(set), + ...statusActions(set), }); + +function taskMRActions(set: ImmerSet) { + return { + setTaskMRs: (mrs: Record) => + set((draft) => { + draft.taskMRs.byTaskId = mrs; + }), + setTaskMR: (taskId: string, mr: GitLabSliceState["taskMRs"]["byTaskId"][string][number]) => + set((draft) => { + const existing = draft.taskMRs.byTaskId[taskId] ?? []; + const repoKey = mr.repository_id ?? ""; + const idx = existing.findIndex( + (m) => + (m.repository_id ?? "") === repoKey && + m.project_path === mr.project_path && + m.mr_iid === mr.mr_iid, + ); + if (idx >= 0) existing[idx] = mr; + else existing.push(mr); + draft.taskMRs.byTaskId[taskId] = existing; + }), + resetTaskMRs: () => + set((draft) => { + draft.taskMRs.byTaskId = {}; + }), + }; +} + +function reviewWatchActions(set: ImmerSet) { + return { + setGitLabReviewWatches: (watches: GitLabSliceState["gitlabReviewWatches"]["items"]) => + set((draft) => { + draft.gitlabReviewWatches.items = watches; + draft.gitlabReviewWatches.loaded = true; + }), + setGitLabReviewWatchesLoading: (loading: boolean) => + set((draft) => { + draft.gitlabReviewWatches.loading = loading; + }), + addGitLabReviewWatch: (watch: GitLabSliceState["gitlabReviewWatches"]["items"][number]) => + set((draft) => { + draft.gitlabReviewWatches.items = [...draft.gitlabReviewWatches.items, watch]; + }), + updateGitLabReviewWatchInStore: ( + watch: GitLabSliceState["gitlabReviewWatches"]["items"][number], + ) => + set((draft) => { + draft.gitlabReviewWatches.items = draft.gitlabReviewWatches.items.map((w) => + w.id === watch.id ? watch : w, + ); + }), + removeGitLabReviewWatch: (id: string) => + set((draft) => { + draft.gitlabReviewWatches.items = draft.gitlabReviewWatches.items.filter( + (w) => w.id !== id, + ); + }), + }; +} + +function issueWatchActions(set: ImmerSet) { + return { + setGitLabIssueWatches: (watches: GitLabSliceState["gitlabIssueWatches"]["items"]) => + set((draft) => { + draft.gitlabIssueWatches.items = watches; + draft.gitlabIssueWatches.loaded = true; + }), + setGitLabIssueWatchesLoading: (loading: boolean) => + set((draft) => { + draft.gitlabIssueWatches.loading = loading; + }), + addGitLabIssueWatch: (watch: GitLabSliceState["gitlabIssueWatches"]["items"][number]) => + set((draft) => { + draft.gitlabIssueWatches.items = [...draft.gitlabIssueWatches.items, watch]; + }), + updateGitLabIssueWatchInStore: ( + watch: GitLabSliceState["gitlabIssueWatches"]["items"][number], + ) => + set((draft) => { + draft.gitlabIssueWatches.items = draft.gitlabIssueWatches.items.map((w) => + w.id === watch.id ? watch : w, + ); + }), + removeGitLabIssueWatch: (id: string) => + set((draft) => { + draft.gitlabIssueWatches.items = draft.gitlabIssueWatches.items.filter((w) => w.id !== id); + }), + }; +} + +function mrWatchActions(set: ImmerSet) { + return { + setGitLabMRWatches: (watches: GitLabSliceState["gitlabMRWatches"]["items"]) => + set((draft) => { + draft.gitlabMRWatches.items = watches; + draft.gitlabMRWatches.loaded = true; + }), + setGitLabMRWatchesLoading: (loading: boolean) => + set((draft) => { + draft.gitlabMRWatches.loading = loading; + }), + removeGitLabMRWatch: (id: string) => + set((draft) => { + draft.gitlabMRWatches.items = draft.gitlabMRWatches.items.filter((w) => w.id !== id); + }), + }; +} + +function presetActions(set: ImmerSet) { + return { + setGitLabActionPresets: ( + workspaceId: string, + presets: GitLabSliceState["gitlabActionPresets"]["byWorkspaceId"][string], + ) => + set((draft) => { + draft.gitlabActionPresets.byWorkspaceId[workspaceId] = presets; + }), + setGitLabActionPresetsLoading: (loading: boolean) => + set((draft) => { + draft.gitlabActionPresets.loading = loading; + }), + }; +} + +function statsActions(set: ImmerSet) { + return { + setGitLabStats: (stats: GitLabSliceState["gitlabStats"]["data"]) => + set((draft) => { + draft.gitlabStats.data = stats; + draft.gitlabStats.loadedAt = Date.now(); + }), + setGitLabStatsLoading: (loading: boolean) => + set((draft) => { + draft.gitlabStats.loading = loading; + }), + }; +} + +function statusActions(set: ImmerSet) { + return { + setGitLabStatus: (status: GitLabSliceState["gitlabStatus"]["data"]) => + set((draft) => { + draft.gitlabStatus.data = status; + draft.gitlabStatus.loadedAt = Date.now(); + }), + setGitLabStatusLoading: (loading: boolean) => + set((draft) => { + draft.gitlabStatus.loading = loading; + }), + }; +} diff --git a/apps/web/lib/state/slices/gitlab/types.ts b/apps/web/lib/state/slices/gitlab/types.ts index a7011cf123..ddf0f7c380 100644 --- a/apps/web/lib/state/slices/gitlab/types.ts +++ b/apps/web/lib/state/slices/gitlab/types.ts @@ -1,23 +1,85 @@ -import type { TaskMR } from "@/lib/types/gitlab"; +import type { + TaskMR, + ReviewWatch, + IssueWatch, + MRWatch, + GitLabStats, + GitLabActionPresets, + GitLabStatus, +} from "@/lib/types/gitlab"; export type TaskMRsState = { /** Each task may have multiple MRs (one per repository for multi-repo tasks). */ byTaskId: Record; }; +export type LoadableList = { + items: T[]; + loaded: boolean; + loading: boolean; +}; + +export type GitLabReviewWatchesState = LoadableList; +export type GitLabIssueWatchesState = LoadableList; +export type GitLabMRWatchesState = LoadableList; + +export type GitLabActionPresetsState = { + byWorkspaceId: Record; + loading: boolean; +}; + +export type GitLabStatsState = { + data: GitLabStats | null; + loading: boolean; + loadedAt: number | null; +}; + +export type GitLabStatusState = { + data: GitLabStatus | null; + loading: boolean; + loadedAt: number | null; +}; + export type GitLabSliceState = { taskMRs: TaskMRsState; + gitlabReviewWatches: GitLabReviewWatchesState; + gitlabIssueWatches: GitLabIssueWatchesState; + gitlabMRWatches: GitLabMRWatchesState; + gitlabActionPresets: GitLabActionPresetsState; + gitlabStats: GitLabStatsState; + gitlabStatus: GitLabStatusState; }; export type GitLabSliceActions = { + // TaskMR — keeping legacy names; do not collide with github slice. setTaskMRs: (mrs: Record) => void; - /** - * Upsert a single MR for a task — keyed by (repository_id, project_path, - * mr_iid) so multi-repo + multi-MR scenarios coexist without overwriting. - */ setTaskMR: (taskId: string, mr: TaskMR) => void; - /** Clear all task MR state. Used on workspace switch / sign-out. */ resetTaskMRs: () => void; + + setGitLabReviewWatches: (watches: ReviewWatch[]) => void; + setGitLabReviewWatchesLoading: (loading: boolean) => void; + addGitLabReviewWatch: (watch: ReviewWatch) => void; + updateGitLabReviewWatchInStore: (watch: ReviewWatch) => void; + removeGitLabReviewWatch: (id: string) => void; + + setGitLabIssueWatches: (watches: IssueWatch[]) => void; + setGitLabIssueWatchesLoading: (loading: boolean) => void; + addGitLabIssueWatch: (watch: IssueWatch) => void; + updateGitLabIssueWatchInStore: (watch: IssueWatch) => void; + removeGitLabIssueWatch: (id: string) => void; + + setGitLabMRWatches: (watches: MRWatch[]) => void; + setGitLabMRWatchesLoading: (loading: boolean) => void; + removeGitLabMRWatch: (id: string) => void; + + setGitLabActionPresets: (workspaceId: string, presets: GitLabActionPresets) => void; + setGitLabActionPresetsLoading: (loading: boolean) => void; + + setGitLabStats: (stats: GitLabStats | null) => void; + setGitLabStatsLoading: (loading: boolean) => void; + + setGitLabStatus: (status: GitLabStatus | null) => void; + setGitLabStatusLoading: (loading: boolean) => void; }; export type GitLabSlice = GitLabSliceState & GitLabSliceActions; diff --git a/apps/web/lib/state/store-overrides.ts b/apps/web/lib/state/store-overrides.ts new file mode 100644 index 0000000000..a28279b15b --- /dev/null +++ b/apps/web/lib/state/store-overrides.ts @@ -0,0 +1,86 @@ +import type { DefaultState } from "./default-state"; + +// buildStateOverrides picks the plain-state fields out of `merged` and re-asserts them +// after all slice spreads so that caller-supplied initialState wins over slice defaults. +// Note: collapsedSubtaskParents is intentionally omitted — createUISlice hydrates it +// from sessionStorage and we want that to win. +export function buildStateOverrides(m: DefaultState) { + return { + kanban: m.kanban, + kanbanMulti: m.kanbanMulti, + workflows: m.workflows, + tasks: m.tasks, + workspaces: m.workspaces, + repositories: m.repositories, + repositoryBranches: m.repositoryBranches, + repositoryScripts: m.repositoryScripts, + executors: m.executors, + settingsAgents: m.settingsAgents, + agentDiscovery: m.agentDiscovery, + availableAgents: m.availableAgents, + agentProfiles: m.agentProfiles, + editors: m.editors, + prompts: m.prompts, + secrets: m.secrets, + notificationProviders: m.notificationProviders, + settingsData: m.settingsData, + userSettings: m.userSettings, + messages: m.messages, + turns: m.turns, + taskSessions: m.taskSessions, + taskSessionsByTask: m.taskSessionsByTask, + sessionAgentctl: m.sessionAgentctl, + worktrees: m.worktrees, + sessionWorktreesBySessionId: m.sessionWorktreesBySessionId, + pendingModel: m.pendingModel, + activeModel: m.activeModel, + queue: m.queue, + terminal: m.terminal, + shell: m.shell, + processes: m.processes, + gitStatus: m.gitStatus, + contextWindow: m.contextWindow, + agents: m.agents, + sessionMode: m.sessionMode, + userShells: m.userShells, + prepareProgress: m.prepareProgress, + sessionTodos: m.sessionTodos, + agentCapabilities: m.agentCapabilities, + sessionModels: m.sessionModels, + promptUsage: m.promptUsage, + sessionPollMode: m.sessionPollMode, + githubStatus: m.githubStatus, + taskPRs: m.taskPRs, + pendingPrUrlByTaskId: m.pendingPrUrlByTaskId, + prWatches: m.prWatches, + reviewWatches: m.reviewWatches, + issueWatches: m.issueWatches, + actionPresets: m.actionPresets, + taskMRs: m.taskMRs, + gitlabReviewWatches: m.gitlabReviewWatches, + gitlabIssueWatches: m.gitlabIssueWatches, + gitlabMRWatches: m.gitlabMRWatches, + gitlabActionPresets: m.gitlabActionPresets, + gitlabStats: m.gitlabStats, + gitlabStatus: m.gitlabStatus, + jiraIssueWatches: m.jiraIssueWatches, + linearIssueWatches: m.linearIssueWatches, + office: m.office, + features: m.features, + automations: m.automations, + automationRuns: m.automationRuns, + system: m.system, + previewPanel: m.previewPanel, + rightPanel: m.rightPanel, + diffs: m.diffs, + connection: m.connection, + mobileKanban: m.mobileKanban, + mobileSession: m.mobileSession, + chatInput: m.chatInput, + documentPanel: m.documentPanel, + systemHealth: m.systemHealth, + quickChat: m.quickChat, + sessionFailureNotification: m.sessionFailureNotification, + bottomTerminal: m.bottomTerminal, + }; +} diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 1de136b24f..79b1a69237 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -13,6 +13,7 @@ import type { SystemHealthResponse } from "@/lib/types/health"; import type { UISliceActions as UIA } from "./slices/ui/types"; import type * as UISliceTypes from "./slices/ui/types"; import { mergeInitialState } from "./default-state"; +import { buildStateOverrides } from "./store-overrides"; import { createKanbanSlice, createWorkspaceSlice, @@ -85,6 +86,7 @@ import type { // Re-export all types from slices for backwards compatibility. export type * from "./store-reexports"; import type { TaskMR } from "@/lib/types/gitlab"; +import type { GitLabSliceActions } from "./slices/gitlab/types"; import type { JiraIssueWatch } from "@/lib/types/jira"; import type { LinearIssueWatch } from "@/lib/types/linear"; import type { @@ -186,6 +188,12 @@ export type AppState = { // GitLab slice taskMRs: (typeof defaultGitLabState)["taskMRs"]; + gitlabReviewWatches: (typeof defaultGitLabState)["gitlabReviewWatches"]; + gitlabIssueWatches: (typeof defaultGitLabState)["gitlabIssueWatches"]; + gitlabMRWatches: (typeof defaultGitLabState)["gitlabMRWatches"]; + gitlabActionPresets: (typeof defaultGitLabState)["gitlabActionPresets"]; + gitlabStats: (typeof defaultGitLabState)["gitlabStats"]; + gitlabStatus: (typeof defaultGitLabState)["gitlabStatus"]; // JIRA slice jiraIssueWatches: (typeof defaultJiraState)["jiraIssueWatches"]; @@ -229,6 +237,25 @@ export type AppState = { setTaskMRs: (mrs: Record) => void; setTaskMR: (taskId: string, mr: TaskMR) => void; resetTaskMRs: () => void; + setGitLabReviewWatches: GitLabSliceActions["setGitLabReviewWatches"]; + setGitLabReviewWatchesLoading: GitLabSliceActions["setGitLabReviewWatchesLoading"]; + addGitLabReviewWatch: GitLabSliceActions["addGitLabReviewWatch"]; + updateGitLabReviewWatchInStore: GitLabSliceActions["updateGitLabReviewWatchInStore"]; + removeGitLabReviewWatch: GitLabSliceActions["removeGitLabReviewWatch"]; + setGitLabIssueWatches: GitLabSliceActions["setGitLabIssueWatches"]; + setGitLabIssueWatchesLoading: GitLabSliceActions["setGitLabIssueWatchesLoading"]; + addGitLabIssueWatch: GitLabSliceActions["addGitLabIssueWatch"]; + updateGitLabIssueWatchInStore: GitLabSliceActions["updateGitLabIssueWatchInStore"]; + removeGitLabIssueWatch: GitLabSliceActions["removeGitLabIssueWatch"]; + setGitLabMRWatches: GitLabSliceActions["setGitLabMRWatches"]; + setGitLabMRWatchesLoading: GitLabSliceActions["setGitLabMRWatchesLoading"]; + removeGitLabMRWatch: GitLabSliceActions["removeGitLabMRWatch"]; + setGitLabActionPresets: GitLabSliceActions["setGitLabActionPresets"]; + setGitLabActionPresetsLoading: GitLabSliceActions["setGitLabActionPresetsLoading"]; + setGitLabStats: GitLabSliceActions["setGitLabStats"]; + setGitLabStatsLoading: GitLabSliceActions["setGitLabStatsLoading"]; + setGitLabStatus: GitLabSliceActions["setGitLabStatus"]; + setGitLabStatusLoading: GitLabSliceActions["setGitLabStatusLoading"]; // JIRA actions setJiraIssueWatches: (watches: JiraIssueWatch[]) => void; @@ -537,7 +564,6 @@ export function createAppStore(initialState?: Partial) { return createStore()( immer((set, get, api) => ({ ...merged, - // Compose all slices // eslint-disable-next-line @typescript-eslint/no-explicit-any ...createKanbanSlice(set as any, get as any, api as any), // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -566,80 +592,8 @@ export function createAppStore(initialState?: Partial) { ...createUISlice(set as any, get as any, api as any), // eslint-disable-next-line @typescript-eslint/no-explicit-any ...createAutomationsSlice(set as any, get as any, api as any), - // Override state with merged initial state - kanban: merged.kanban, - kanbanMulti: merged.kanbanMulti, - workflows: merged.workflows, - tasks: merged.tasks, - workspaces: merged.workspaces, - repositories: merged.repositories, - repositoryBranches: merged.repositoryBranches, - repositoryScripts: merged.repositoryScripts, - executors: merged.executors, - settingsAgents: merged.settingsAgents, - agentDiscovery: merged.agentDiscovery, - availableAgents: merged.availableAgents, - agentProfiles: merged.agentProfiles, - editors: merged.editors, - prompts: merged.prompts, - secrets: merged.secrets, - notificationProviders: merged.notificationProviders, - settingsData: merged.settingsData, - userSettings: merged.userSettings, - messages: merged.messages, - turns: merged.turns, - taskSessions: merged.taskSessions, - taskSessionsByTask: merged.taskSessionsByTask, - sessionAgentctl: merged.sessionAgentctl, - worktrees: merged.worktrees, - sessionWorktreesBySessionId: merged.sessionWorktreesBySessionId, - pendingModel: merged.pendingModel, - activeModel: merged.activeModel, - queue: merged.queue, - terminal: merged.terminal, - shell: merged.shell, - processes: merged.processes, - gitStatus: merged.gitStatus, - contextWindow: merged.contextWindow, - agents: merged.agents, - sessionMode: merged.sessionMode, - userShells: merged.userShells, - prepareProgress: merged.prepareProgress, - sessionTodos: merged.sessionTodos, - agentCapabilities: merged.agentCapabilities, - sessionModels: merged.sessionModels, - promptUsage: merged.promptUsage, - sessionPollMode: merged.sessionPollMode, - githubStatus: merged.githubStatus, - taskPRs: merged.taskPRs, - pendingPrUrlByTaskId: merged.pendingPrUrlByTaskId, - prWatches: merged.prWatches, - reviewWatches: merged.reviewWatches, - issueWatches: merged.issueWatches, - actionPresets: merged.actionPresets, - taskMRs: merged.taskMRs, - jiraIssueWatches: merged.jiraIssueWatches, - linearIssueWatches: merged.linearIssueWatches, - office: merged.office, - features: merged.features, - automations: merged.automations, - automationRuns: merged.automationRuns, - system: merged.system, - previewPanel: merged.previewPanel, - rightPanel: merged.rightPanel, - diffs: merged.diffs, - connection: merged.connection, - mobileKanban: merged.mobileKanban, - mobileSession: merged.mobileSession, - chatInput: merged.chatInput, - documentPanel: merged.documentPanel, - systemHealth: merged.systemHealth, - quickChat: merged.quickChat, - sessionFailureNotification: merged.sessionFailureNotification, - bottomTerminal: merged.bottomTerminal, - // Note: collapsedSubtaskParents is intentionally not overridden here — - // createUISlice hydrates it from sessionStorage and we want that to win. - // Add hydrate method + // Re-assert merged initial state so caller-supplied values win over slice defaults. + ...buildStateOverrides(merged), // eslint-disable-next-line @typescript-eslint/no-explicit-any hydrate: (state, options) => set((draft) => hydrateState(draft as any, state, options)), })), diff --git a/apps/web/lib/types/gitlab.ts b/apps/web/lib/types/gitlab.ts index e251c215a9..78ec404068 100644 --- a/apps/web/lib/types/gitlab.ts +++ b/apps/web/lib/types/gitlab.ts @@ -158,3 +158,156 @@ export type GitLabMRDiscussion = { created_at: string; updated_at: string; }; + +/** Project filter for review/issue watch scoping. */ +export type ProjectFilter = { path: string }; + +/** Review watch — saved search for MRs awaiting review. */ +export type ReviewWatch = { + id: string; + workspace_id: string; + workflow_id: string; + workflow_step_id: string; + projects: ProjectFilter[]; + agent_profile_id: string; + executor_profile_id: string; + prompt: string; + review_scope: "user" | "user_and_teams" | string; + custom_query: string; + enabled: boolean; + poll_interval_seconds: number; + cleanup_policy: "auto" | "always" | "never" | string; + last_polled_at?: string; + created_at: string; + updated_at: string; +}; + +/** Issue watch — saved search for issues. */ +export type IssueWatch = { + id: string; + workspace_id: string; + workflow_id: string; + workflow_step_id: string; + projects: ProjectFilter[]; + agent_profile_id: string; + executor_profile_id: string; + prompt: string; + labels: string[]; + custom_query: string; + enabled: boolean; + poll_interval_seconds: number; + cleanup_policy: "auto" | "always" | "never" | string; + last_polled_at?: string; + created_at: string; + updated_at: string; +}; + +/** Branch-bound MR watch — links a session's source branch to a discovered MR. */ +export type MRWatch = { + id: string; + session_id: string; + task_id: string; + repository_id?: string; + project_path: string; + mr_iid: number; + branch: string; + last_checked_at?: string; + last_note_at?: string; + last_pipeline_state: string; + last_approval_state: string; + created_at: string; + updated_at: string; +}; + +/** Aggregate stats for the /gitlab page. */ +export type GitLabStats = { + open_mrs: number; + mrs_awaiting_my_review: number; + open_issues_assigned_me: number; +}; + +/** Action preset (quick-launch task template). */ +export type GitLabActionPreset = { + id: string; + label: string; + hint: string; + icon: string; + prompt_template: string; +}; + +/** Workspace's MR + issue presets. */ +export type GitLabActionPresets = { + workspace_id: string; + mr: GitLabActionPreset[]; + issue: GitLabActionPreset[]; +}; + +/** Project (lightweight, for autocomplete). */ +export type GitLabProject = { + id: number; + path_with_namespace: string; + namespace: string; + path: string; + name: string; + visibility: string; +}; + +/** Project allowed merge methods. */ +export type ProjectMergeMethods = { + merge: boolean; + rebase_merge: boolean; + fast_forward: boolean; + allow_squash: boolean; +}; + +/** Single approval entry on an MR. */ +export type GitLabMRApproval = { + username: string; + avatar?: string; + created_at: string; +}; + +/** Pipeline (CI) for an MR. */ +export type GitLabPipeline = { + id: number; + iid: number; + status: string; + source: string; + ref: string; + sha: string; + web_url: string; + jobs_total: number; + jobs_passing: number; + started_at?: string; + finished_at?: string; +}; + +/** Aggregate feedback for an MR (used by the detail panel). */ +export type GitLabMRFeedback = { + mr: MR; + approvals: GitLabMRApproval[]; + discussions: GitLabMRDiscussion[]; + pipelines: GitLabPipeline[]; + has_issues: boolean; +}; + +/** File changed in an MR. */ +export type GitLabMRFile = { + filename: string; + status: string; + additions: number; + deletions: number; + patch?: string; + old_path?: string; +}; + +/** Commit in an MR. */ +export type GitLabMRCommit = { + sha: string; + message: string; + author_name: string; + author_date: string; +}; + +/** Project branch entry. */ +export type GitLabRepoBranch = { name: string };