Files
kandev/apps/backend/internal/gitlab/service_watches.go
T

865 lines
30 KiB
Go

package gitlab
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/watchreset"
)
// 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
watchDeleteTimeout = 30 * time.Second
)
// --- 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
}
// EnsureMRWatch is the idempotent get-or-create counterpart to CreateMRWatch,
// keyed by (sessionID, repositoryID, branch). Returns the existing row when
// one is already present, backfilling mr_iid in place if it was previously
// unknown (0) and a real iid is now available. Otherwise creates a new watch.
// Safe to call repeatedly for the same (session, repository, branch) triple —
// callers on the auto-link and on-demand-check paths call this on every push
// without checking for an existing row themselves.
func (s *Service) EnsureMRWatch(ctx context.Context, sessionID, taskID, repositoryID, projectPath string, iid int, branch string) (*MRWatch, error) {
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
existing, err := store.GetMRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch)
if err != nil {
return nil, err
}
if existing == nil {
w, createErr := s.CreateMRWatch(ctx, sessionID, taskID, repositoryID, projectPath, iid, branch)
if createErr == nil {
return w, nil
}
// Lost the race with a concurrent EnsureMRWatch for the same triple
// (push detection and the on-demand check can both run for one
// session): the UNIQUE(session_id, repository_id, branch) constraint
// rejected our INSERT because the row we were about to create now
// exists. Re-read and return it — the caller's intent is satisfied.
// Any other failure is still reported.
if raced, getErr := store.GetMRWatchBySessionRepoAndBranch(ctx, sessionID, repositoryID, branch); getErr == nil && raced != nil {
return raced, nil
}
return nil, createErr
}
// Replace whenever the caller supplies a different, known iid — not just
// when the stored one was still unknown (<=0). A branch can be relinked
// to a replacement MR (the prior one closed and a new one opened), and
// without this the watch keeps polling the stale iid forever.
if iid > 0 && existing.MRIID != iid {
if err := store.UpdateMRWatchMRIID(ctx, existing.ID, iid); err != nil {
return nil, fmt.Errorf("update MR watch iid: %w", err)
}
existing.MRIID = iid
}
return existing, 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)
}
func (s *Service) ListMRWatchesBySessionForWorkspace(ctx context.Context, workspaceID, sessionID string) ([]*MRWatch, error) {
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
return store.ListMRWatchesBySessionForWorkspace(ctx, workspaceID, sessionID)
}
func (s *Service) ListMRWatchesByTaskForWorkspace(ctx context.Context, workspaceID, taskID string) ([]*MRWatch, error) {
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
return store.ListMRWatchesByTaskForWorkspace(ctx, workspaceID, taskID)
}
func (s *Service) ListActiveMRWatchesForWorkspace(ctx context.Context, workspaceID string) ([]*MRWatch, error) {
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
return store.ListActiveMRWatchesForWorkspace(ctx, workspaceID)
}
func (s *Service) DeleteMRWatchForWorkspace(ctx context.Context, workspaceID, id string) error {
store := s.requireStore()
if store == nil {
return errStoreUnavailable
}
deleted, err := store.DeleteMRWatchForWorkspace(ctx, workspaceID, id)
if err != nil {
return err
}
if !deleted {
return ErrWatchNotFound
}
return nil
}
// 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")
// ErrWatchOwnershipLost means an event belongs to a watch generation that was
// invalidated by reset or delete. Callers must not retain the created task.
var ErrWatchOwnershipLost = fmt.Errorf("watch ownership lost")
// 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, err := s.clientForTask(ctx, watch.TaskID)
if err != nil {
return nil, false, err
}
store := s.requireStore()
if store == nil {
return nil, false, errStoreUnavailable
}
valid, err := s.watchMatchesTaskRepository(ctx, store, client, watch)
if err != nil {
return nil, false, err
}
if !valid {
return nil, false, nil
}
// 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)
}
s.refreshTaskMRFromWatch(ctx, store, client, watch, status)
return status, notable, nil
}
// watchMatchesTaskRepository confirms watch.RepositoryID/ProjectPath still
// identify the task's own GitLab repository before any GitLab request or
// gitlab_task_mrs write. refreshTaskMRFromWatch's identity check only
// validates the MR *returned by GitLab* against the watch's own (host,
// project, iid) — that alone still lets a stale or mis-scoped watch (one
// whose repository_id was never actually tied to this GitLab project) poll
// and self-consistently upsert data anyway, since GetMRStatus itself only
// cares about project path and iid, not repository_id.
//
// Returns (true, nil) when the watch is valid, or when the owning task is
// orphaned (workspaceID == "", e.g. the task was deleted) — refreshing an
// orphaned watch is already a benign no-op elsewhere in this file, so
// identity is not re-litigated here. Returns (false, nil) — not an error —
// only when ValidateTaskMRRepositoryIdentity returns the direct
// ErrTaskMRRepositoryMismatch sentinel, a durable identity mismatch; the
// watch is deleted rather than left to retry, since a mismatch is a
// permanent fact, not a transient failure, and would otherwise poll forever
// for nothing. Any other error — ErrTaskMRNotFound (the task/repository
// link row itself is missing, which can be a transient race rather than a
// proven mismatch) or a wrapped store/lookup failure — is propagated
// instead of deleting the watch, so the poller's existing per-watch error
// logging fires and retries on the next tick rather than destroying data on
// what may be a recoverable condition.
func (s *Service) watchMatchesTaskRepository(ctx context.Context, store *Store, client Client, watch *MRWatch) (bool, error) {
workspaceID, err := store.WorkspaceIDForTask(ctx, watch.TaskID)
if err != nil {
return false, err
}
if workspaceID == "" {
return true, nil
}
err = store.ValidateTaskMRRepositoryIdentity(
ctx, workspaceID, watch.TaskID, watch.RepositoryID, client.Host(), watch.ProjectPath,
)
if err == nil {
return true, nil
}
if !errors.Is(err, ErrTaskMRRepositoryMismatch) {
return false, err
}
s.logger.Warn("dropping MR watch whose repository no longer matches the task",
zap.String("watch_id", watch.ID), zap.String("task_id", watch.TaskID), zap.Error(err))
if delErr := store.DeleteMRWatch(ctx, watch.ID); delErr != nil {
s.logger.Warn("failed to delete mismatched MR watch",
zap.String("watch_id", watch.ID), zap.Error(delErr))
}
return false, nil
}
// refreshTaskMRFromWatch keeps gitlab_task_mrs in sync with every poll, not
// just the ones the caller found "notable" — otherwise the topbar MR surface
// (state, pipeline_state, approval_state, merge_status) only updates on a
// pipeline/approval transition and misses everything else (title, merge
// state, review comments' side effects, etc). Best-effort: a failure here
// must not fail the poll cycle, since UpdateMRWatchTimestamps above already
// recorded a successful check.
func (s *Service) refreshTaskMRFromWatch(ctx context.Context, store *Store, client Client, watch *MRWatch, status *MRStatus) {
// A response that doesn't actually match this watch's (host, project,
// iid) — a client bug, or a mismatched mock in tests — must not create or
// overwrite a task-MR association. Same check the manual-link and
// auto-link paths already run before trusting a fetched MR.
if err := validateReturnedMRIdentity(status, client.Host(), watch.ProjectPath, watch.MRIID); err != nil {
s.logger.Warn("dropping MR watch refresh with mismatched identity",
zap.String("watch_id", watch.ID), zap.String("task_id", watch.TaskID), zap.Error(err))
return
}
workspaceID, err := store.WorkspaceIDForTask(ctx, watch.TaskID)
if err != nil {
// This is now the only path that refreshes gitlab_task_mrs on every
// poll, so a transient DB error here must be observable — unlike an
// empty workspaceID with no error (a benign orphaned-watch case,
// e.g. the task was deleted), which is not logged.
s.logger.Warn("failed to resolve workspace while refreshing task MR from watch",
zap.String("watch_id", watch.ID), zap.String("task_id", watch.TaskID), zap.Error(err))
return
}
if workspaceID == "" {
return
}
association := taskMRFromStatus(watch.TaskID, watch.RepositoryID, client.Host(), watch.ProjectPath, status)
// Loaded before the upsert so it reflects the row's state prior to this
// poll; a lookup failure isn't fatal to the refresh itself, but without a
// "previous" to compare against we can't tell whether anything visible
// changed, so this poll's update is published rather than silently
// dropped by a comparison against a stale nil.
previous, prevErr := store.GetTaskMR(ctx, watch.TaskID, watch.RepositoryID, watch.ProjectPath, watch.MRIID)
if prevErr != nil {
s.logger.Warn("failed to load existing task MR before refresh",
zap.String("watch_id", watch.ID), zap.String("task_id", watch.TaskID), zap.Error(prevErr))
}
if err := store.UpsertTaskMR(ctx, association); err != nil {
s.logger.Warn("failed to refresh task MR from watch",
zap.String("watch_id", watch.ID), zap.String("task_id", watch.TaskID), zap.Error(err))
return
}
if prevErr != nil || taskMRVisibleFieldsChanged(previous, association) {
s.publishTaskMRUpdated(ctx, workspaceID, association)
}
}
// taskMRVisibleFieldsChanged reports whether any user-visible field differs
// between a task-MR association's previous and current state. Excludes
// bookkeeping-only columns (id, timestamps) so a poll that re-fetches
// identical MR data doesn't broadcast a gitlab.task_mr.updated event on every
// tick — refreshTaskMRFromWatch runs on every poll, not just "notable"
// pipeline/approval transitions, so without this filter the event fires far
// more often than anything actually changed. previous == nil (first-ever
// refresh for this association) always counts as changed.
func taskMRVisibleFieldsChanged(previous, current *TaskMR) bool {
if previous == nil {
return true
}
return taskMRIdentityFieldsChanged(previous, current) || taskMRStatusFieldsChanged(previous, current)
}
// taskMRIdentityFieldsChanged compares the descriptive fields a poll can
// revise (title, branches, author) — split out from taskMRStatusFieldsChanged
// purely to keep taskMRVisibleFieldsChanged under the cyclomatic-complexity
// budget; the two halves together are the full comparison.
func taskMRIdentityFieldsChanged(previous, current *TaskMR) bool {
return previous.MRURL != current.MRURL ||
previous.MRTitle != current.MRTitle ||
previous.HeadBranch != current.HeadBranch ||
previous.BaseBranch != current.BaseBranch ||
previous.AuthorUsername != current.AuthorUsername
}
// taskMRStatusFieldsChanged compares the review/CI status fields a poll can
// revise (state, approvals, pipeline, merge).
func taskMRStatusFieldsChanged(previous, current *TaskMR) bool {
return previous.State != current.State ||
previous.ApprovalState != current.ApprovalState ||
previous.PipelineState != current.PipelineState ||
previous.MergeStatus != current.MergeStatus ||
previous.Draft != current.Draft ||
previous.ApprovalCount != current.ApprovalCount ||
previous.RequiredApprovals != current.RequiredApprovals ||
previous.PipelineJobsTotal != current.PipelineJobsTotal ||
previous.PipelineJobsPass != current.PipelineJobsPass ||
!timePtrEqual(previous.MergedAt, current.MergedAt) ||
!timePtrEqual(previous.ClosedAt, current.ClosedAt)
}
// timePtrEqual compares two *time.Time for equal instants, nil-safe.
func timePtrEqual(a, b *time.Time) bool {
if a == nil || b == nil {
return a == b
}
return a.Equal(*b)
}
// --- 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)
}
if err := s.validateWatchDependencies(ctx, req.WorkspaceID, req.WorkflowID, req.WorkflowStepID, req.AgentProfileID, req.ExecutorProfileID); err != nil {
return nil, err
}
repositoryID, baseBranch, err := s.resolveWatchRepository(ctx, req.WorkspaceID, req.RepositoryID, req.BaseBranch)
if err != nil {
return nil, err
}
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,
RepositoryID: repositoryID,
BaseBranch: baseBranch,
ReviewScope: scope,
CustomQuery: req.CustomQuery,
Enabled: true,
PollIntervalSeconds: interval,
CleanupPolicy: NormalizeCleanupPolicy(req.CleanupPolicy),
MaxInflightTasks: req.MaxInflightTasks,
}
if err := validateWatchMaxInflight(rw.MaxInflightTasks); err != nil {
return nil, err
}
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)
}
func (s *Service) GetReviewWatchIncludingDeleting(ctx context.Context, id string) (*ReviewWatch, error) {
store := s.requireStore()
if store == nil {
return nil, errStoreUnavailable
}
return store.GetReviewWatchIncludingDeleting(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 err := s.validateWatchDependencies(ctx, rw.WorkspaceID, rw.WorkflowID, rw.WorkflowStepID, rw.AgentProfileID, rw.ExecutorProfileID); err != nil {
return err
}
if req.RepositoryID != nil || req.BaseBranch != nil {
repositoryID, baseBranch, err := s.resolveWatchRepository(ctx, rw.WorkspaceID, rw.RepositoryID, rw.BaseBranch)
if err != nil {
return err
}
rw.RepositoryID, rw.BaseBranch = repositoryID, baseBranch
}
if req.CleanupPolicy != nil && !IsValidCleanupPolicy(*req.CleanupPolicy) {
return fmt.Errorf("invalid cleanup_policy: %q", *req.CleanupPolicy)
}
if err := validateWatchMaxInflight(rw.MaxInflightTasks); err != nil {
return err
}
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.RepositoryID != nil {
rw.RepositoryID = *req.RepositoryID
if rw.RepositoryID == "" {
rw.BaseBranch = ""
}
}
if req.BaseBranch != nil && rw.RepositoryID != "" {
rw.BaseBranch = *req.BaseBranch
}
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 rw.Enabled {
rw.LastError = ""
rw.LastErrorAt = nil
}
}
if req.PollIntervalSeconds != nil {
rw.PollIntervalSeconds = clampPollInterval(*req.PollIntervalSeconds)
}
if req.CleanupPolicy != nil {
rw.CleanupPolicy = NormalizeCleanupPolicy(*req.CleanupPolicy)
}
if req.MaxInflightTasks != nil {
if *req.MaxInflightTasks == 0 {
rw.MaxInflightTasks = nil
} else {
rw.MaxInflightTasks = req.MaxInflightTasks
}
}
}
// 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
}
func validateWatchMaxInflight(max *int) error {
if max != nil && *max <= 0 {
return fmt.Errorf("max_inflight_tasks must be positive")
}
return nil
}
// 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
}
if err := ctx.Err(); err != nil {
return err
}
deleteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), watchDeleteTimeout)
defer cancel()
s.mu.RLock()
deleter := s.taskDeleter
cascadeDeleter := s.cascadeTaskDeleter
s.mu.RUnlock()
invalidation, err := store.BeginReviewWatchDelete(deleteCtx, id)
if err != nil {
return err
}
if invalidation.Missing {
return nil
}
s.cleanupInvalidatedWatchTasks(deleteCtx, id, invalidation.TaskIDs, cascadeDeleter, deleter)
return store.DeleteReviewWatch(deleteCtx, id)
}
func (s *Service) cleanupInvalidatedWatchTasks(ctx context.Context, watchID string, ids []string, cascadeDeleter watchreset.TaskDeleter, deleter TaskDeleter) {
if cascadeDeleter != nil {
s.deleteWatchTaskTrees(ctx, watchID, ids, cascadeDeleter)
return
}
if deleter == nil {
return
}
for _, taskID := range ids {
if taskID == "" {
continue
}
if err := deleter.DeleteTask(ctx, taskID); err != nil {
s.logger.Warn("failed to delete task during watch cleanup",
zap.String("watch_id", watchID), zap.String("task_id", 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
}
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, err := s.ClientForWorkspace(ctx, watch.WorkspaceID)
if err != nil {
return nil, err
}
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
}
// TriggerReviewWatchAllForWorkspace runs only watches owned by workspaceID.
func (s *Service) TriggerReviewWatchAllForWorkspace(ctx context.Context, workspaceID string) (int, error) {
store := s.requireStore()
if store == nil {
return 0, errStoreUnavailable
}
watches, err := store.ListEnabledReviewWatchesForWorkspace(ctx, workspaceID)
if err != nil {
return 0, err
}
total := 0
for _, watch := range watches {
found, checkErr := s.CheckReviewWatch(ctx, watch)
if checkErr != nil {
s.logger.Warn("trigger workspace review watches", zap.String("watch_id", watch.ID), zap.Error(checkErr))
continue
}
for _, mr := range found {
s.publishNewReviewMREvent(ctx, watch, 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
}