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