Files
kandev/apps/backend/internal/sentry/service_issue_watch.go
T
2026-06-15 18:54:16 +01:00

356 lines
12 KiB
Go

package sentry
import (
"context"
"errors"
"fmt"
"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("sentry: issue watch not found")
// SetEventBus wires the bus used to publish NewSentryIssueEvent. 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 err := validateFilter(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 := validateMaxInflightTasks(w.MaxInflightTasks); err != nil {
return nil, err
}
if err := validatePollInterval(w.PollIntervalSeconds); 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)
}
// sentryIssueWatchResetter is the watchreset.Resetter adapter for a single
// Sentry issue watch. Closes over the store and watch ID so the shared
// watchreset.Run helper stays integration-agnostic.
type sentryIssueWatchResetter struct {
store *Store
watchID string
}
func (r *sentryIssueWatchResetter) ListTaskIDs(ctx context.Context) ([]string, error) {
return r.store.ListIssueWatchTaskIDs(ctx, r.watchID)
}
func (r *sentryIssueWatchResetter) 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, &sentryIssueWatchResetter{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("sentry: task deleter not wired; reset unavailable")
}
res, err := watchreset.Run(ctx,
&sentryIssueWatchResetter{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. The duplicate publish is harmless — the
// second reserver loses the INSERT OR IGNORE race in the orchestrator and
// bails. Same pattern as the Linear / Jira watchers.
func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*SentryIssue, error) {
defer s.stampWatchLastPolled(w.ID)
client, err := s.clientFor(ctx)
if err != nil {
return nil, err
}
// Intentionally reads only the first page per tick (bounded-page-per-tick
// invariant, matching the Linear/Jira watchers). SearchIssues sorts results
// by first-seen descending (sort=new) so newly created issues reliably land
// on page one and are not missed by the single-page read.
res, err := client.SearchIssues(ctx, w.Filter, "")
if err != nil {
return nil, err
}
seen, err := s.store.ListSeenIssueShortIDs(ctx, w.ID)
if err != nil {
// Skip this tick rather than treat a failed dedup read as "nothing seen":
// a nil map would let the whole page (up to 100 issues) publish as events.
// The next tick retries with a working dedup set.
return nil, fmt.Errorf("load dedup set for watch %s: %w", w.ID, err)
}
out := make([]*SentryIssue, 0, len(res.Issues))
for i := range res.Issues {
issue := res.Issues[i]
if _, ok := seen[issue.ShortID]; 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("sentry: update last_polled_at failed",
zap.String("watch_id", watchID), zap.Error(err))
}
}
// ReserveIssueWatchTask exposes the dedup store API to the orchestrator's
// WatcherSource implementation.
func (s *Service) ReserveIssueWatchTask(ctx context.Context, watchID, shortID, issueURL string) (bool, error) {
return s.store.ReserveIssueWatchTask(ctx, watchID, shortID, issueURL)
}
// AssignIssueWatchTaskID exposes the dedup store API to the orchestrator's
// WatcherSource implementation.
func (s *Service) AssignIssueWatchTaskID(ctx context.Context, watchID, shortID, taskID string) error {
return s.store.AssignIssueWatchTaskID(ctx, watchID, shortID, taskID)
}
// ReleaseIssueWatchTask exposes the dedup store API to the orchestrator's
// WatcherSource implementation.
func (s *Service) ReleaseIssueWatchTask(ctx context.Context, watchID, shortID string) error {
return s.store.ReleaseIssueWatchTask(ctx, watchID, shortID)
}
// publishNewSentryIssueEvent 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) publishNewSentryIssueEvent(ctx context.Context, w *IssueWatch, issue *SentryIssue) {
s.mu.Lock()
eb := s.eventBus
s.mu.Unlock()
if eb == nil {
return
}
evt := bus.NewEvent(events.SentryNewIssue, "sentry", &NewSentryIssueEvent{
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.SentryNewIssue, evt); err != nil {
s.log.Warn("sentry: publish new issue event failed",
zap.String("watch_id", w.ID), zap.String("short_id", issue.ShortID), zap.Error(err))
}
}
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 err := validateFilter(normalizeFilter(req.Filter)); err != nil {
return err
}
if err := validateMaxInflightTasks(req.MaxInflightTasks); err != nil {
return err
}
if req.PollIntervalSeconds != 0 {
if err := validatePollInterval(req.PollIntervalSeconds); 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
}
// validateFilter requires the minimum identity for a Sentry search: an org and
// a project. Other fields (environment, levels, statuses, query, statsPeriod)
// are optional.
func validateFilter(f SearchFilter) error {
if f.OrgSlug == "" {
return fmt.Errorf("%w: filter.orgSlug is required", ErrInvalidConfig)
}
if f.ProjectSlug == "" {
return fmt.Errorf("%w: filter.projectSlug is required", ErrInvalidConfig)
}
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 list entries so a filter
// that looks empty after normalization fails the minimum-identity check
// instead of slipping through with whitespace.
func normalizeFilter(f SearchFilter) SearchFilter {
out := SearchFilter{
OrgSlug: strings.TrimSpace(f.OrgSlug),
ProjectSlug: strings.TrimSpace(f.ProjectSlug),
Environment: strings.TrimSpace(f.Environment),
Query: strings.TrimSpace(f.Query),
StatsPeriod: strings.TrimSpace(f.StatsPeriod),
}
for _, v := range f.Levels {
v = strings.TrimSpace(v)
if v != "" {
out.Levels = append(out.Levels, v)
}
}
for _, v := range f.Statuses {
v = strings.TrimSpace(v)
if v != "" {
out.Statuses = append(out.Statuses, v)
}
}
return out
}
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
// field was present in the payload. Absent leaves the cap unchanged; null
// clears it to uncapped; a value sets the cap.
if req.MaxInflightTasks.Present {
w.MaxInflightTasks = req.MaxInflightTasks.Value
}
}