package linear import ( "context" "errors" "fmt" "math" "strings" "time" "go.uber.org/zap" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/watchreset" ) // ErrIssueWatchNotFound is returned when GetIssueWatch's caller looks up an ID // that doesn't exist. Callers map this to HTTP 404. var ErrIssueWatchNotFound = errors.New("linear: issue watch not found") // SetEventBus wires the bus used to publish NewLinearIssueEvent. Optional: if // unset the poller still runs but observed issues do not become Kandev tasks. func (s *Service) SetEventBus(eb bus.EventBus) { s.mu.Lock() s.eventBus = eb s.mu.Unlock() } // CreateIssueWatch validates the request and persists a new watch row. func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) { if err := validateIssueWatchCreate(req); err != nil { return nil, err } w := &IssueWatch{ WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, WorkflowStepID: req.WorkflowStepID, Filter: normalizeFilter(req.Filter), AgentProfileID: req.AgentProfileID, ExecutorProfileID: req.ExecutorProfileID, Prompt: req.Prompt, PollIntervalSeconds: req.PollIntervalSeconds, MaxInflightTasks: req.MaxInflightTasks, Enabled: true, } if req.Enabled != nil { w.Enabled = *req.Enabled } if err := s.store.CreateIssueWatch(ctx, w); err != nil { return nil, err } return w, nil } // ListIssueWatches returns the watches configured for a workspace. func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { return s.store.ListIssueWatches(ctx, workspaceID) } // ListAllIssueWatches returns every watch across all workspaces. func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { return s.store.ListAllIssueWatches(ctx) } // GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound. func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { w, err := s.store.GetIssueWatch(ctx, id) if err != nil { return nil, err } if w == nil { return nil, ErrIssueWatchNotFound } return w, nil } // UpdateIssueWatch applies a partial update by patching only the fields the // caller explicitly set, then persists the result. func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) (*IssueWatch, error) { w, err := s.GetIssueWatch(ctx, id) if err != nil { return nil, err } applyIssueWatchPatch(w, req) if filterIsEmpty(w.Filter) { return nil, fmt.Errorf("%w: filter must specify at least one of query, teamKey, stateIds, assigned, priorities, labelIds, creatorId, or estimate range", ErrInvalidConfig) } if err := validateFilterBounds(w.Filter); err != nil { return nil, err } if w.WorkflowID == "" || w.WorkflowStepID == "" { return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig) } if err := validatePollInterval(w.PollIntervalSeconds); err != nil { return nil, err } if err := validateMaxInflightTasks(w.MaxInflightTasks); err != nil { return nil, err } if err := s.store.UpdateIssueWatch(ctx, w); err != nil { return nil, err } return w, nil } // DeleteIssueWatch removes the watch and its dedup rows. Idempotent. func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error { return s.store.DeleteIssueWatch(ctx, id) } // linearIssueWatchResetter is the watchreset.Resetter adapter for a single // Linear issue watch. Closes over the store and watch ID so the shared // watchreset.Run helper stays integration-agnostic. type linearIssueWatchResetter struct { store *Store watchID string } func (r *linearIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) { return r.store.ListIssueWatchTaskIDs(ctx, r.watchID) } func (r *linearIssueWatchResetter) Clear(ctx context.Context) error { return r.store.ResetIssueWatchState(ctx, r.watchID) } // PreviewResetIssueWatch returns how many tasks ResetIssueWatch would // cascade-delete. Used by the frontend to populate the confirmation dialog. func (s *Service) PreviewResetIssueWatch(ctx context.Context, watchID string) (int, error) { return watchreset.Preview(ctx, &linearIssueWatchResetter{store: s.store, watchID: watchID}) } // ResetIssueWatch is destructive: cascade-deletes every task previously // created by the watch (including archived), wipes the per-watch dedup // rows, and nulls last_polled_at so the next poll re-imports every // currently-matching issue. Returns the count of tasks deleted. func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, error) { s.mu.Lock() td := s.taskDeleter s.mu.Unlock() if td == nil { return 0, errors.New("linear: task deleter not wired; reset unavailable") } res, err := watchreset.Run(ctx, &linearIssueWatchResetter{store: s.store, watchID: watchID}, td, s.log) return res.TasksDeleted, err } // CheckIssueWatch runs the watch's filter once and returns the issues that // haven't been turned into tasks yet. last_polled_at is stamped regardless of // whether the search succeeded — a failing search still counts as "we tried". // // Concurrency note: callers must tolerate being handed an issue that gets // stolen by a concurrent reserver. We query the seen-set and return unseen // identifiers, but we do NOT insert the dedup row here — that happens in the // orchestrator's ReserveIssueWatchTask via INSERT OR IGNORE. If the manual // /trigger endpoint and the poller tick fire for the same watch in quick // succession, both calls can see the same identifier as unseen before either // has reserved it. The duplicate publish is harmless (the second reserver // loses the race and bails) but the goroutine work is wasted. Same pattern as // the JIRA watcher. func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*LinearIssue, error) { defer s.stampWatchLastPolled(w.ID) client, err := s.clientFor(ctx) if err != nil { return nil, err } res, err := client.SearchIssues(ctx, w.Filter, "", issueWatchSearchPageSize) if err != nil { return nil, err } seen, err := s.store.ListSeenIssueIdentifiers(ctx, w.ID) if err != nil { s.log.Warn("linear: dedup set fetch failed", zap.String("watch_id", w.ID), zap.Error(err)) seen = nil } out := make([]*LinearIssue, 0, len(res.Issues)) for i := range res.Issues { issue := res.Issues[i] if _, ok := seen[issue.Identifier]; ok { continue } out = append(out, &issue) } return out, nil } // stampWatchLastPolled writes the current timestamp using a fresh background // context with a short write deadline, so a cancelled caller ctx (e.g. shutdown) // doesn't drop the liveness record. func (s *Service) stampWatchLastPolled(watchID string) { ctx, cancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) defer cancel() if err := s.store.UpdateIssueWatchLastPolled(ctx, watchID, time.Now().UTC()); err != nil { s.log.Warn("linear: update last_polled_at failed", zap.String("watch_id", watchID), zap.Error(err)) } } // publishNewLinearIssueEvent emits the orchestrator-facing event for one // freshly-observed issue. No-op when the event bus is not wired (tests, early // boot). func (s *Service) publishNewLinearIssueEvent(ctx context.Context, w *IssueWatch, issue *LinearIssue) { s.mu.Lock() eb := s.eventBus s.mu.Unlock() if eb == nil { return } evt := bus.NewEvent(events.LinearNewIssue, "linear", &NewLinearIssueEvent{ IssueWatchID: w.ID, WorkspaceID: w.WorkspaceID, WorkflowID: w.WorkflowID, WorkflowStepID: w.WorkflowStepID, AgentProfileID: w.AgentProfileID, ExecutorProfileID: w.ExecutorProfileID, Prompt: w.Prompt, MaxInflightTasks: w.MaxInflightTasks, Issue: issue, }) if err := eb.Publish(ctx, events.LinearNewIssue, evt); err != nil { s.log.Debug("linear: publish new issue event failed", zap.String("watch_id", w.ID), zap.String("identifier", issue.Identifier), zap.Error(err)) } } // issueWatchSearchPageSize caps how many issues a single CheckIssueWatch call // pulls from Linear. Mirrors the Jira watcher's limit so per-tick cost stays // bounded for very broad filters. const issueWatchSearchPageSize = 50 // MinIssueWatchPollInterval / MaxIssueWatchPollInterval bound the per-watch // search re-run cadence. const ( MinIssueWatchPollInterval = 60 MaxIssueWatchPollInterval = 3600 ) func validateIssueWatchCreate(req *CreateIssueWatchRequest) error { if req.WorkspaceID == "" { return fmt.Errorf("%w: workspaceId required", ErrInvalidConfig) } if req.WorkflowID == "" || req.WorkflowStepID == "" { return fmt.Errorf("%w: workflowId and workflowStepId required", ErrInvalidConfig) } if filterIsEmpty(normalizeFilter(req.Filter)) { return fmt.Errorf("%w: filter must specify at least one of query, teamKey, stateIds, assigned, priorities, labelIds, creatorId, or estimate range", ErrInvalidConfig) } if err := validateFilterBounds(req.Filter); err != nil { return err } if req.PollIntervalSeconds != 0 { if err := validatePollInterval(req.PollIntervalSeconds); err != nil { return err } } if err := validateMaxInflightTasks(req.MaxInflightTasks); err != nil { return err } return nil } // validateMaxInflightTasks rejects non-positive caps. A nil pointer is // "uncapped" and explicitly allowed. func validateMaxInflightTasks(v *int) error { if v == nil { return nil } if *v <= 0 { return fmt.Errorf("%w: maxInflightTasks must be a positive integer", ErrInvalidConfig) } return nil } // validateFilterBounds rejects out-of-range values for fields where the wire // type permits invalid values (e.g. Priority being an unconstrained int). // Empty / unset fields pass without check. func validateFilterBounds(f SearchFilter) error { for _, p := range f.Priorities { if p < 0 || p > 4 { return fmt.Errorf("%w: priority must be between 0 and 4", ErrInvalidConfig) } } if err := validateEstimateBound(f.EstimateMin, "estimateMin"); err != nil { return err } if err := validateEstimateBound(f.EstimateMax, "estimateMax"); err != nil { return err } if f.EstimateMin != nil && f.EstimateMax != nil && *f.EstimateMin > *f.EstimateMax { return fmt.Errorf("%w: estimateMin cannot be greater than estimateMax", ErrInvalidConfig) } return nil } // validateEstimateBound rejects NaN, ±Inf, and negative values. json.Marshal // fails on NaN/Inf, so without this check a malformed config would surface as a // 500 at watch-time instead of a clean validation error at create-time. func validateEstimateBound(v *float64, name string) error { if v == nil { return nil } if math.IsNaN(*v) || math.IsInf(*v, 0) || *v < 0 { return fmt.Errorf("%w: %s must be a non-negative number", ErrInvalidConfig, name) } return nil } func validatePollInterval(seconds int) error { if seconds < MinIssueWatchPollInterval || seconds > MaxIssueWatchPollInterval { return fmt.Errorf("%w: pollIntervalSeconds must be between %d and %d", ErrInvalidConfig, MinIssueWatchPollInterval, MaxIssueWatchPollInterval) } return nil } // normalizeFilter trims string fields and drops empty stateIds entries, so a // filter that looks empty after normalization fails the at-least-one check // instead of slipping through with whitespace. func normalizeFilter(f SearchFilter) SearchFilter { out := SearchFilter{ Query: strings.TrimSpace(f.Query), TeamKey: strings.TrimSpace(f.TeamKey), Assigned: strings.TrimSpace(f.Assigned), CreatorID: strings.TrimSpace(f.CreatorID), EstimateMin: f.EstimateMin, EstimateMax: f.EstimateMax, } for _, id := range f.StateIDs { id = strings.TrimSpace(id) if id != "" { out.StateIDs = append(out.StateIDs, id) } } for _, id := range f.LabelIDs { id = strings.TrimSpace(id) if id != "" { out.LabelIDs = append(out.LabelIDs, id) } } seenPriority := map[int]bool{} for _, p := range f.Priorities { if seenPriority[p] { continue } seenPriority[p] = true out.Priorities = append(out.Priorities, p) } return out } func filterIsEmpty(f SearchFilter) bool { return f.Query == "" && f.TeamKey == "" && f.Assigned == "" && len(f.StateIDs) == 0 && len(f.Priorities) == 0 && len(f.LabelIDs) == 0 && f.CreatorID == "" && f.EstimateMin == nil && f.EstimateMax == nil } func applyIssueWatchPatch(w *IssueWatch, req *UpdateIssueWatchRequest) { if req.WorkflowID != nil { w.WorkflowID = *req.WorkflowID } if req.WorkflowStepID != nil { w.WorkflowStepID = *req.WorkflowStepID } if req.Filter != nil { w.Filter = normalizeFilter(*req.Filter) } if req.AgentProfileID != nil { w.AgentProfileID = *req.AgentProfileID } if req.ExecutorProfileID != nil { w.ExecutorProfileID = *req.ExecutorProfileID } if req.Prompt != nil { w.Prompt = *req.Prompt } if req.Enabled != nil { w.Enabled = *req.Enabled } if req.PollIntervalSeconds != nil { w.PollIntervalSeconds = *req.PollIntervalSeconds } // MaxInflightTasks is tri-state (optional.Int): only apply it when the // PATCH actually carried the key, so a partial update that omits it // leaves the cap intact. Present+null clears the cap; present+int sets it. if req.MaxInflightTasks.Present { w.MaxInflightTasks = req.MaxInflightTasks.Value } }