Files
kandev/apps/backend/internal/gitlab/store_watches.go
T
João Salavisa dfaab11959 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>
2026-06-02 22:10:07 +01:00

740 lines
25 KiB
Go

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
}