diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 05aff539ef..419c8468c4 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -60,6 +60,7 @@ import ( prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/sentry" "github.com/kandev/kandev/internal/slack" spriteshandlers "github.com/kandev/kandev/internal/sprites" sshhandlers "github.com/kandev/kandev/internal/ssh" @@ -764,6 +765,12 @@ func registerSecondaryRoutes( p.log.Debug("Registered Linear handlers (HTTP + WebSocket)") } + if p.services.Sentry != nil { + sentry.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Sentry, p.log) + sentry.RegisterMockRoutes(p.router, p.services.Sentry, p.log) + p.log.Debug("Registered Sentry handlers (HTTP)") + } + if p.services.Slack != nil { slack.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Slack, p.log) p.log.Debug("Registered Slack handlers (HTTP + WebSocket)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 4f40866c98..0e1afc7209 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -35,6 +35,7 @@ import ( // JIRA integration jirapkg "github.com/kandev/kandev/internal/jira" linearpkg "github.com/kandev/kandev/internal/linear" + sentrypkg "github.com/kandev/kandev/internal/sentry" slackpkg "github.com/kandev/kandev/internal/slack" // Agent infrastructure @@ -487,6 +488,16 @@ func startAgentInfrastructure( addCleanup(func() error { linearPoller.Stop(); return nil }) } + // Start Sentry poller: an auth-health probe plus an issue-watch loop that + // runs configured filters and emits NewSentryIssueEvent. The dedup adapter + // lets the orchestrator turn matching Sentry issues into kandev tasks. + if services.Sentry != nil { + orchestratorSvc.SetSentryService(&sentryServiceAdapter{svc: services.Sentry}) + sentryPoller := sentrypkg.NewPoller(services.Sentry, log) + sentryPoller.Start(ctx) + addCleanup(func() error { sentryPoller.Stop(); return nil }) + } + // Start Slack auth-health poller and the trigger loop. The trigger // polls each configured workspace every 30s for new `!kandev …` // messages from the authenticated user and turns them into Kandev diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index 5f2f81c655..7588e99027 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -28,6 +28,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/messagequeue" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" + sentrypkg "github.com/kandev/kandev/internal/sentry" taskmodels "github.com/kandev/kandev/internal/task/models" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" @@ -336,6 +337,28 @@ func (a *linearServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchI return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, identifier) } +// sentryServiceAdapter exposes the Sentry service's issue-watch dedup methods +// to the orchestrator without leaking the rest of the package surface area. +type sentryServiceAdapter struct { + svc *sentrypkg.Service +} + +func (a *sentryServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, shortID, issueURL string) (bool, error) { + return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, shortID, issueURL) +} + +func (a *sentryServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, shortID, taskID string) error { + return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, shortID, taskID) +} + +func (a *sentryServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, shortID string) error { + return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, shortID) +} + +func (a *sentryServiceAdapter) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error { + return a.svc.Store().DisableIssueWatchWithError(ctx, watchID, cause) +} + func (a *linearServiceAdapter) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error { return a.svc.Store().DisableIssueWatchWithError(ctx, watchID, cause) } diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 4fb6bf4a8b..5f864f27a9 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -24,6 +24,7 @@ import ( "github.com/kandev/kandev/internal/linear" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/sentry" "github.com/kandev/kandev/internal/slack" taskmodels "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" @@ -95,6 +96,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories gitlabSvc := initGitLabService(dbPool, eventBus, repos.Secrets, log) jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) + sentrySvc := initSentryService(dbPool, eventBus, repos.Secrets, log) slackSvc := initSlackService(dbPool, repos.Secrets, log) shareHTTP := initShareHandlers(dbPool, repos.Task, githubSvc, log, version) @@ -123,6 +125,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories GitLab: gitlabSvc, Jira: jiraSvc, Linear: linearSvc, + Sentry: sentrySvc, Slack: slackSvc, Share: shareHTTP, Automation: automationComponents, @@ -343,6 +346,15 @@ func initLinearService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secr return svc } +// initSentryService wires up the Sentry integration. Failures are non-fatal. +func initSentryService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *sentry.Service { + svc, _, err := sentry.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log) + if err != nil { + log.Warn("Sentry service initialization failed (non-fatal)", zap.Error(err)) + } + return svc +} + // initShareHandlers wires up the public-share-links HTTP surface. Failures // are non-fatal: the rest of the backend boots without the share endpoints. // The github.Client may be nil; CreateShare will fail at the IsAuthenticated diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 5b36974060..6140ed42a8 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -18,6 +18,7 @@ import ( promptservice "github.com/kandev/kandev/internal/prompts/service" promptstore "github.com/kandev/kandev/internal/prompts/store" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/sentry" "github.com/kandev/kandev/internal/slack" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" @@ -60,6 +61,7 @@ type Services struct { GitLab *gitlab.Service Jira *jira.Service Linear *linear.Service + Sentry *sentry.Service Slack *slack.Service Share *share.HTTPHandlers Office *officeservice.Service diff --git a/apps/backend/config/prompts/sentry-issue-watch-default.md b/apps/backend/config/prompts/sentry-issue-watch-default.md new file mode 100644 index 0000000000..f5aa57c293 --- /dev/null +++ b/apps/backend/config/prompts/sentry-issue-watch-default.md @@ -0,0 +1,22 @@ +You have been assigned a Sentry issue to investigate. + +**Issue:** {{issue.url}} +**Short ID:** {{issue.short_id}} +**Title:** {{issue.title}} +**Project:** {{issue.project}} +**Level:** {{issue.level}} +**Status:** {{issue.status}} +**Culprit:** {{issue.culprit}} +**Assignee:** {{issue.assignee}} +**Events:** {{issue.count}} (affecting {{issue.user_count}} users) +**First seen:** {{issue.first_seen}} +**Last seen:** {{issue.last_seen}} + +## Instructions + +1. Read the Sentry issue carefully and understand the failure mode. +2. Reproduce the problem locally where possible, or trace the culprit in the codebase. +3. Implement the fix. +4. Write or update tests covering the regression. +5. Run the test suite to ensure nothing is broken. +6. Commit your changes with a descriptive commit message referencing {{issue.short_id}}. diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index c2ea3fff5f..22edce917a 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -249,6 +249,11 @@ const ( LinearNewIssue = "linear.new_issue" // New issue found matching a Linear issue watch ) +// Event types for Sentry integration +const ( + SentryNewIssue = "sentry.new_issue" // New issue found matching a Sentry issue watch +) + // BuildShellOutputSubject creates a shell output subject for a specific session func BuildShellOutputSubject(sessionID string) string { return ShellOutput + "." + sessionID diff --git a/apps/backend/internal/orchestrator/event_handlers_sentry.go b/apps/backend/internal/orchestrator/event_handlers_sentry.go new file mode 100644 index 0000000000..8cb435e01e --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_sentry.go @@ -0,0 +1,99 @@ +//nolint:dupl // Mirrors event_handlers_linear.go / event_handlers_jira.go by design — each watcher source has a small 3-line forwarder onto the shared WatcherDispatchCoordinator. See ADR on WatcherSource pattern. +package orchestrator + +import ( + "context" + "strconv" + "strings" + + "go.uber.org/zap" + + promptcfg "github.com/kandev/kandev/config/prompts" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/sentry" +) + +// SentryService is the subset of sentry.Service the orchestrator needs to +// deduplicate issue→task mappings. Mirrors the Linear / Jira equivalents. +type SentryService interface { + ReserveIssueWatchTask(ctx context.Context, watchID, shortID, issueURL string) (bool, error) + AssignIssueWatchTaskID(ctx context.Context, watchID, shortID, taskID string) error + ReleaseIssueWatchTask(ctx context.Context, watchID, shortID string) error + // DisableIssueWatchWithError is invoked by the dispatch coordinator's + // self-heal flow when the watcher's bound agent profile has been + // soft-deleted. + DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error +} + +// SetSentryService wires the Sentry dedup helpers into the orchestrator so +// sentry-watch handlers can claim issue slots before creating tasks. Also +// (re)builds the cached SentryWatcherSource so handleNewSentryIssue can +// dispatch without allocating per event. +func (s *Service) SetSentryService(sv SentryService) { + s.sentryService = sv + s.sentrySource = NewSentryWatcherSource(sv, s.logger) +} + +// subscribeSentryEvents wires the Sentry event handlers onto the bus. +func (s *Service) subscribeSentryEvents() { + if s.eventBus == nil { + return + } + if _, err := s.eventBus.Subscribe(events.SentryNewIssue, s.handleNewSentryIssue); err != nil { + s.logger.Error("failed to subscribe to sentry.new_issue events", zap.Error(err)) + } +} + +// handleNewSentryIssue translates a SentryNewIssue bus event into a +// dispatchWatcherEvent call. Integration-specific work (reserve, build, +// attach, release, auto-start params) lives in SentryWatcherSource; the +// pipeline (create, auto-start, error/release handling) lives in the +// coordinator; the shared wiring guards (creator/coordinator nil-checks, +// cancellation detachment) live in the dispatchWatcherEvent helper. +func (s *Service) handleNewSentryIssue(ctx context.Context, event *bus.Event) error { + evt, ok := event.Data.(*sentry.NewSentryIssueEvent) + if !ok || evt == nil || evt.Issue == nil { + return nil + } + src := s.sentrySource + if src == nil { + // SetSentryService was never called; fall back to a fail-open + // source so behaviour matches the pre-cache code path. + src = NewSentryWatcherSource(nil, s.logger) + } + s.dispatchWatcherEvent(ctx, "sentry", src, evt, + zap.String("issue_watch_id", evt.IssueWatchID), + zap.String("short_id", evt.Issue.ShortID)) + return nil +} + +// interpolateSentryPrompt replaces {{issue.*}} placeholders with issue fields. +// When the template is empty (user didn't configure a custom prompt), it falls +// back to the embedded default at config/prompts/sentry-issue-watch-default.md +// — same pattern as the linear / jira watchers, so prompt content is editable +// without redeploying. +func interpolateSentryPrompt(template string, i *sentry.SentryIssue) string { + if strings.TrimSpace(template) == "" { + template = promptcfg.Get("sentry-issue-watch-default") + } + project := i.ProjectSlug + if i.ProjectName != "" { + project = i.ProjectName + } + r := strings.NewReplacer( + "{{issue.short_id}}", i.ShortID, + "{{issue.title}}", i.Title, + "{{issue.url}}", i.Permalink, + "{{issue.project}}", project, + "{{issue.level}}", i.Level, + "{{issue.status}}", i.Status, + "{{issue.culprit}}", i.Culprit, + "{{issue.assignee}}", i.AssigneeName, + "{{issue.count}}", i.Count, + "{{issue.user_count}}", strconv.Itoa(i.UserCount), + "{{issue.first_seen}}", i.FirstSeen, + "{{issue.last_seen}}", i.LastSeen, + ) + return r.Replace(template) +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index f626ab0bf1..2124ae6d14 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -301,6 +301,11 @@ type Service struct { // SetLinearService so handlers don't allocate per bus event. linearSource *LinearWatcherSource + // Sentry service for issue watch dedup operations + sentryService SentryService + // sentrySource adapts sentryService onto WatcherSource. Built once in + // SetSentryService so handlers don't allocate per bus event. + sentrySource *SentryWatcherSource // 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 @@ -847,6 +852,9 @@ func (s *Service) Start(ctx context.Context) error { // Subscribe to Linear integration events s.subscribeLinearEvents() + // Subscribe to Sentry integration events + s.subscribeSentryEvents() + // Subscribe to automation events s.subscribeAutomationEvents() diff --git a/apps/backend/internal/orchestrator/source_sentry.go b/apps/backend/internal/orchestrator/source_sentry.go new file mode 100644 index 0000000000..33647ef14e --- /dev/null +++ b/apps/backend/internal/orchestrator/source_sentry.go @@ -0,0 +1,150 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode/utf8" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/sentry" + "github.com/kandev/kandev/internal/task/models" +) + +// maxSentryTitleRunes bounds the issue-title portion of a generated task +// title. Sentry titles are derived from error messages / culprits and can be +// arbitrarily long (unlike Linear/Jira issue titles, which are short upstream), +// so the producer truncates here rather than deferring to the UI. +const maxSentryTitleRunes = 80 + +func truncateSentryTitle(s string) string { + if utf8.RuneCountInString(s) <= maxSentryTitleRunes { + return s + } + return string([]rune(s)[:maxSentryTitleRunes-1]) + "…" +} + +// SentryWatcherSource adapts the Sentry integration onto the WatcherSource +// pipeline. Mirrors LinearWatcherSource so a third integration is a clean +// instance of the abstraction. +type SentryWatcherSource struct { + service SentryService + logger *logger.Logger +} + +// NewSentryWatcherSource constructs a source bound to the orchestrator's +// Sentry service handle. logger may be nil — methods that log will no-op. +func NewSentryWatcherSource(svc SentryService, log *logger.Logger) *SentryWatcherSource { + return &SentryWatcherSource{service: svc, logger: log} +} + +func (s *SentryWatcherSource) Name() string { return "sentry" } + +func (s *SentryWatcherSource) AgentProfileID(evt any) string { + e, ok := evt.(*sentry.NewSentryIssueEvent) + if !ok || e == nil { + return "" + } + return e.AgentProfileID +} + +// SelfHeal disables the sentry_issue_watches row that produced this event. +// Nil-safe: with no SentryService wired the call is silently dropped — same +// pattern as Reserve / Release. +func (s *SentryWatcherSource) SelfHeal(ctx context.Context, evt any, cause string) error { + e, ok := evt.(*sentry.NewSentryIssueEvent) + if !ok || e == nil || s.service == nil { + return nil + } + return s.service.DisableIssueWatchWithError(ctx, e.IssueWatchID, cause) +} + +func (s *SentryWatcherSource) Reserve(ctx context.Context, evt any) (bool, error) { + e, ok := evt.(*sentry.NewSentryIssueEvent) + if !ok || e == nil || e.Issue == nil { + return false, errors.New("sentry source: event payload missing or wrong type") + } + // Matches Linear: nil service is "fail open" so a boot-order corner case + // doesn't drop the event. + if s.service == nil { + return true, nil + } + return s.service.ReserveIssueWatchTask(ctx, e.IssueWatchID, e.Issue.ShortID, e.Issue.Permalink) +} + +func (s *SentryWatcherSource) Release(ctx context.Context, evt any) { + e, ok := evt.(*sentry.NewSentryIssueEvent) + if !ok || e == nil || e.Issue == nil || s.service == nil { + return + } + if err := s.service.ReleaseIssueWatchTask(ctx, e.IssueWatchID, e.Issue.ShortID); err != nil && s.logger != nil { + s.logger.Warn("sentry source: release failed", + zap.String("short_id", e.Issue.ShortID), zap.Error(err)) + } +} + +func (s *SentryWatcherSource) BuildTaskRequest(evt any) (*IssueTaskRequest, error) { + e, ok := evt.(*sentry.NewSentryIssueEvent) + if !ok || e == nil || e.Issue == nil { + return nil, errors.New("sentry source: event payload missing or wrong type") + } + return &IssueTaskRequest{ + WorkspaceID: e.WorkspaceID, + WorkflowID: e.WorkflowID, + WorkflowStepID: e.WorkflowStepID, + Title: fmt.Sprintf("[%s] %s — %s", strings.ToUpper(e.Issue.Level), e.Issue.ShortID, truncateSentryTitle(e.Issue.Title)), + Description: interpolateSentryPrompt(e.Prompt, e.Issue), + Metadata: map[string]interface{}{ + "sentry_issue_watch_id": e.IssueWatchID, + "sentry_issue_short_id": e.Issue.ShortID, + "sentry_issue_url": e.Issue.Permalink, + "sentry_issue_level": e.Issue.Level, + "sentry_issue_status": e.Issue.Status, + "sentry_issue_project": e.Issue.ProjectSlug, + models.MetaKeyAgentProfileID: e.AgentProfileID, + models.MetaKeyExecutorProfileID: e.ExecutorProfileID, + }, + }, nil +} + +func (s *SentryWatcherSource) AttachTaskID(ctx context.Context, evt any, taskID string) error { + e, ok := evt.(*sentry.NewSentryIssueEvent) + if !ok || e == nil || e.Issue == nil || s.service == nil { + return nil + } + return s.service.AssignIssueWatchTaskID(ctx, e.IssueWatchID, e.Issue.ShortID, taskID) +} + +func (s *SentryWatcherSource) AutoStartParams(evt any) AutoStartParams { + e, ok := evt.(*sentry.NewSentryIssueEvent) + if !ok || e == nil { + return AutoStartParams{} + } + return AutoStartParams{ + AgentProfileID: e.AgentProfileID, + ExecutorProfileID: e.ExecutorProfileID, + WorkflowStepID: e.WorkflowStepID, + } +} + +func (s *SentryWatcherSource) WatchID(evt any) string { + e, ok := evt.(*sentry.NewSentryIssueEvent) + if !ok || e == nil { + return "" + } + return e.IssueWatchID +} + +// MaxInflightTasks reports the per-watch cap on open watcher-created tasks, +// carried on the issue event from the watch row. nil = uncapped, letting the +// coordinator apply its default (mirrors the Linear source). +func (s *SentryWatcherSource) MaxInflightTasks(evt any) *int { + e, ok := evt.(*sentry.NewSentryIssueEvent) + if !ok || e == nil { + return nil + } + return e.MaxInflightTasks +} diff --git a/apps/backend/internal/orchestrator/source_sentry_test.go b/apps/backend/internal/orchestrator/source_sentry_test.go new file mode 100644 index 0000000000..e795bdcb4e --- /dev/null +++ b/apps/backend/internal/orchestrator/source_sentry_test.go @@ -0,0 +1,255 @@ +package orchestrator + +import ( + "context" + "errors" + "strings" + "testing" + "unicode/utf8" + + "github.com/kandev/kandev/internal/sentry" +) + +type fakeSentryService struct { + reserveOK bool + reserveErr error + assignErr error + releaseErr error + disableErr error + gotReserve []string + gotAssign []string + gotRelease []string + gotDisable []string +} + +func (f *fakeSentryService) ReserveIssueWatchTask(_ context.Context, watchID, id, _ string) (bool, error) { + f.gotReserve = append(f.gotReserve, watchID+":"+id) + return f.reserveOK, f.reserveErr +} + +func (f *fakeSentryService) AssignIssueWatchTaskID(_ context.Context, watchID, id, taskID string) error { + f.gotAssign = append(f.gotAssign, watchID+":"+id+":"+taskID) + return f.assignErr +} + +func (f *fakeSentryService) ReleaseIssueWatchTask(_ context.Context, watchID, id string) error { + f.gotRelease = append(f.gotRelease, watchID+":"+id) + return f.releaseErr +} + +func (f *fakeSentryService) DisableIssueWatchWithError(_ context.Context, watchID, cause string) error { + f.gotDisable = append(f.gotDisable, watchID+":"+cause) + return f.disableErr +} + +func sampleSentryEvent() *sentry.NewSentryIssueEvent { + return &sentry.NewSentryIssueEvent{ + IssueWatchID: "watch-1", + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + AgentProfileID: "agent-1", + ExecutorProfileID: "exec-1", + Prompt: "Investigate {{issue.short_id}}: {{issue.title}}", + Issue: &sentry.SentryIssue{ + ID: "100", + ShortID: "PROJ-1", + Title: "Boom", + Permalink: "https://sentry.io/issues/PROJ-1", + ProjectSlug: "frontend", + ProjectName: "Frontend", + Level: "error", + Status: "unresolved", + Culprit: "render.tsx", + AssigneeName: "Alice", + Count: "42", + UserCount: 7, + }, + } +} + +func TestSentrySource_Name(t *testing.T) { + src := &SentryWatcherSource{} + if src.Name() != "sentry" { + t.Fatalf("expected name=sentry, got %q", src.Name()) + } +} + +func TestSentrySource_Reserve_Passthrough(t *testing.T) { + svc := &fakeSentryService{reserveOK: true} + src := &SentryWatcherSource{service: svc} + ok, err := src.Reserve(context.Background(), sampleSentryEvent()) + if err != nil || !ok { + t.Fatalf("expected reserve ok, got ok=%v err=%v", ok, err) + } + if len(svc.gotReserve) != 1 || svc.gotReserve[0] != "watch-1:PROJ-1" { + t.Fatalf("unexpected reserve args: %v", svc.gotReserve) + } +} + +func TestSentrySource_Reserve_NilServiceFailOpen(t *testing.T) { + src := &SentryWatcherSource{service: nil} + ok, err := src.Reserve(context.Background(), sampleSentryEvent()) + if err != nil || !ok { + t.Fatalf("expected nil service to fail open, got ok=%v err=%v", ok, err) + } +} + +func TestSentrySource_Reserve_Error(t *testing.T) { + svc := &fakeSentryService{reserveErr: errors.New("boom")} + src := &SentryWatcherSource{service: svc} + ok, err := src.Reserve(context.Background(), sampleSentryEvent()) + if ok { + t.Fatal("expected reserve to fail") + } + if err == nil { + t.Fatal("expected reserve error to surface") + } +} + +func TestSentrySource_Reserve_WrongType(t *testing.T) { + src := &SentryWatcherSource{} + if _, err := src.Reserve(context.Background(), "not an event"); err == nil { + t.Fatal("expected error for wrong event type") + } +} + +func TestSentrySource_BuildTaskRequest(t *testing.T) { + src := &SentryWatcherSource{} + req, err := src.BuildTaskRequest(sampleSentryEvent()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + wantTitle := "[ERROR] PROJ-1 — Boom" + if req.Title != wantTitle { + t.Errorf("title = %q, want %q", req.Title, wantTitle) + } + if req.WorkspaceID != "ws-1" || req.WorkflowID != "wf-1" || req.WorkflowStepID != "step-1" { + t.Errorf("workflow fields wrong: %+v", req) + } + if !strings.Contains(req.Description, "PROJ-1") || !strings.Contains(req.Description, "Boom") { + t.Errorf("prompt interpolation wrong: %q", req.Description) + } + if req.Metadata["sentry_issue_watch_id"] != "watch-1" { + t.Errorf("missing sentry_issue_watch_id metadata") + } + if req.Metadata["sentry_issue_short_id"] != "PROJ-1" { + t.Errorf("missing sentry_issue_short_id metadata") + } + if req.Metadata["sentry_issue_level"] != "error" { + t.Errorf("missing sentry_issue_level metadata") + } + if req.Metadata["agent_profile_id"] != "agent-1" { + t.Errorf("missing agent_profile_id metadata") + } + if req.Metadata["executor_profile_id"] != "exec-1" { + t.Errorf("missing executor_profile_id metadata") + } +} + +func TestSentrySource_BuildTaskRequest_WrongType(t *testing.T) { + src := &SentryWatcherSource{} + if _, err := src.BuildTaskRequest("not an event"); err == nil { + t.Fatal("expected error for wrong event type") + } +} + +func TestSentrySource_BuildTaskRequest_TruncatesLongTitle(t *testing.T) { + src := &SentryWatcherSource{} + evt := sampleSentryEvent() + // Sentry titles come from error messages and can be arbitrarily long. + evt.Issue.Title = strings.Repeat("é", 500) // multibyte to catch byte-slicing bugs + req, err := src.BuildTaskRequest(evt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // The title portion must be bounded at the producer and stay valid UTF-8. + if !utf8.ValidString(req.Title) { + t.Errorf("title is not valid UTF-8 after truncation: %q", req.Title) + } + if !strings.HasSuffix(req.Title, "…") { + t.Errorf("expected truncation ellipsis, got %q", req.Title) + } + if got := utf8.RuneCountInString(req.Title); got > 100 { + t.Errorf("title not bounded: %d runes", got) + } +} + +func TestSentrySource_AttachTaskID(t *testing.T) { + svc := &fakeSentryService{} + src := &SentryWatcherSource{service: svc} + if err := src.AttachTaskID(context.Background(), sampleSentryEvent(), "task-9"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(svc.gotAssign) != 1 || svc.gotAssign[0] != "watch-1:PROJ-1:task-9" { + t.Fatalf("unexpected assign args: %v", svc.gotAssign) + } +} + +func TestSentrySource_Release(t *testing.T) { + svc := &fakeSentryService{} + src := &SentryWatcherSource{service: svc} + src.Release(context.Background(), sampleSentryEvent()) + if len(svc.gotRelease) != 1 || svc.gotRelease[0] != "watch-1:PROJ-1" { + t.Fatalf("unexpected release args: %v", svc.gotRelease) + } +} + +func TestSentrySource_Release_ErrorIsLoggedNotPropagated(t *testing.T) { + svc := &fakeSentryService{releaseErr: errors.New("dedup store down")} + src := &SentryWatcherSource{service: svc, logger: nopLogger(t)} + src.Release(context.Background(), sampleSentryEvent()) + if len(svc.gotRelease) != 1 { + t.Fatalf("expected release call to be attempted, got %d", len(svc.gotRelease)) + } +} + +func TestSentrySource_AutoStartParams(t *testing.T) { + src := &SentryWatcherSource{} + p := src.AutoStartParams(sampleSentryEvent()) + if p.AgentProfileID != "agent-1" || p.ExecutorProfileID != "exec-1" { + t.Fatalf("unexpected auto-start params: %+v", p) + } + if p.WorkflowStepID != "step-1" { + t.Errorf("step id wrong: %q", p.WorkflowStepID) + } +} + +func TestSentrySource_AgentProfileID(t *testing.T) { + src := &SentryWatcherSource{} + if got := src.AgentProfileID(sampleSentryEvent()); got != "agent-1" { + t.Fatalf("AgentProfileID = %q, want agent-1", got) + } + if got := src.AgentProfileID("not an event"); got != "" { + t.Errorf("AgentProfileID for wrong type = %q, want empty", got) + } +} + +func TestSentrySource_SelfHeal_Passthrough(t *testing.T) { + svc := &fakeSentryService{} + src := &SentryWatcherSource{service: svc} + if err := src.SelfHeal(context.Background(), sampleSentryEvent(), "agent profile deleted"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(svc.gotDisable) != 1 || svc.gotDisable[0] != "watch-1:agent profile deleted" { + t.Fatalf("unexpected disable args: %v", svc.gotDisable) + } +} + +func TestSentrySource_SelfHeal_NilServiceNoop(t *testing.T) { + src := &SentryWatcherSource{service: nil} + if err := src.SelfHeal(context.Background(), sampleSentryEvent(), "cause"); err != nil { + t.Fatalf("expected nil service to no-op, got %v", err) + } +} + +func TestSentrySource_SelfHeal_WrongType(t *testing.T) { + svc := &fakeSentryService{} + src := &SentryWatcherSource{service: svc} + if err := src.SelfHeal(context.Background(), "not an event", "cause"); err != nil { + t.Fatalf("expected wrong type to no-op, got %v", err) + } + if len(svc.gotDisable) != 0 { + t.Fatalf("expected no disable call for wrong type, got %v", svc.gotDisable) + } +} diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index 2ab4a0c5fc..3317806052 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -78,6 +78,10 @@ mocks: prod: "" dev: "" e2e: "true" + KANDEV_MOCK_SENTRY: + prod: "" + dev: "" + e2e: "true" # ── Debug / observability ───────────────────────────────────────── # Production keeps these off so /debug/* endpoints and pprof aren't diff --git a/apps/backend/internal/sentry/client.go b/apps/backend/internal/sentry/client.go new file mode 100644 index 0000000000..79f1cebe2c --- /dev/null +++ b/apps/backend/internal/sentry/client.go @@ -0,0 +1,32 @@ +package sentry + +import ( + "context" + "errors" + "fmt" +) + +// ErrNotConfigured is returned when a Sentry operation is attempted without a +// stored configuration. +var ErrNotConfigured = errors.New("sentry: not configured") + +// Client is the minimal interface the service needs from a Sentry backend. The +// real implementation is RESTClient; tests can substitute a fake. +type Client interface { + TestAuth(ctx context.Context) (*TestConnectionResult, error) + ListOrganizations(ctx context.Context) ([]SentryOrganization, error) + ListProjects(ctx context.Context) ([]SentryProject, error) + SearchIssues(ctx context.Context, filter SearchFilter, cursor string) (*SearchResult, error) + GetIssue(ctx context.Context, idOrShortID string) (*SentryIssue, error) +} + +// APIError captures an upstream non-2xx response so handlers can surface a +// meaningful status to the UI. +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("sentry api: status %d: %s", e.StatusCode, e.Message) +} diff --git a/apps/backend/internal/sentry/handlers.go b/apps/backend/internal/sentry/handlers.go new file mode 100644 index 0000000000..236c73b976 --- /dev/null +++ b/apps/backend/internal/sentry/handlers.go @@ -0,0 +1,179 @@ +package sentry + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// RegisterRoutes wires the Sentry HTTP handlers. The dispatcher parameter is +// accepted for signature parity with other integrations; Phase 1 has no +// WebSocket surface, so it is intentionally unused. +func RegisterRoutes(router *gin.Engine, _ *ws.Dispatcher, svc *Service, log *logger.Logger) { + ctrl := &Controller{service: svc, logger: log} + ctrl.RegisterHTTPRoutes(router) +} + +// Controller holds HTTP route handlers for the Sentry integration. +type Controller struct { + service *Service + logger *logger.Logger +} + +// RegisterHTTPRoutes attaches the Sentry HTTP endpoints to router. +func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { + api := router.Group("/api/v1/sentry") + api.GET("/config", c.httpGetConfig) + api.PUT("/config", c.httpSetConfig) + api.DELETE("/config", c.httpDeleteConfig) + api.POST("/config/test", c.httpTestConfig) + api.GET("/organizations", c.httpListOrganizations) + api.GET("/projects", c.httpListProjects) + api.GET("/issues", c.httpSearchIssues) + api.GET("/issues/:id", c.httpGetIssue) + c.registerIssueWatchRoutes(api) +} + +func (c *Controller) httpGetConfig(ctx *gin.Context) { + cfg, err := c.service.GetConfig(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if cfg == nil { + ctx.Status(http.StatusNoContent) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpSetConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrInvalidConfig) { + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpDeleteConfig(ctx *gin.Context) { + if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTestConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + result, err := c.service.TestConnection(ctx.Request.Context(), &req) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpListOrganizations(ctx *gin.Context) { + organizations, err := c.service.ListOrganizations(ctx.Request.Context()) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"organizations": organizations}) +} + +func (c *Controller) httpListProjects(ctx *gin.Context) { + projects, err := c.service.ListProjects(ctx.Request.Context()) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"projects": projects}) +} + +func (c *Controller) httpSearchIssues(ctx *gin.Context) { + q := ctx.Request.URL.Query() + filter := SearchFilter{ + OrgSlug: q.Get("orgSlug"), + ProjectSlug: q.Get("projectSlug"), + Environment: q.Get("environment"), + Query: q.Get("query"), + StatsPeriod: q.Get("statsPeriod"), + Levels: trimAll(q["level"]), + Statuses: trimAll(q["status"]), + } + cursor := q.Get("cursor") + result, err := c.service.SearchIssues(ctx.Request.Context(), filter, cursor) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) httpGetIssue(ctx *gin.Context) { + id := ctx.Param("id") + issue, err := c.service.GetIssue(ctx.Request.Context(), id) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, issue) +} + +// errCodeSentryNotConfigured is the wire-level code surfaced to the UI when +// Sentry has no saved credentials. +const errCodeSentryNotConfigured = "SENTRY_NOT_CONFIGURED" + +func (c *Controller) writeClientError(ctx *gin.Context, err error) { + if errors.Is(err, ErrNotConfigured) { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Sentry is not configured", + "code": errCodeSentryNotConfigured, + }) + return + } + var apiErr *APIError + if errors.As(err, &apiErr) { + status := http.StatusInternalServerError + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: + status = apiErr.StatusCode + } + ctx.JSON(status, gin.H{"error": apiErr.Error()}) + return + } + c.logger.Warn("sentry handler error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +func trimAll(xs []string) []string { + out := make([]string, 0, len(xs)) + for _, x := range xs { + x = strings.TrimSpace(x) + if x != "" { + out = append(out, x) + } + } + return out +} diff --git a/apps/backend/internal/sentry/handlers_issue_watch.go b/apps/backend/internal/sentry/handlers_issue_watch.go new file mode 100644 index 0000000000..331ac71e6f --- /dev/null +++ b/apps/backend/internal/sentry/handlers_issue_watch.go @@ -0,0 +1,160 @@ +package sentry + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// registerIssueWatchRoutes attaches the Sentry issue-watch HTTP endpoints to +// the existing /api/v1/sentry route group. +func (c *Controller) registerIssueWatchRoutes(api *gin.RouterGroup) { + api.GET("/watches/issue", c.httpListIssueWatches) + api.POST("/watches/issue", c.httpCreateIssueWatch) + api.GET("/watches/issue/:id", c.httpGetIssueWatch) + api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch) + api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch) + api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch) +} + +// httpListIssueWatches returns watches scoped to one workspace when +// `workspace_id` is supplied, or every watch across all workspaces when it is +// absent (used by the install-wide settings page). +func (c *Controller) httpListIssueWatches(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + var ( + watches []*IssueWatch + err error + ) + if workspaceID == "" { + watches, err = c.service.ListAllIssueWatches(ctx.Request.Context()) + } else { + watches, err = c.service.ListIssueWatches(ctx.Request.Context(), workspaceID) + } + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) +} + +func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) { + var req CreateIssueWatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + w, err := c.service.CreateIssueWatch(ctx.Request.Context(), &req) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + ctx.JSON(http.StatusOK, w) +} + +func (c *Controller) httpGetIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + w, err := c.service.GetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + if !workspaceMatches(ctx, w.WorkspaceID) { + ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()}) + return + } + ctx.JSON(http.StatusOK, w) +} + +func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.assertWatchInWorkspace(ctx, id) { + return + } + var req UpdateIssueWatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + w, err := c.service.UpdateIssueWatch(ctx.Request.Context(), id, &req) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + ctx.JSON(http.StatusOK, w) +} + +func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.assertWatchInWorkspace(ctx, id) { + return + } + if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +// httpTriggerIssueWatch runs a single immediate poll, ignoring the per-watch +// interval gating. Publishes events for each unseen issue and returns the +// count so the user gets feedback without waiting for the orchestrator to fan +// tasks out. +func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + w, err := c.service.GetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + if !workspaceMatches(ctx, w.WorkspaceID) { + ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()}) + return + } + issues, err := c.service.CheckIssueWatch(ctx.Request.Context(), w) + if err != nil { + c.writeClientError(ctx, err) + return + } + for _, issue := range issues { + c.service.publishNewSentryIssueEvent(ctx.Request.Context(), w, issue) + } + ctx.JSON(http.StatusOK, gin.H{"published": len(issues)}) +} + +// assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the +// caller must supply `?workspace_id=...` matching the watch's workspace. +// Writes the response and returns false on mismatch. +func (c *Controller) assertWatchInWorkspace(ctx *gin.Context, id string) bool { + w, err := c.service.GetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeIssueWatchError(ctx, err) + return false + } + if !workspaceMatches(ctx, w.WorkspaceID) { + ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()}) + return false + } + return true +} + +// workspaceMatches returns true when the request's `workspace_id` query +// matches the resource's stored workspace. Empty query is rejected so callers +// can't bypass the check by omitting the parameter. +func workspaceMatches(ctx *gin.Context, resourceWorkspace string) bool { + q := ctx.Query("workspace_id") + return q != "" && q == resourceWorkspace +} + +func (c *Controller) writeIssueWatchError(ctx *gin.Context, err error) { + if errors.Is(err, ErrIssueWatchNotFound) { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, ErrInvalidConfig) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.writeClientError(ctx, err) +} diff --git a/apps/backend/internal/sentry/handlers_test.go b/apps/backend/internal/sentry/handlers_test.go new file mode 100644 index 0000000000..fa3d44bbcc --- /dev/null +++ b/apps/backend/internal/sentry/handlers_test.go @@ -0,0 +1,225 @@ +package sentry + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func newTestController(t *testing.T) (*Controller, *gin.Engine, *fakeClient) { + t.Helper() + store := newTestStore(t) + secrets := newFakeSecretStore() + client := &fakeClient{} + svc := NewService(store, secrets, func(_ *SentryConfig, _ string) Client { + return client + }, logger.Default()) + gin.SetMode(gin.TestMode) + router := gin.New() + ctrl := &Controller{service: svc, logger: logger.Default()} + ctrl.RegisterHTTPRoutes(router) + return ctrl, router, client +} + +// seedConfig persists a singleton config + secret without going through +// Service.SetConfig (which fires an async probe we'd have to drain). +func seedConfig(t *testing.T, ctrl *Controller) { + t.Helper() + ctx := context.Background() + if err := ctrl.service.store.UpsertConfig(ctx, &SentryConfig{ + AuthMethod: AuthMethodAuthToken, + }); err != nil { + t.Fatalf("upsert: %v", err) + } + if err := ctrl.service.secrets.Set(ctx, SecretKey, "sentry", "tok"); err != nil { + t.Fatalf("set secret: %v", err) + } +} + +func TestHTTPGetConfig_NoConfig_Returns204(t *testing.T) { + _, router, _ := newTestController(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/sentry/config", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", w.Code) + } +} + +func TestHTTPGetConfig_ReturnsConfig(t *testing.T) { + ctrl, router, _ := newTestController(t) + seedConfig(t, ctrl) + req := httptest.NewRequest(http.MethodGet, "/api/v1/sentry/config", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + var cfg SentryConfig + if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil { + t.Fatalf("decode: %v", err) + } + if cfg.AuthMethod != AuthMethodAuthToken || !cfg.HasSecret { + t.Errorf("config = %+v", cfg) + } +} + +func TestHTTPSearchIssues_MultiValueLevelsAndStatuses(t *testing.T) { + ctrl, router, client := newTestController(t) + seedConfig(t, ctrl) + var seen SearchFilter + client.searchIssuesFn = func(filter SearchFilter, _ string) (*SearchResult, error) { + seen = filter + return &SearchResult{IsLast: true}, nil + } + req := httptest.NewRequest(http.MethodGet, + "/api/v1/sentry/issues?orgSlug=acme&projectSlug=fe&level=error&level=fatal&status=unresolved&query=boom&statsPeriod=24h", + nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", w.Code, w.Body.String()) + } + if seen.OrgSlug != "acme" || seen.ProjectSlug != "fe" || seen.Query != "boom" || seen.StatsPeriod != "24h" { + t.Errorf("filter = %+v", seen) + } + if !sameStrings(seen.Levels, []string{"error", "fatal"}) { + t.Errorf("levels = %v", seen.Levels) + } + if !sameStrings(seen.Statuses, []string{"unresolved"}) { + t.Errorf("statuses = %v", seen.Statuses) + } +} + +func TestHTTPGetIssue_ForwardsID(t *testing.T) { + ctrl, router, client := newTestController(t) + seedConfig(t, ctrl) + var seenID string + client.getIssueFn = func(id string) (*SentryIssue, error) { + seenID = id + return &SentryIssue{ID: "99", ShortID: id}, nil + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/sentry/issues/PROJ-7", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d", w.Code) + } + if seenID != "PROJ-7" { + t.Errorf("id forwarded = %q", seenID) + } +} + +func TestHTTPListProjects_NotConfigured_Returns503(t *testing.T) { + _, router, _ := newTestController(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/sentry/projects", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", w.Code) + } + if !strings.Contains(w.Body.String(), `"code":"SENTRY_NOT_CONFIGURED"`) { + t.Errorf("missing code in body: %s", w.Body.String()) + } +} + +func TestHTTPListOrganizations_ReturnsOrganizations(t *testing.T) { + ctrl, router, client := newTestController(t) + seedConfig(t, ctrl) + client.listOrganizationsFn = func() ([]SentryOrganization, error) { + return []SentryOrganization{{ID: "1", Slug: "acme", Name: "Acme"}}, nil + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/sentry/organizations", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", w.Code, w.Body.String()) + } + var resp struct { + Organizations []SentryOrganization `json:"organizations"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Organizations) != 1 || resp.Organizations[0].Slug != "acme" { + t.Errorf("organizations = %+v", resp.Organizations) + } +} + +func TestHTTPListOrganizations_NotConfigured_Returns503(t *testing.T) { + _, router, _ := newTestController(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/sentry/organizations", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", w.Code) + } + if !strings.Contains(w.Body.String(), `"code":"SENTRY_NOT_CONFIGURED"`) { + t.Errorf("missing code in body: %s", w.Body.String()) + } +} + +func TestHTTPSetConfig_BadJSON(t *testing.T) { + _, router, _ := newTestController(t) + req := httptest.NewRequest(http.MethodPut, "/api/v1/sentry/config", strings.NewReader("not-json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestWriteClientError_APIErrorStatusPassthrough(t *testing.T) { + cases := []struct { + upstream int + want int + }{ + {http.StatusUnauthorized, http.StatusUnauthorized}, + {http.StatusForbidden, http.StatusForbidden}, + {http.StatusNotFound, http.StatusNotFound}, + {http.StatusBadRequest, http.StatusBadRequest}, + {http.StatusBadGateway, http.StatusInternalServerError}, + } + ctrl, _, _ := newTestController(t) + for _, tc := range cases { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + ctrl.writeClientError(c, &APIError{StatusCode: tc.upstream, Message: "x"}) + if w.Code != tc.want { + t.Errorf("upstream %d → got %d, want %d", tc.upstream, w.Code, tc.want) + } + } +} + +func TestWriteClientError_GenericError(t *testing.T) { + ctrl, _, _ := newTestController(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + ctrl.writeClientError(c, errors.New("boom")) + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", w.Code) + } +} + +func TestRegisterRoutes_AcceptsNilDispatcher(t *testing.T) { + // Sanity check that RegisterRoutes can be called with a real dispatcher + // without registering any WS handlers (Phase 1 has no WS surface). + gin.SetMode(gin.TestMode) + router := gin.New() + store := newTestStore(t) + svc := NewService(store, newFakeSecretStore(), func(_ *SentryConfig, _ string) Client { + return &fakeClient{} + }, logger.Default()) + dispatcher := ws.NewDispatcher() + RegisterRoutes(router, dispatcher, svc, logger.Default()) +} diff --git a/apps/backend/internal/sentry/mock_client.go b/apps/backend/internal/sentry/mock_client.go new file mode 100644 index 0000000000..49f24654e7 --- /dev/null +++ b/apps/backend/internal/sentry/mock_client.go @@ -0,0 +1,206 @@ +package sentry + +import ( + "context" + "net/http" + "strings" + "sync" +) + +// MockClient implements Client with in-memory data for E2E tests. A single +// shared instance per process, driven by HTTP control routes mounted in mock +// mode. +type MockClient struct { + mu sync.RWMutex + authResult *TestConnectionResult + organizations []SentryOrganization + projects []SentryProject + issues map[string]*SentryIssue // short ID → issue + // issueOrder preserves insertion order so SearchIssues returns a stable + // sequence — Go map range is randomised. + issueOrder []string + getError *APIError +} + +// NewMockClient seeds a default-success TestAuth so a fresh config flips to +// "Authenticated" without explicit seeding. +func NewMockClient() *MockClient { + return &MockClient{ + authResult: &TestConnectionResult{ + OK: true, + UserID: "mock-user", + DisplayName: "Mock User", + Email: "mock@example.com", + }, + issues: make(map[string]*SentryIssue), + } +} + +// --- Client interface --- + +func (m *MockClient) TestAuth(context.Context) (*TestConnectionResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.authResult == nil { + return &TestConnectionResult{OK: false, Error: "mock: no auth result configured"}, nil + } + r := *m.authResult + return &r, nil +} + +func (m *MockClient) ListOrganizations(context.Context) ([]SentryOrganization, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]SentryOrganization, len(m.organizations)) + copy(out, m.organizations) + return out, nil +} + +func (m *MockClient) ListProjects(context.Context) ([]SentryProject, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]SentryProject, len(m.projects)) + copy(out, m.projects) + return out, nil +} + +func (m *MockClient) SearchIssues(_ context.Context, filter SearchFilter, _ string) (*SearchResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]SentryIssue, 0, len(m.issueOrder)) + for _, id := range m.issueOrder { + issue, ok := m.issues[id] + if !ok { + continue + } + if !mockMatchesFilter(issue, filter) { + continue + } + out = append(out, *issue) + } + return &SearchResult{Issues: out, IsLast: true}, nil +} + +func (m *MockClient) GetIssue(_ context.Context, idOrShortID string) (*SentryIssue, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.getError != nil { + err := *m.getError + return nil, &err + } + if issue, ok := m.issues[idOrShortID]; ok { + cp := *issue + return &cp, nil + } + // Fall back to numeric ID lookup so callers can use either form. + for _, i := range m.issues { + if i.ID == idOrShortID { + cp := *i + return &cp, nil + } + } + return nil, &APIError{StatusCode: http.StatusNotFound, Message: "issue not found: " + idOrShortID} +} + +// mockMatchesFilter applies the filter predicates that map to a per-issue +// field, so E2E tests can assert the backend forwarded the filter rather than +// silently returning everything. Deliberately NOT enforced (no per-issue +// analog on SentryIssue): OrgSlug (the mock's project list is one tenant), +// Environment (issues carry no environment in our projection), and StatsPeriod +// (a relative time window, not an issue attribute). The real REST client's +// URL building for those params is covered in rest_client_test.go. +func mockMatchesFilter(issue *SentryIssue, f SearchFilter) bool { + if f.ProjectSlug != "" && issue.ProjectSlug != f.ProjectSlug { + return false + } + if len(f.Levels) > 0 && !containsFold(f.Levels, issue.Level) { + return false + } + if len(f.Statuses) > 0 && !containsFold(f.Statuses, issue.Status) { + return false + } + if q := strings.TrimSpace(f.Query); q != "" { + if !strings.Contains(strings.ToLower(issue.Title), strings.ToLower(q)) { + return false + } + } + return true +} + +func containsFold(xs []string, target string) bool { + for _, x := range xs { + if strings.EqualFold(x, target) { + return true + } + } + return false +} + +// --- Setters used by MockController --- + +func (m *MockClient) SetAuthResult(r *TestConnectionResult) { + m.mu.Lock() + defer m.mu.Unlock() + m.authResult = r +} + +func (m *MockClient) SetOrganizations(organizations []SentryOrganization) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]SentryOrganization, len(organizations)) + copy(cp, organizations) + m.organizations = cp +} + +func (m *MockClient) SetProjects(projects []SentryProject) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]SentryProject, len(projects)) + copy(cp, projects) + m.projects = cp +} + +func (m *MockClient) AddIssue(issue *SentryIssue) { + m.mu.Lock() + defer m.mu.Unlock() + cp := *issue + key := issue.ShortID + if key == "" { + key = issue.ID + } + if _, exists := m.issues[key]; !exists { + m.issueOrder = append(m.issueOrder, key) + } + m.issues[key] = &cp +} + +func (m *MockClient) SetGetIssueError(err *APIError) { + m.mu.Lock() + defer m.mu.Unlock() + m.getError = err +} + +// Reset clears every seeded value back to defaults. +func (m *MockClient) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.authResult = &TestConnectionResult{ + OK: true, + UserID: "mock-user", + DisplayName: "Mock User", + Email: "mock@example.com", + } + m.organizations = nil + m.projects = nil + m.issues = make(map[string]*SentryIssue) + m.issueOrder = nil + m.getError = nil +} + +// MockClientFactory returns a ClientFactory that always hands back the shared +// MockClient regardless of credentials. +func MockClientFactory(shared *MockClient) ClientFactory { + return func(*SentryConfig, string) Client { + return shared + } +} diff --git a/apps/backend/internal/sentry/mock_client_test.go b/apps/backend/internal/sentry/mock_client_test.go new file mode 100644 index 0000000000..a1a47155a9 --- /dev/null +++ b/apps/backend/internal/sentry/mock_client_test.go @@ -0,0 +1,114 @@ +package sentry + +import ( + "context" + "net/http" + "testing" +) + +func TestMockClient_DefaultsToSuccessfulAuth(t *testing.T) { + m := NewMockClient() + res, err := m.TestAuth(context.Background()) + if err != nil || !res.OK { + t.Fatalf("expected OK=true by default, got %+v err=%v", res, err) + } +} + +func TestMockClient_SearchFiltersByProject(t *testing.T) { + m := NewMockClient() + m.AddIssue(&SentryIssue{ShortID: "FE-1", Title: "Boom", Level: "error", Status: "unresolved", ProjectSlug: "frontend"}) + m.AddIssue(&SentryIssue{ShortID: "BE-1", Title: "Crash", Level: "fatal", Status: "unresolved", ProjectSlug: "backend"}) + + res, err := m.SearchIssues(context.Background(), SearchFilter{ + OrgSlug: "acme", ProjectSlug: "frontend", + }, "") + if err != nil { + t.Fatalf("search: %v", err) + } + if len(res.Issues) != 1 || res.Issues[0].ShortID != "FE-1" { + t.Errorf("expected only FE-1, got %+v", res.Issues) + } +} + +func TestMockClient_SearchFiltersByLevelStatusQuery(t *testing.T) { + m := NewMockClient() + m.AddIssue(&SentryIssue{ShortID: "A-1", Title: "Login failed", Level: "error", Status: "unresolved", ProjectSlug: "fe"}) + m.AddIssue(&SentryIssue{ShortID: "A-2", Title: "Signup failed", Level: "warning", Status: "unresolved", ProjectSlug: "fe"}) + m.AddIssue(&SentryIssue{ShortID: "A-3", Title: "Login broken", Level: "error", Status: "resolved", ProjectSlug: "fe"}) + + cases := []struct { + name string + filter SearchFilter + want []string + }{ + {"levels", SearchFilter{OrgSlug: "acme", Levels: []string{"error"}}, []string{"A-1", "A-3"}}, + {"statuses", SearchFilter{OrgSlug: "acme", Statuses: []string{"resolved"}}, []string{"A-3"}}, + {"query", SearchFilter{OrgSlug: "acme", Query: "Login"}, []string{"A-1", "A-3"}}, + {"combined", SearchFilter{OrgSlug: "acme", Levels: []string{"error"}, Statuses: []string{"unresolved"}}, []string{"A-1"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := m.SearchIssues(context.Background(), tc.filter, "") + if err != nil { + t.Fatalf("search: %v", err) + } + got := make([]string, 0, len(res.Issues)) + for _, i := range res.Issues { + got = append(got, i.ShortID) + } + if !sameStrings(got, tc.want) { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestMockClient_GetIssueByShortIDOrNumeric(t *testing.T) { + m := NewMockClient() + m.AddIssue(&SentryIssue{ID: "99", ShortID: "PROJ-1", Title: "x"}) + if _, err := m.GetIssue(context.Background(), "PROJ-1"); err != nil { + t.Errorf("short id lookup failed: %v", err) + } + if _, err := m.GetIssue(context.Background(), "99"); err != nil { + t.Errorf("numeric id lookup failed: %v", err) + } + if _, err := m.GetIssue(context.Background(), "missing"); err == nil { + t.Error("expected error on missing id") + } +} + +func TestMockClient_GetIssueErrorOverride(t *testing.T) { + m := NewMockClient() + m.AddIssue(&SentryIssue{ShortID: "PROJ-1"}) + m.SetGetIssueError(&APIError{StatusCode: http.StatusInternalServerError, Message: "boom"}) + if _, err := m.GetIssue(context.Background(), "PROJ-1"); err == nil { + t.Error("expected forced error") + } +} + +func TestMockClient_Reset(t *testing.T) { + m := NewMockClient() + m.AddIssue(&SentryIssue{ShortID: "PROJ-1"}) + m.SetProjects([]SentryProject{{Slug: "fe"}}) + m.Reset() + res, _ := m.SearchIssues(context.Background(), SearchFilter{OrgSlug: "x"}, "") + if len(res.Issues) != 0 { + t.Error("expected issues cleared") + } + projects, _ := m.ListProjects(context.Background()) + if len(projects) != 0 { + t.Error("expected projects cleared") + } +} + +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/apps/backend/internal/sentry/mock_controller.go b/apps/backend/internal/sentry/mock_controller.go new file mode 100644 index 0000000000..3983cf9bc8 --- /dev/null +++ b/apps/backend/internal/sentry/mock_controller.go @@ -0,0 +1,124 @@ +package sentry + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" +) + +// MockController exposes HTTP endpoints that drive the in-memory MockClient +// during E2E tests. Mounted by RegisterMockRoutes only when the service was +// built with a MockClient. +type MockController struct { + mock *MockClient + store *Store + log *logger.Logger +} + +// NewMockController wires the controller to the mock + store so auth-health +// writes can be triggered synchronously from tests. +func NewMockController(mock *MockClient, store *Store, log *logger.Logger) *MockController { + return &MockController{mock: mock, store: store, log: log} +} + +// RegisterRoutes mounts the mock control routes under /api/v1/sentry/mock. +func (c *MockController) RegisterRoutes(router *gin.Engine) { + api := router.Group("/api/v1/sentry/mock") + api.PUT("/auth-result", c.setAuthResult) + api.PUT("/auth-health", c.setAuthHealth) + api.POST("/organizations", c.setOrganizations) + api.POST("/projects", c.setProjects) + api.POST("/issues", c.addIssues) + api.PUT("/get-issue-error", c.setGetIssueError) + api.DELETE("/reset", c.reset) +} + +func (c *MockController) setAuthResult(ctx *gin.Context) { + var req TestConnectionResult + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + r := req + c.mock.SetAuthResult(&r) + ctx.JSON(http.StatusOK, gin.H{"set": true}) +} + +func (c *MockController) setAuthHealth(ctx *gin.Context) { + var req struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if err := c.store.UpdateAuthHealth(ctx.Request.Context(), req.OK, req.Error, time.Now().UTC()); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"set": true}) +} + +func (c *MockController) setOrganizations(ctx *gin.Context) { + var req struct { + Organizations []SentryOrganization `json:"organizations"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.SetOrganizations(req.Organizations) + ctx.JSON(http.StatusOK, gin.H{"count": len(req.Organizations)}) +} + +func (c *MockController) setProjects(ctx *gin.Context) { + var req struct { + Projects []SentryProject `json:"projects"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + c.mock.SetProjects(req.Projects) + ctx.JSON(http.StatusOK, gin.H{"count": len(req.Projects)}) +} + +func (c *MockController) addIssues(ctx *gin.Context) { + var req struct { + Issues []SentryIssue `json:"issues"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + for i := range req.Issues { + c.mock.AddIssue(&req.Issues[i]) + } + ctx.JSON(http.StatusOK, gin.H{"added": len(req.Issues)}) +} + +func (c *MockController) setGetIssueError(ctx *gin.Context) { + var req struct { + StatusCode int `json:"statusCode"` + Message string `json:"message"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if req.StatusCode == 0 { + c.mock.SetGetIssueError(nil) + } else { + c.mock.SetGetIssueError(&APIError{StatusCode: req.StatusCode, Message: req.Message}) + } + ctx.JSON(http.StatusOK, gin.H{"set": true}) +} + +func (c *MockController) reset(ctx *gin.Context) { + c.mock.Reset() + ctx.JSON(http.StatusOK, gin.H{"reset": true}) +} diff --git a/apps/backend/internal/sentry/models.go b/apps/backend/internal/sentry/models.go new file mode 100644 index 0000000000..631e17f744 --- /dev/null +++ b/apps/backend/internal/sentry/models.go @@ -0,0 +1,210 @@ +// Package sentry implements the Sentry integration (Phase 1: configure + browse). +// A single install-wide configuration, a REST client for projects and issues, +// and the HTTP handlers that expose these capabilities to the frontend. +package sentry + +import ( + "time" + + "github.com/kandev/kandev/internal/integrations/optional" +) + +// AuthMethodAuthToken is the only auth method Sentry supports in Phase 1: a +// user or organization auth token sent as `Authorization: Bearer `. +const AuthMethodAuthToken = "auth_token" + +// SecretKey is the secret-store key used for the install-wide Sentry token. +const SecretKey = "sentry:singleton:token" + +// SentryConfig is the install-wide configuration for the Sentry integration. +// The token is stored separately in the encrypted secret store under SecretKey. +type SentryConfig struct { + AuthMethod string `json:"authMethod" db:"auth_method"` + HasSecret bool `json:"hasSecret" db:"-"` + // LastCheckedAt / LastOk / LastError are written by the background auth + // poller so the UI can render a "connected/disconnected + checked Xs ago" + // indicator without doing its own probing. + LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty" db:"last_checked_at"` + LastOk bool `json:"lastOk" db:"last_ok"` + LastError string `json:"lastError,omitempty" db:"last_error"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// SetConfigRequest is the payload sent by the UI to create or update the +// Sentry configuration. When Secret is empty on update, the existing secret +// is retained; when non-empty it replaces the stored value. +type SetConfigRequest struct { + AuthMethod string `json:"authMethod"` + Secret string `json:"secret"` +} + +// TestConnectionResult reports what the backend learned when pinging Sentry +// with the supplied credentials. +type TestConnectionResult struct { + OK bool `json:"ok"` + UserID string `json:"userId,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + Error string `json:"error,omitempty"` +} + +// SentryOrganization is the minimal shape used by the organization selector on +// the settings page. +type SentryOrganization struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` +} + +// SentryProject is the minimal shape used by the project selector on the +// settings page and by the issue browser to scope searches. +type SentryProject struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + OrgSlug string `json:"orgSlug"` +} + +// SentryIssue is the subset of Sentry's issue payload Kandev consumes. +type SentryIssue struct { + ID string `json:"id"` + ShortID string `json:"shortId"` + Title string `json:"title"` + Culprit string `json:"culprit,omitempty"` + Permalink string `json:"permalink"` + ProjectSlug string `json:"projectSlug"` + ProjectName string `json:"projectName,omitempty"` + Level string `json:"level"` + Status string `json:"status"` + // Count is returned by Sentry as a string ("1234"), preserved as-is. + Count string `json:"count,omitempty"` + UserCount int `json:"userCount,omitempty"` + FirstSeen string `json:"firstSeen,omitempty"` + LastSeen string `json:"lastSeen,omitempty"` + AssigneeName string `json:"assigneeName,omitempty"` +} + +// SearchFilter is a structured search filter used by SearchIssues. Sentry +// expresses level/status filters by appending tokens to the free-text `query` +// param; we expose them as structured fields so the UI can render multi-select +// chips and the backend builds the right query string. +type SearchFilter struct { + OrgSlug string `json:"orgSlug"` + ProjectSlug string `json:"projectSlug,omitempty"` + Environment string `json:"environment,omitempty"` + Levels []string `json:"levels,omitempty"` + Statuses []string `json:"statuses,omitempty"` + Query string `json:"query,omitempty"` + StatsPeriod string `json:"statsPeriod,omitempty"` +} + +// SearchResult is a page of issues from a search. Sentry uses opaque cursors +// embedded in the Link header for pagination; NextPageToken carries that +// cursor verbatim so the next call can pass it back as `?cursor=...`. +type SearchResult struct { + Issues []SentryIssue `json:"issues"` + NextPageToken string `json:"nextPageToken,omitempty"` + IsLast bool `json:"isLast"` +} + +// DefaultIssueWatchPollInterval is the polling cadence assigned to a watcher +// when the caller does not specify one. Five minutes balances freshness +// against Sentry rate limits when many watches are configured. +const DefaultIssueWatchPollInterval = 300 + +// MinIssueWatchPollInterval / MaxIssueWatchPollInterval bound the per-watch +// search re-run cadence. +const ( + MinIssueWatchPollInterval = 60 + MaxIssueWatchPollInterval = 3600 +) + +// IssueWatch persists a Sentry issue-search filter that becomes a kandev task +// whenever a new matching issue appears. Mirrors the Linear/Jira shape so the +// orchestrator's WatcherSource pipeline applies uniformly. +type IssueWatch struct { + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + WorkflowID string `json:"workflowId" db:"workflow_id"` + WorkflowStepID string `json:"workflowStepId" db:"workflow_step_id"` + Filter SearchFilter `json:"filter"` + AgentProfileID string `json:"agentProfileId" db:"agent_profile_id"` + ExecutorProfileID string `json:"executorProfileId" db:"executor_profile_id"` + Prompt string `json:"prompt" db:"prompt"` + Enabled bool `json:"enabled" db:"enabled"` + PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` + // MaxInflightTasks caps how many open watcher-created tasks this watch can + // hold at once. nil = uncapped. Values <= 0 are rejected at the API layer. + MaxInflightTasks *int `json:"maxInflightTasks,omitempty" db:"max_inflight_tasks"` + LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"` + // LastError / LastErrorAt are stamped when the dispatch coordinator + // self-heals an orphaned watch (e.g. its bound agent profile was + // deleted). Surfaced to the settings UI so the user sees why a watch + // was disabled. Empty string / nil when the watch is healthy. + LastError string `json:"lastError,omitempty" db:"last_error"` + LastErrorAt *time.Time `json:"lastErrorAt,omitempty" db:"last_error_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// IssueWatchTask deduplicates task creation per (watch, issue) tuple. Keyed on +// short_id (e.g. "PROJ-123") because it is stable, human-readable, and present +// on every Sentry search result. +type IssueWatchTask struct { + ID string `json:"id" db:"id"` + IssueWatchID string `json:"issueWatchId" db:"issue_watch_id"` + IssueShortID string `json:"issueShortId" db:"issue_short_id"` + IssueURL string `json:"issueUrl" db:"issue_url"` + TaskID string `json:"taskId" db:"task_id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +// NewSentryIssueEvent is published on the bus whenever the poller observes an +// issue matching a watch that has no existing dedup row. The orchestrator +// consumes this to create (and optionally auto-start) a Kandev task. +type NewSentryIssueEvent struct { + IssueWatchID string `json:"issueWatchId"` + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + AgentProfileID string `json:"agentProfileId"` + ExecutorProfileID string `json:"executorProfileId"` + Prompt string `json:"prompt"` + // MaxInflightTasks mirrors the watch row's per-watcher throttle cap so the + // orchestrator's gate can read it without loading the row again. nil = + // uncapped. + MaxInflightTasks *int `json:"maxInflightTasks,omitempty"` + Issue *SentryIssue `json:"issue"` +} + +// CreateIssueWatchRequest is the payload for POST /api/v1/sentry/watches/issue. +type CreateIssueWatchRequest struct { + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + Filter SearchFilter `json:"filter"` + AgentProfileID string `json:"agentProfileId"` + ExecutorProfileID string `json:"executorProfileId"` + Prompt string `json:"prompt"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + MaxInflightTasks *int `json:"maxInflightTasks,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +// UpdateIssueWatchRequest is the payload for PATCH /api/v1/sentry/watches/issue/:id. +// All fields are pointers so the caller can omit ones it doesn't want to change. +type UpdateIssueWatchRequest struct { + WorkflowID *string `json:"workflowId,omitempty"` + WorkflowStepID *string `json:"workflowStepId,omitempty"` + Filter *SearchFilter `json:"filter,omitempty"` + AgentProfileID *string `json:"agentProfileId,omitempty"` + ExecutorProfileID *string `json:"executorProfileId,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"` + // MaxInflightTasks is tri-state so a partial PATCH that omits the field + // leaves the cap unchanged. Absent = unchanged, null = uncapped, positive + // int = cap. + MaxInflightTasks optional.Int `json:"maxInflightTasks"` +} diff --git a/apps/backend/internal/sentry/poller.go b/apps/backend/internal/sentry/poller.go new file mode 100644 index 0000000000..dcf5b4d965 --- /dev/null +++ b/apps/backend/internal/sentry/poller.go @@ -0,0 +1,185 @@ +package sentry + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/integrations/healthpoll" +) + +// defaultIssuePollTickInterval is how often the issue-watch loop wakes up to +// consider every enabled watcher. The actual Sentry search is only re-run for +// a watcher when its per-watch `PollIntervalSeconds` has elapsed since +// `LastPolledAt`, so this is the *minimum* granularity, not the *actual* +// cadence. +const defaultIssuePollTickInterval = 60 * time.Second + +// Poller drives two background loops sharing a single Service: +// - auth health: probes stored credentials so the UI can show connect status. +// Delegated to internal/integrations/healthpoll for the loop semantics. +// - issue watches: runs each enabled watcher's filter and emits +// NewSentryIssueEvent for every matching issue the orchestrator hasn't yet +// seen. +// +// Both loops are cancelled together via Stop. +type Poller struct { + service *Service + logger *logger.Logger + auth *healthpoll.Poller + issueInterval time.Duration + issueTickHook func() // tests use this to observe each issue-watch tick. + + mu sync.Mutex + cancelIssueLoop context.CancelFunc + wg sync.WaitGroup + started bool +} + +// NewPoller returns a poller using the default cadences. Returns nil when svc +// is nil so the caller's nil-check still works after the indirection. +func NewPoller(svc *Service, log *logger.Logger) *Poller { + if svc == nil { + return nil + } + return &Poller{ + service: svc, + logger: log, + auth: healthpoll.New("sentry", svcProber{svc}, log), + issueInterval: defaultIssuePollTickInterval, + } +} + +// SetIssueTickHook installs a callback fired at the end of each issue-watch +// tick. Production code never sets this; tests use it to wait for a tick +// without sleep-polling. +func (p *Poller) SetIssueTickHook(fn func()) { + p.mu.Lock() + p.issueTickHook = fn + p.mu.Unlock() +} + +// Start launches both background loops. Calling Start more than once without +// Stop is a no-op. A nil receiver is also a no-op. +func (p *Poller) Start(ctx context.Context) { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if p.started || p.service == nil { + return + } + p.started = true + issueCtx, cancel := context.WithCancel(ctx) + p.cancelIssueLoop = cancel + p.auth.Start(ctx) + p.wg.Add(1) + go p.issueWatchLoop(issueCtx) +} + +// Stop cancels both loops and waits for them to drain. +func (p *Poller) Stop() { + if p == nil { + return + } + p.mu.Lock() + if !p.started { + p.mu.Unlock() + return + } + cancel := p.cancelIssueLoop + p.mu.Unlock() + if cancel != nil { + cancel() + } + p.auth.Stop() + p.wg.Wait() + p.mu.Lock() + p.started = false + p.mu.Unlock() +} + +func (p *Poller) issueWatchLoop(ctx context.Context) { + defer p.wg.Done() + ticker := time.NewTicker(p.issueInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.checkIssueWatches(ctx) + p.fireIssueTickHook() + } + } +} + +func (p *Poller) checkIssueWatches(ctx context.Context) { + watches, err := p.service.Store().ListEnabledIssueWatches(ctx) + if err != nil { + p.logger.Warn("sentry poller: list enabled issue watches failed", zap.Error(err)) + return + } + if len(watches) == 0 { + return + } + for _, w := range watches { + if ctx.Err() != nil { + return + } + if !isIssueWatchDue(w, time.Now()) { + continue + } + newIssues, err := p.service.CheckIssueWatch(ctx, w) + if err != nil { + p.logger.Warn("sentry poller: check issue watch failed", + zap.String("watch_id", w.ID), zap.Error(err)) + continue + } + for _, issue := range newIssues { + p.logger.Info("new sentry issue found for watch", + zap.String("watch_id", w.ID), + zap.String("short_id", issue.ShortID), + zap.String("title", issue.Title)) + p.service.publishNewSentryIssueEvent(ctx, w, issue) + } + } +} + +// isIssueWatchDue reports whether enough time has passed since the watch was +// last polled to re-run its search. +func isIssueWatchDue(w *IssueWatch, now time.Time) bool { + if w.LastPolledAt == nil { + return true + } + interval := w.PollIntervalSeconds + if interval <= 0 { + interval = DefaultIssueWatchPollInterval + } + return now.Sub(*w.LastPolledAt) >= time.Duration(interval)*time.Second +} + +func (p *Poller) fireIssueTickHook() { + p.mu.Lock() + hook := p.issueTickHook + p.mu.Unlock() + if hook != nil { + hook() + } +} + +// svcProber adapts *Service to healthpoll.Prober without leaking the shared +// interface into Service's public API. +type svcProber struct{ svc *Service } + +func (s svcProber) HasConfig(ctx context.Context) (bool, error) { + return s.svc.Store().HasConfig(ctx) +} + +func (s svcProber) RecordAuthHealth(ctx context.Context) { + s.svc.RecordAuthHealth(ctx) +} diff --git a/apps/backend/internal/sentry/poller_issue_watch_test.go b/apps/backend/internal/sentry/poller_issue_watch_test.go new file mode 100644 index 0000000000..656b632ce5 --- /dev/null +++ b/apps/backend/internal/sentry/poller_issue_watch_test.go @@ -0,0 +1,213 @@ +package sentry + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// recordingSubscriber subscribes to SentryNewIssue on the in-memory bus and +// captures payloads so the test can assert on them without touching internals. +type recordingSubscriber struct { + mu sync.Mutex + payload []*NewSentryIssueEvent + done chan struct{} + want int +} + +func newRecordingSubscriber(eb bus.EventBus, want int) (*recordingSubscriber, error) { + r := &recordingSubscriber{done: make(chan struct{}), want: want} + _, err := eb.Subscribe(events.SentryNewIssue, func(_ context.Context, e *bus.Event) error { + r.mu.Lock() + defer r.mu.Unlock() + evt, ok := e.Data.(*NewSentryIssueEvent) + if !ok { + return nil + } + r.payload = append(r.payload, evt) + if r.want > 0 && len(r.payload) == r.want { + close(r.done) + } + return nil + }) + return r, err +} + +func (r *recordingSubscriber) snapshot() []*NewSentryIssueEvent { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*NewSentryIssueEvent, len(r.payload)) + copy(out, r.payload) + return out +} + +func TestPoller_CheckIssueWatches_PublishesNewIssuesOnly(t *testing.T) { + f := newPollerFixture(t) + ctx := context.Background() + f.saveConfig(t, "tok") + + eb := bus.NewMemoryEventBus(logger.Default()) + defer eb.Close() + f.svc.SetEventBus(eb) + + sub, err := newRecordingSubscriber(eb, 2) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + + w := newTestIssueWatch("ws-1") + if err := f.store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create watch: %v", err) + } + + f.client.searchIssuesFn = func(_ SearchFilter, _ string) (*SearchResult, error) { + return &SearchResult{ + Issues: []SentryIssue{ + {ShortID: "PROJ-1", Title: "first", Permalink: "https://sentry.io/issues/PROJ-1"}, + {ShortID: "PROJ-2", Title: "second", Permalink: "https://sentry.io/issues/PROJ-2"}, + }, + IsLast: true, + }, nil + } + + // First tick: both issues are new, two events publish. + f.poller.checkIssueWatches(ctx) + + <-sub.done + first := sub.snapshot() + if len(first) != 2 { + t.Fatalf("expected 2 events on first tick, got %d", len(first)) + } + ids := []string{first[0].Issue.ShortID, first[1].Issue.ShortID} + if !contains(ids, "PROJ-1") || !contains(ids, "PROJ-2") { + t.Errorf("expected events for PROJ-1 and PROJ-2, got %v", ids) + } + if first[0].WorkspaceID != "ws-1" || first[0].WorkflowID != "wf-1" { + t.Errorf("event missing watch context: %+v", first[0]) + } + + // Reserve both to simulate the orchestrator having created tasks. + for _, id := range []string{"PROJ-1", "PROJ-2"} { + if _, err := f.store.ReserveIssueWatchTask(ctx, w.ID, id, "https://sentry.io/issues/"+id); err != nil { + t.Fatalf("reserve %s: %v", id, err) + } + } + + // Second tick: nothing new, no additional events should publish. + f.poller.checkIssueWatches(ctx) + if got := len(sub.snapshot()); got != 2 { + t.Errorf("expected event count to stay at 2 after dedup, got %d", got) + } +} + +func TestPoller_CheckIssueWatches_SkipsDisabled(t *testing.T) { + f := newPollerFixture(t) + ctx := context.Background() + f.saveConfig(t, "tok") + + eb := bus.NewMemoryEventBus(logger.Default()) + defer eb.Close() + f.svc.SetEventBus(eb) + + disabled := newTestIssueWatch("ws-1") + disabled.Enabled = false + if err := f.store.CreateIssueWatch(ctx, disabled); err != nil { + t.Fatalf("create disabled: %v", err) + } + + calls := 0 + f.client.searchIssuesFn = func(_ SearchFilter, _ string) (*SearchResult, error) { + calls++ + return &SearchResult{Issues: []SentryIssue{{ShortID: "PROJ-1"}}, IsLast: true}, nil + } + + f.poller.checkIssueWatches(ctx) + + if calls != 0 { + t.Errorf("expected disabled watch to be skipped (no Sentry call), got %d calls", calls) + } +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} + +func TestIsIssueWatchDue(t *testing.T) { + now := time.Now() + stamp := now.Add(-30 * time.Second) + cases := []struct { + name string + watch *IssueWatch + expected bool + }{ + { + name: "never polled is always due", + watch: &IssueWatch{PollIntervalSeconds: 300}, + expected: true, + }, + { + name: "polled less than interval ago is not due", + watch: &IssueWatch{PollIntervalSeconds: 300, LastPolledAt: &stamp}, + expected: false, + }, + { + name: "polled at exactly the interval boundary is due", + watch: &IssueWatch{PollIntervalSeconds: 30, LastPolledAt: &stamp}, + expected: true, + }, + { + name: "zero interval falls back to default and gates accordingly", + watch: &IssueWatch{PollIntervalSeconds: 0, LastPolledAt: &stamp}, + expected: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isIssueWatchDue(tc.watch, now); got != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, got) + } + }) + } +} + +func TestPoller_CheckIssueWatches_RespectsPerWatchInterval(t *testing.T) { + f := newPollerFixture(t) + ctx := context.Background() + f.saveConfig(t, "tok") + + eb := bus.NewMemoryEventBus(logger.Default()) + defer eb.Close() + f.svc.SetEventBus(eb) + + w := newTestIssueWatch("ws-1") + w.PollIntervalSeconds = 300 + if err := f.store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + now := time.Now().UTC() + if err := f.store.UpdateIssueWatchLastPolled(ctx, w.ID, now); err != nil { + t.Fatalf("stamp: %v", err) + } + + calls := 0 + f.client.searchIssuesFn = func(_ SearchFilter, _ string) (*SearchResult, error) { + calls++ + return &SearchResult{Issues: []SentryIssue{{ShortID: "PROJ-1"}}, IsLast: true}, nil + } + + f.poller.checkIssueWatches(ctx) + + if calls != 0 { + t.Errorf("expected gating to skip the Sentry search, got %d call(s)", calls) + } +} diff --git a/apps/backend/internal/sentry/poller_test.go b/apps/backend/internal/sentry/poller_test.go new file mode 100644 index 0000000000..d154835679 --- /dev/null +++ b/apps/backend/internal/sentry/poller_test.go @@ -0,0 +1,104 @@ +package sentry + +import ( + "context" + "testing" + "testing/synctest" + + "github.com/kandev/kandev/internal/common/logger" +) + +type pollerFixture struct { + store *Store + secrets *fakeSecretStore + client *fakeClient + svc *Service + poller *Poller +} + +func newPollerFixture(t *testing.T) *pollerFixture { + t.Helper() + f := &pollerFixture{ + store: newTestStore(t), + secrets: newFakeSecretStore(), + client: &fakeClient{}, + } + f.svc = NewService(f.store, f.secrets, func(_ *SentryConfig, _ string) Client { + return f.client + }, logger.Default()) + f.poller = NewPoller(f.svc, logger.Default()) + return f +} + +// saveConfig persists the singleton directly via store + secret fakes, +// bypassing Service.SetConfig (and its async probe). +func (f *pollerFixture) saveConfig(t *testing.T, secret string) { + t.Helper() + ctx := context.Background() + if err := f.store.UpsertConfig(ctx, &SentryConfig{AuthMethod: AuthMethodAuthToken}); err != nil { + t.Fatalf("save config: %v", err) + } + if err := f.secrets.Set(ctx, SecretKey, "sentry", secret); err != nil { + t.Fatalf("save secret: %v", err) + } +} + +func TestService_RecordAuthHealth_Success(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true}, nil + } + + f.svc.RecordAuthHealth(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background()) + if cfg == nil || !cfg.LastOk { + t.Errorf("expected LastOk=true, got %+v", cfg) + } +} + +func TestService_RecordAuthHealth_Failure(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "tok") + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: false, Error: "401 unauthorized"}, nil + } + + f.svc.RecordAuthHealth(context.Background()) + + cfg, _ := f.store.GetConfig(context.Background()) + if cfg.LastOk { + t.Error("expected LastOk=false") + } + if cfg.LastError != "401 unauthorized" { + t.Errorf("expected error preserved, got %q", cfg.LastError) + } +} + +func TestPoller_Start_ProbesWhenConfigured(t *testing.T) { + // Smoke test of the prober adapter under fake time so the immediate probe + // on Start completes deterministically. + synctest.Test(t, func(t *testing.T) { + f := newPollerFixture(t) + f.saveConfig(t, "tok") + var probed bool + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true}, nil + } + f.svc.SetProbeHook(func() { + probed = true + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + f.poller.Start(ctx) + defer f.poller.Stop() + + synctest.Wait() + + if !probed { + t.Error("expected probe to fire when configured") + } + }) +} diff --git a/apps/backend/internal/sentry/provider.go b/apps/backend/internal/sentry/provider.go new file mode 100644 index 0000000000..6fc93029f9 --- /dev/null +++ b/apps/backend/internal/sentry/provider.go @@ -0,0 +1,58 @@ +package sentry + +import ( + "os" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" +) + +// mockEnvVar gates the in-memory mock client used in E2E tests. +const mockEnvVar = "KANDEV_MOCK_SENTRY" + +// MockEnabled reports whether KANDEV_MOCK_SENTRY is set to "true". +func MockEnabled() bool { + return os.Getenv(mockEnvVar) == "true" +} + +// Provide builds the Sentry service. eventBus is used by the Phase 2 issue +// watcher to publish NewSentryIssueEvent for the orchestrator to consume. +// Cleanup is a no-op — the service holds only in-memory client caches. +// +// When KANDEV_MOCK_SENTRY=true, the service is wired to a process-wide +// MockClient and the same instance is exposed via Service.MockClient() so the +// E2E mock controller can drive it. +func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) { + store, err := NewStore(writer, reader) + if err != nil { + return nil, nil, err + } + clientFn := DefaultClientFactory + var mock *MockClient + if MockEnabled() { + mock = NewMockClient() + clientFn = MockClientFactory(mock) + log.Info("sentry: using in-memory mock client (KANDEV_MOCK_SENTRY=true)") + } + svc := NewService(store, secrets, clientFn, log) + svc.mockClient = mock + if eventBus != nil { + svc.SetEventBus(eventBus) + } + cleanup := func() error { return nil } + return svc, cleanup, nil +} + +// RegisterMockRoutes mounts the mock control routes when the service was built +// with a MockClient. No-op otherwise. +func RegisterMockRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { + mock := svc.MockClient() + if mock == nil { + return + } + NewMockController(mock, svc.Store(), log).RegisterRoutes(router) + log.Info("registered Sentry mock control endpoints") +} diff --git a/apps/backend/internal/sentry/rest_client.go b/apps/backend/internal/sentry/rest_client.go new file mode 100644 index 0000000000..a5216d84e0 --- /dev/null +++ b/apps/backend/internal/sentry/rest_client.go @@ -0,0 +1,466 @@ +package sentry + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +// SentryAPIEndpoint is the SaaS API base. Phase 1 targets sentry.io only; +// self-hosted instances will need a per-config base-URL field later. +const SentryAPIEndpoint = "https://sentry.io/api/0" + +const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)" + +// RESTClient talks to Sentry's REST API using a Bearer auth token. +type RESTClient struct { + http *http.Client + endpoint string + token string + maxBodySize int64 +} + +// NewRESTClient builds a client from a SentryConfig + secret. The config is +// accepted for symmetry with other integrations; only the token is read today. +func NewRESTClient(_ *SentryConfig, secret string) *RESTClient { + return &RESTClient{ + http: &http.Client{Timeout: 30 * time.Second}, + endpoint: SentryAPIEndpoint, + token: secret, + maxBodySize: 8 << 20, // 8 MB — issue payloads can include event samples. + } +} + +// do executes a GET against path (relative to endpoint), parses query params, +// and decodes the JSON body into out. Returns the response so callers can read +// headers (e.g. Link for pagination). +func (c *RESTClient) do(ctx context.Context, path string, query url.Values, out interface{}) (*http.Response, error) { + u := c.endpoint + path + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Authorization", "Bearer "+c.token) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBodySize)) + if err != nil { + return resp, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return resp, &APIError{StatusCode: resp.StatusCode, Message: summarizeBody(raw)} + } + if out == nil { + return resp, nil + } + if err := json.Unmarshal(raw, out); err != nil { + return resp, &APIError{StatusCode: resp.StatusCode, Message: "invalid JSON response: " + err.Error()} + } + return resp, nil +} + +// summarizeBody trims an error body to a useful length for log/error messages. +func summarizeBody(raw []byte) string { + const maxMsg = 500 + s := strings.TrimSpace(string(raw)) + if len(s) > maxMsg { + return s[:maxMsg] + "…" + } + if s == "" { + return "(empty body)" + } + return s +} + +// --- whoami / TestAuth --- + +type whoamiResponse struct { + User struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"user"` +} + +// TestAuth hits the root API endpoint, which returns the authenticated user +// when the token is valid. Mirrors Sentry's official "verify your token" +// guidance. +func (c *RESTClient) TestAuth(ctx context.Context) (*TestConnectionResult, error) { + var data whoamiResponse + if _, err := c.do(ctx, "/", nil, &data); err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) { + return &TestConnectionResult{OK: false, Error: apiErr.Error()}, nil + } + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + name := data.User.Name + if name == "" { + name = data.User.Username + } + return &TestConnectionResult{ + OK: true, + UserID: data.User.ID, + DisplayName: name, + Email: data.User.Email, + }, nil +} + +// --- organizations --- + +// listOrganizationsMaxPages bounds the pagination loop so a misbehaving API or +// an enormous account can't spin forever. 100 pages × 100/page = 10k orgs. +const listOrganizationsMaxPages = 100 + +// ListOrganizations returns all organizations the token can access, following +// Sentry's Link-header cursor pagination (the endpoint returns ~100 per page). +// The settings dropdown uses these to populate the default-org selector. +// SentryOrganization's json tags match the API payload, so the response decodes +// straight into it. +func (c *RESTClient) ListOrganizations(ctx context.Context) ([]SentryOrganization, error) { + out := make([]SentryOrganization, 0, 32) + cursor := "" + for page := 0; page < listOrganizationsMaxPages; page++ { + q := url.Values{} + if cursor != "" { + q.Set("cursor", cursor) + } + var nodes []SentryOrganization + resp, err := c.do(ctx, "/organizations/", q, &nodes) + if err != nil { + return nil, err + } + out = append(out, nodes...) + next, hasNext := parseNextCursor(resp.Header.Get("Link")) + if !hasNext { + break + } + cursor = next + } + return out, nil +} + +// --- projects --- + +type projectNode struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Organization struct { + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"organization"` +} + +// listProjectsMaxPages bounds the pagination loop so a misbehaving API or an +// enormous account can't spin forever. 100 pages × 100/page = 10k projects. +const listProjectsMaxPages = 100 + +// ListProjects returns all projects the token can access, across every +// organization. It enumerates the token's organizations first, then lists each +// org's projects via the org-scoped /organizations/{org}/projects/ endpoint. +// +// The user-scoped /projects/ endpoint only returns projects whose teams the +// token's user belongs to, so an org owner who is not a member of any team sees +// an empty list there. The org-scoped endpoint returns every project in the +// org, which is what the watcher/browse project dropdowns need. They filter +// client-side by the selected org. +func (c *RESTClient) ListProjects(ctx context.Context) ([]SentryProject, error) { + orgs, err := c.ListOrganizations(ctx) + if err != nil { + return nil, err + } + out := make([]SentryProject, 0, 32) + for i := range orgs { + orgSlug := orgs[i].Slug + if orgSlug == "" { + continue + } + projects, err := c.listOrgProjects(ctx, orgSlug) + if err != nil { + return nil, err + } + out = append(out, projects...) + } + return out, nil +} + +// listOrgProjects pages through one organization's projects via the org-scoped +// endpoint, following Sentry's Link-header cursor pagination (~100 per page). +func (c *RESTClient) listOrgProjects(ctx context.Context, orgSlug string) ([]SentryProject, error) { + out := make([]SentryProject, 0, 32) + path := "/organizations/" + url.PathEscape(orgSlug) + "/projects/" + cursor := "" + for page := 0; page < listProjectsMaxPages; page++ { + q := url.Values{} + if cursor != "" { + q.Set("cursor", cursor) + } + var nodes []projectNode + resp, err := c.do(ctx, path, q, &nodes) + if err != nil { + return nil, err + } + for _, n := range nodes { + // The org-scoped endpoint may omit the nested organization object; + // fall back to the org slug we queried so OrgSlug is never empty. + projOrg := n.Organization.Slug + if projOrg == "" { + projOrg = orgSlug + } + out = append(out, SentryProject{ + ID: n.ID, + Slug: n.Slug, + Name: n.Name, + OrgSlug: projOrg, + }) + } + next, hasNext := parseNextCursor(resp.Header.Get("Link")) + if !hasNext { + break + } + cursor = next + } + return out, nil +} + +// --- issues --- + +type issueNode struct { + ID string `json:"id"` + ShortID string `json:"shortId"` + Title string `json:"title"` + Culprit string `json:"culprit"` + Permalink string `json:"permalink"` + Level string `json:"level"` + Status string `json:"status"` + Count string `json:"count"` + UserCount int `json:"userCount"` + FirstSeen string `json:"firstSeen"` + LastSeen string `json:"lastSeen"` + Project struct { + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"project"` + AssignedTo *struct { + Name string `json:"name"` + } `json:"assignedTo"` +} + +func issueNodeToIssue(n *issueNode) SentryIssue { + out := SentryIssue{ + ID: n.ID, + ShortID: n.ShortID, + Title: n.Title, + Culprit: n.Culprit, + Permalink: n.Permalink, + ProjectSlug: n.Project.Slug, + ProjectName: n.Project.Name, + Level: n.Level, + Status: n.Status, + Count: n.Count, + UserCount: n.UserCount, + FirstSeen: n.FirstSeen, + LastSeen: n.LastSeen, + } + if n.AssignedTo != nil { + out.AssigneeName = n.AssignedTo.Name + } + return out +} + +// SearchIssues runs a filtered issue search scoped to filter.OrgSlug. cursor +// is the opaque token returned in the previous page's NextPageToken; pass "" +// for the first page. +// +// Results are always sorted by first-seen date descending (sort=new) so that +// newly created issues appear on page one. The issue watcher relies on this to +// detect new issues in a single bounded page read per poll tick. +func (c *RESTClient) SearchIssues(ctx context.Context, filter SearchFilter, cursor string) (*SearchResult, error) { + if filter.OrgSlug == "" { + return nil, &APIError{StatusCode: http.StatusBadRequest, Message: "orgSlug required"} + } + q := url.Values{} + // Sort by first-seen descending so the newest issues land on page one. + // The issue watcher reads only a single page per tick; this ensures newly + // created issues are not buried behind older frequently-occurring ones. + q.Set("sort", "new") + if filter.Environment != "" { + q.Set("environment", filter.Environment) + } + if filter.StatsPeriod != "" { + q.Set("statsPeriod", filter.StatsPeriod) + } + if cursor != "" { + q.Set("cursor", cursor) + } + if built := buildIssueQueryString(filter); built != "" { + q.Set("query", built) + } + var nodes []issueNode + resp, err := c.do(ctx, issuesSearchPath(filter.OrgSlug, filter.ProjectSlug), q, &nodes) + if err != nil { + return nil, err + } + next, hasNext := parseNextCursor(resp.Header.Get("Link")) + out := &SearchResult{ + Issues: make([]SentryIssue, 0, len(nodes)), + NextPageToken: next, + IsLast: !hasNext, + } + for i := range nodes { + out.Issues = append(out.Issues, issueNodeToIssue(&nodes[i])) + } + return out, nil +} + +// issuesSearchPath picks the issue-search endpoint. The org-scoped +// /organizations/{org}/issues/ endpoint requires a NUMERIC project id in +// ?project=, so when a project slug is set we use the project-scoped +// endpoint, which takes the slug directly in the path. Empty project slug +// (browse "all projects" in an org) falls back to the org endpoint. +func issuesSearchPath(orgSlug, projectSlug string) string { + if projectSlug == "" { + return "/organizations/" + url.PathEscape(orgSlug) + "/issues/" + } + return "/projects/" + url.PathEscape(orgSlug) + "/" + url.PathEscape(projectSlug) + "/issues/" +} + +// buildIssueQueryString assembles Sentry's search-bar syntax from a +// SearchFilter. Levels and statuses are encoded as repeated `level:foo` / +// `is:bar` tokens — Sentry treats repeated tokens of the same key as an OR. +func buildIssueQueryString(f SearchFilter) string { + parts := make([]string, 0, 4) + for _, lvl := range f.Levels { + lvl = strings.TrimSpace(lvl) + if lvl == "" { + continue + } + parts = append(parts, "level:"+lvl) + } + for _, st := range f.Statuses { + st = strings.TrimSpace(st) + if st == "" { + continue + } + parts = append(parts, "is:"+st) + } + if q := strings.TrimSpace(f.Query); q != "" { + parts = append(parts, q) + } + return strings.Join(parts, " ") +} + +// nextCursorRe extracts the cursor from a Sentry Link header entry of the form +// `; rel="next"; results="true"; cursor="0:100:0"`. The header may carry +// both `previous` and `next` entries separated by commas. +var nextCursorRe = regexp.MustCompile(`rel="next"[^,]*?cursor="([^"]+)"[^,]*?results="([^"]+)"|rel="next"[^,]*?results="([^"]+)"[^,]*?cursor="([^"]+)"`) + +// parseNextCursor returns (cursor, hasNext). hasNext is true only when the +// `next` entry's `results` flag is "true"; Sentry always sends a `next` link +// but flips `results` to "false" on the final page. +func parseNextCursor(link string) (string, bool) { + if link == "" { + return "", false + } + m := nextCursorRe.FindStringSubmatch(link) + if m == nil { + return "", false + } + var cursor, results string + switch { + case m[1] != "": + cursor, results = m[1], m[2] + default: + cursor, results = m[4], m[3] + } + if results != "true" { + return "", false + } + return cursor, true +} + +// shortIDResolveResponse is the payload from the org-scoped shortids resolver; +// its `group` field carries the full issue. +type shortIDResolveResponse struct { + Group issueNode `json:"group"` +} + +// numericIssueID reports whether s is Sentry's internal numeric issue id (all +// digits) rather than a human-facing short id like "PROJ-123". +func numericIssueID(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// GetIssue loads a single issue by numeric ID or short ID (e.g. "PROJ-123"). +// The /issues/{id}/ endpoint only accepts the numeric internal id, so a short +// id is resolved through the org-scoped shortids endpoint instead. +func (c *RESTClient) GetIssue(ctx context.Context, idOrShortID string) (*SentryIssue, error) { + if idOrShortID == "" { + return nil, fmt.Errorf("idOrShortID required") + } + if numericIssueID(idOrShortID) { + return c.getIssueByID(ctx, idOrShortID) + } + return c.resolveShortID(ctx, idOrShortID) +} + +func (c *RESTClient) getIssueByID(ctx context.Context, id string) (*SentryIssue, error) { + var n issueNode + if _, err := c.do(ctx, "/issues/"+url.PathEscape(id)+"/", nil, &n); err != nil { + return nil, err + } + issue := issueNodeToIssue(&n) + return &issue, nil +} + +// resolveShortID looks a short id up via each accessible org's shortids +// resolver until one matches. Short ids carry no org, and the same id can only +// resolve in the org that owns the project, so a 404 just means "try the next". +func (c *RESTClient) resolveShortID(ctx context.Context, shortID string) (*SentryIssue, error) { + orgs, err := c.ListOrganizations(ctx) + if err != nil { + return nil, err + } + for i := range orgs { + if orgs[i].Slug == "" { + continue + } + var resp shortIDResolveResponse + path := "/organizations/" + url.PathEscape(orgs[i].Slug) + "/shortids/" + url.PathEscape(shortID) + "/" + if _, err := c.do(ctx, path, nil, &resp); err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + continue + } + return nil, err + } + issue := issueNodeToIssue(&resp.Group) + return &issue, nil + } + return nil, &APIError{StatusCode: http.StatusNotFound, Message: "short id not found: " + shortID} +} diff --git a/apps/backend/internal/sentry/rest_client_test.go b/apps/backend/internal/sentry/rest_client_test.go new file mode 100644 index 0000000000..27997be225 --- /dev/null +++ b/apps/backend/internal/sentry/rest_client_test.go @@ -0,0 +1,277 @@ +package sentry + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + s := httptest.NewServer(handler) + t.Cleanup(s.Close) + return s +} + +// pointTo rewrites the endpoint on a freshly-built client so tests hit the +// httptest server without needing a mockable URL on the production constructor. +func pointTo(c *RESTClient, url string) *RESTClient { + c.endpoint = url + return c +} + +func TestRESTClient_TestAuth_BearerHeaderAndOK(t *testing.T) { + var gotAuth string + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + if r.URL.Path != "/" { + t.Errorf("expected probe to hit /, got %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"user":{"id":"42","username":"alice","name":"Alice","email":"a@x"}}`)) + }) + c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL) + res, err := c.TestAuth(context.Background()) + if err != nil { + t.Fatalf("test auth: %v", err) + } + if !res.OK || res.DisplayName != "Alice" || res.UserID != "42" { + t.Errorf("unexpected result: %+v", res) + } + if gotAuth != "Bearer tok" { + t.Errorf("auth header = %q, want Bearer tok", gotAuth) + } +} + +func TestRESTClient_TestAuth_Unauthorized(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"detail":"Invalid token"}`)) + }) + c := pointTo(NewRESTClient(&SentryConfig{}, "bad"), ts.URL) + res, err := c.TestAuth(context.Background()) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if res.OK { + t.Error("expected OK=false") + } + if !strings.Contains(res.Error, "401") { + t.Errorf("expected 401 in error, got %q", res.Error) + } +} + +// TestRESTClient_ListProjects locks in that projects are gathered per-org via +// the org-scoped endpoint (not the user-scoped /projects/, which hides projects +// from org owners not on any team). It also covers the OrgSlug fallback: the +// second org's project node omits the nested organization object, so OrgSlug +// must come from the queried org slug. +func TestRESTClient_ListProjects(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/organizations/": + _, _ = w.Write([]byte(`[{"id":"10","slug":"acme","name":"Acme"},{"id":"11","slug":"globex","name":"Globex"}]`)) + case "/organizations/acme/projects/": + _, _ = w.Write([]byte(`[{"id":"1","slug":"frontend","name":"Frontend","organization":{"slug":"acme","name":"Acme"}}]`)) + case "/organizations/globex/projects/": + // No nested organization object — exercises the OrgSlug fallback. + _, _ = w.Write([]byte(`[{"id":"2","slug":"api","name":"API"}]`)) + default: + t.Errorf("unexpected path %q", r.URL.Path) + } + }) + c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL) + projects, err := c.ListProjects(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(projects) != 2 { + t.Fatalf("expected 2 projects, got %+v", projects) + } + if projects[0].Slug != "frontend" || projects[0].OrgSlug != "acme" { + t.Errorf("project[0] = %+v", projects[0]) + } + if projects[1].Slug != "api" || projects[1].OrgSlug != "globex" { + t.Errorf("project[1] OrgSlug fallback failed: %+v", projects[1]) + } +} + +func TestRESTClient_SearchIssues_BuildsQueryStringAndPaginates(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/projects/acme/frontend/issues/") { + t.Errorf("expected /projects/acme/frontend/issues/, got %q", r.URL.Path) + } + q := r.URL.Query() + if q.Has("project") { + t.Errorf("project slug must be in the path, not the ?project= param: %q", q.Get("project")) + } + if q.Get("environment") != "prod" { + t.Errorf("expected environment=prod, got %q", q.Get("environment")) + } + if q.Get("cursor") != "abc" { + t.Errorf("expected cursor=abc, got %q", q.Get("cursor")) + } + got := q.Get("query") + if !strings.Contains(got, "level:error") || !strings.Contains(got, "is:unresolved") || + !strings.Contains(got, "boom") { + t.Errorf("query string missing tokens: %q", got) + } + w.Header().Set("Link", `; rel="previous"; results="false"; cursor="prev", `+ + `; rel="next"; results="true"; cursor="next-cursor"`) + _, _ = w.Write([]byte(`[{"id":"i1","shortId":"PROJ-1","title":"Boom","level":"error","status":"unresolved","count":"5","userCount":2,"project":{"slug":"frontend","name":"FE"},"assignedTo":{"name":"Alice"}}]`)) + }) + c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL) + res, err := c.SearchIssues(context.Background(), SearchFilter{ + OrgSlug: "acme", + ProjectSlug: "frontend", + Environment: "prod", + Levels: []string{"error"}, + Statuses: []string{"unresolved"}, + Query: "boom", + }, "abc") + if err != nil { + t.Fatalf("search: %v", err) + } + if res.IsLast { + t.Error("expected IsLast=false when next results=true") + } + if res.NextPageToken != "next-cursor" { + t.Errorf("expected next cursor parsed, got %q", res.NextPageToken) + } + if len(res.Issues) != 1 || res.Issues[0].ShortID != "PROJ-1" || res.Issues[0].AssigneeName != "Alice" { + t.Errorf("issues = %+v", res.Issues) + } +} + +func TestRESTClient_SearchIssues_LastPage(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Link", `; rel="next"; results="false"; cursor="x"`) + _, _ = w.Write([]byte(`[]`)) + }) + c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL) + res, err := c.SearchIssues(context.Background(), SearchFilter{OrgSlug: "acme"}, "") + if err != nil { + t.Fatalf("search: %v", err) + } + if !res.IsLast { + t.Error("expected IsLast=true when results=false") + } + if res.NextPageToken != "" { + t.Errorf("expected empty cursor on last page, got %q", res.NextPageToken) + } +} + +func TestRESTClient_SearchIssues_RequiresOrgSlug(t *testing.T) { + c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), "http://nope") + _, err := c.SearchIssues(context.Background(), SearchFilter{}, "") + if err == nil { + t.Error("expected error when orgSlug missing") + } +} + +// TestRESTClient_SearchIssues_AllProjectsUsesOrgEndpoint locks in that an +// empty project slug falls back to the org-scoped endpoint (browse "all +// projects"), while a set slug uses the project-scoped path (asserted in +// TestRESTClient_SearchIssues_BuildsQueryStringAndPaginates). Regression +// guard for the slug-vs-numeric-id bug: the org endpoint must never receive +// a slug in ?project=. +func TestRESTClient_SearchIssues_AllProjectsUsesOrgEndpoint(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/organizations/acme/issues/" { + t.Errorf("expected /organizations/acme/issues/, got %q", r.URL.Path) + } + if r.URL.Query().Has("project") { + t.Errorf("org endpoint must not receive a project slug param") + } + _, _ = w.Write([]byte(`[]`)) + }) + c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL) + if _, err := c.SearchIssues(context.Background(), SearchFilter{OrgSlug: "acme"}, ""); err != nil { + t.Fatalf("search: %v", err) + } +} + +// TestRESTClient_GetIssue_NumericID locks in that a numeric internal id hits +// the /issues/{id}/ endpoint directly (no org needed). +func TestRESTClient_GetIssue_NumericID(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/issues/99/" { + t.Errorf("expected /issues/99/, got %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"id":"99","shortId":"PROJ-7","title":"Crash","level":"fatal","status":"unresolved","project":{"slug":"frontend"}}`)) + }) + c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL) + issue, err := c.GetIssue(context.Background(), "99") + if err != nil { + t.Fatalf("get: %v", err) + } + if issue.ID != "99" || issue.ShortID != "PROJ-7" || issue.Level != "fatal" { + t.Errorf("issue = %+v", issue) + } +} + +// TestRESTClient_GetIssue_ShortID locks in that a human-facing short id is +// resolved via the org-scoped shortids endpoint (the /issues/{id}/ endpoint +// rejects short ids), skipping past an org that returns 404 to the one that +// owns the project. +func TestRESTClient_GetIssue_ShortID(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/organizations/": + _, _ = w.Write([]byte(`[{"id":"1","slug":"globex","name":"Globex"},{"id":"2","slug":"acme","name":"Acme"}]`)) + case "/organizations/globex/shortids/PROJ-7/": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"not found"}`)) + case "/organizations/acme/shortids/PROJ-7/": + _, _ = w.Write([]byte(`{"shortId":"PROJ-7","group":{"id":"99","shortId":"PROJ-7","title":"Crash","level":"fatal","status":"unresolved","project":{"slug":"frontend"}}}`)) + default: + t.Errorf("unexpected path %q", r.URL.Path) + } + }) + c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL) + issue, err := c.GetIssue(context.Background(), "PROJ-7") + if err != nil { + t.Fatalf("get: %v", err) + } + if issue.ID != "99" || issue.ShortID != "PROJ-7" || issue.Level != "fatal" { + t.Errorf("issue = %+v", issue) + } +} + +func TestParseNextCursor(t *testing.T) { + link := `; rel="previous"; results="false"; cursor="prev", ` + + `; rel="next"; results="true"; cursor="abc-123"` + cur, has := parseNextCursor(link) + if !has || cur != "abc-123" { + t.Errorf("expected abc-123/true, got %q/%v", cur, has) + } + + // results="false" → no next page. + link = `<...>; rel="next"; results="false"; cursor="zz"` + cur, has = parseNextCursor(link) + if has || cur != "" { + t.Errorf("expected no-next, got %q/%v", cur, has) + } + + if _, has := parseNextCursor(""); has { + t.Error("expected false for empty header") + } +} + +func TestBuildIssueQueryString(t *testing.T) { + got := buildIssueQueryString(SearchFilter{}) + if got != "" { + t.Errorf("expected empty, got %q", got) + } + got = buildIssueQueryString(SearchFilter{ + Levels: []string{"error", "fatal"}, + Statuses: []string{"unresolved"}, + Query: "boom", + }) + for _, want := range []string{"level:error", "level:fatal", "is:unresolved", "boom"} { + if !strings.Contains(got, want) { + t.Errorf("query string %q missing %q", got, want) + } + } +} diff --git a/apps/backend/internal/sentry/service.go b/apps/backend/internal/sentry/service.go new file mode 100644 index 0000000000..dd41c6910b --- /dev/null +++ b/apps/backend/internal/sentry/service.go @@ -0,0 +1,332 @@ +package sentry + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" +) + +// SecretStore is the subset of the secrets store the service needs. +type SecretStore interface { + Reveal(ctx context.Context, id string) (string, error) + Set(ctx context.Context, id, name, value string) error + Delete(ctx context.Context, id string) error + Exists(ctx context.Context, id string) (bool, error) +} + +// Service orchestrates Sentry config storage, the cached client, and the +// browse operations used by the HTTP handlers. +type Service struct { + store *Store + secrets SecretStore + log *logger.Logger + mu sync.Mutex + clientFn ClientFactory + client Client + // clientGen is incremented by invalidateClient each time the cached client + // is discarded. clientFor captures it before I/O and only stores a newly + // built client when the value is unchanged, preventing a stale client from + // clobbering a concurrent invalidation. + clientGen uint64 + probeHook func() + // mockClient is non-nil only when Provide built the service with a MockClient. + mockClient *MockClient + // eventBus is wired by SetEventBus so the poller can publish + // NewSentryIssueEvent. Optional: when nil, observed issues are not + // surfaced to the orchestrator. + eventBus bus.EventBus +} + +// MockClient returns the shared mock client when the service was built in mock +// mode, or nil for production builds. +func (s *Service) MockClient() *MockClient { + return s.mockClient +} + +// ClientFactory builds a Client for the given config + secret. Overridable so +// tests can inject fakes without touching HTTP. +type ClientFactory func(cfg *SentryConfig, secret string) Client + +// DefaultClientFactory returns a real RESTClient. +func DefaultClientFactory(cfg *SentryConfig, secret string) Client { + return NewRESTClient(cfg, secret) +} + +// NewService wires the service. Pass nil for clientFn to use the default. +func NewService(store *Store, secrets SecretStore, clientFn ClientFactory, log *logger.Logger) *Service { + if clientFn == nil { + clientFn = DefaultClientFactory + } + return &Service{ + store: store, + secrets: secrets, + log: log, + clientFn: clientFn, + } +} + +// Store exposes the underlying store so background workers can persist state. +func (s *Service) Store() *Store { + return s.store +} + +// GetConfig returns the singleton config enriched with a HasSecret flag. +func (s *Service) GetConfig(ctx context.Context) (*SentryConfig, error) { + cfg, err := s.store.GetConfig(ctx) + if err != nil || cfg == nil { + return cfg, err + } + if s.secrets == nil { + return cfg, nil + } + exists, existsErr := s.secrets.Exists(ctx, SecretKey) + if existsErr != nil { + s.log.Warn("sentry: secret exists check failed", zap.Error(existsErr)) + } + cfg.HasSecret = exists + return cfg, nil +} + +// ErrInvalidConfig is returned by SetConfig when the request fails validation. +var ErrInvalidConfig = errors.New("sentry: invalid configuration") + +// SetConfig is upsert. An empty Secret on update keeps the existing token. +func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*SentryConfig, error) { + if err := validateConfigRequest(req); err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) + } + cfg := &SentryConfig{AuthMethod: req.AuthMethod} + if err := s.store.UpsertConfig(ctx, cfg); err != nil { + return nil, fmt.Errorf("upsert sentry config: %w", err) + } + if req.Secret != "" && s.secrets != nil { + if err := s.secrets.Set(ctx, SecretKey, "Sentry auth token", req.Secret); err != nil { + return nil, fmt.Errorf("store sentry secret: %w", err) + } + } + s.invalidateClient() + // Probe asynchronously so a slow Sentry doesn't stall the save response. + go func() { + s.RecordAuthHealth(context.Background()) + }() + return s.GetConfig(ctx) +} + +// DeleteConfig removes both the config row and the stored secret. +func (s *Service) DeleteConfig(ctx context.Context) error { + if err := s.store.DeleteConfig(ctx); err != nil { + return err + } + if s.secrets != nil { + if err := s.secrets.Delete(ctx, SecretKey); err != nil { + s.log.Warn("sentry: secret delete failed", zap.Error(err)) + } + } + s.invalidateClient() + return nil +} + +// TestConnection validates credentials either from a fresh SetConfigRequest +// (before persisting) or from the stored config (after saving). +func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { + cfg, secret, err := s.resolveCredentials(ctx, req) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + client := s.clientFn(cfg, secret) + return client.TestAuth(ctx) +} + +// ProbeAuth validates the stored credentials. +func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) { + client, err := s.clientFor(ctx) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return client.TestAuth(ctx) +} + +// authProbeTimeout caps a single auth-health probe. +const authProbeTimeout = 15 * time.Second + +// authHealthWriteTimeout bounds the DB write that persists the probe outcome. +const authHealthWriteTimeout = 5 * time.Second + +// SetProbeHook installs a callback fired at the end of each RecordAuthHealth +// call. Production code never sets this; tests use it to synchronise on probe +// completion without sleep-polling. +func (s *Service) SetProbeHook(fn func()) { + s.mu.Lock() + defer s.mu.Unlock() + s.probeHook = fn +} + +// RecordAuthHealth probes credentials and writes the outcome onto the row. +func (s *Service) RecordAuthHealth(ctx context.Context) { + probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) + defer cancel() + res, err := s.ProbeAuth(probeCtx) + ok := err == nil && res != nil && res.OK + errMsg := "" + switch { + case err != nil: + errMsg = err.Error() + case res != nil && !res.OK: + errMsg = res.Error + } + // Detach the DB write from ctx so a probe that exhausted its deadline can + // still record the failure. + writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) + defer writeCancel() + if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, time.Now().UTC()); updateErr != nil { + s.log.Warn("sentry: update auth health failed", zap.Error(updateErr)) + } + s.mu.Lock() + hook := s.probeHook + s.mu.Unlock() + if hook != nil { + hook() + } +} + +// ListOrganizations returns the organizations the stored token can access. +func (s *Service) ListOrganizations(ctx context.Context) ([]SentryOrganization, error) { + client, err := s.clientFor(ctx) + if err != nil { + return nil, err + } + return client.ListOrganizations(ctx) +} + +// ListProjects returns the projects the stored token can access. +func (s *Service) ListProjects(ctx context.Context) ([]SentryProject, error) { + client, err := s.clientFor(ctx) + if err != nil { + return nil, err + } + return client.ListProjects(ctx) +} + +// SearchIssues runs a filtered search. The caller supplies the org/project to +// search — there is no install-wide default to fall back on. +func (s *Service) SearchIssues(ctx context.Context, filter SearchFilter, cursor string) (*SearchResult, error) { + client, err := s.clientFor(ctx) + if err != nil { + return nil, err + } + return client.SearchIssues(ctx, filter, cursor) +} + +// GetIssue loads a single issue by short ID or numeric ID. +func (s *Service) GetIssue(ctx context.Context, idOrShortID string) (*SentryIssue, error) { + client, err := s.clientFor(ctx) + if err != nil { + return nil, err + } + return client.GetIssue(ctx, idOrShortID) +} + +// clientFor returns the cached client, creating it if needed. +// It captures the generation counter before releasing the lock for I/O so +// that a concurrent invalidateClient call during the build window is not +// silently overwritten: if the counter changed the freshly built client is +// returned to the caller without being cached, and the next call rebuilds +// with the updated config. +func (s *Service) clientFor(ctx context.Context) (Client, error) { + s.mu.Lock() + if s.client != nil { + c := s.client + s.mu.Unlock() + return c, nil + } + gen := s.clientGen + s.mu.Unlock() + + cfg, err := s.store.GetConfig(ctx) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, ErrNotConfigured + } + secret := "" + if s.secrets != nil { + secret, err = s.secrets.Reveal(ctx, SecretKey) + if err != nil { + return nil, fmt.Errorf("read sentry secret: %w", err) + } + if secret == "" { + return nil, ErrNotConfigured + } + } + client := s.clientFn(cfg, secret) + s.mu.Lock() + defer s.mu.Unlock() + if s.client != nil { + // Another goroutine already cached a client; use that one. + return s.client, nil + } + if s.clientGen != gen { + // invalidateClient ran while we were doing I/O — the config/secret we + // read is now stale. Return the client to the caller for this call only; + // the next call will rebuild with fresh credentials. + return client, nil + } + s.client = client + return client, nil +} + +// invalidateClient drops the cached client so the next request rebuilds it. +// The generation counter is incremented so a concurrent clientFor build does +// not restore a client built from the now-stale config. +func (s *Service) invalidateClient() { + s.mu.Lock() + s.client = nil + s.clientGen++ + s.mu.Unlock() +} + +// resolveCredentials picks credentials for a test: inline if the request +// carries a secret, otherwise the stored secret. +func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*SentryConfig, string, error) { + cfg := &SentryConfig{AuthMethod: req.AuthMethod} + if req.Secret != "" { + return cfg, req.Secret, nil + } + if s.secrets == nil { + return nil, "", errors.New("no secret store configured") + } + secret, err := s.secrets.Reveal(ctx, SecretKey) + if err != nil { + s.log.Warn("sentry: secret reveal failed", zap.Error(err)) + return nil, "", fmt.Errorf("read sentry secret: %w", err) + } + if secret == "" { + return nil, "", errors.New("no auth token stored — paste one to test") + } + stored, storeErr := s.store.GetConfig(ctx) + if storeErr != nil { + s.log.Warn("sentry: load stored config for credential resolution failed", zap.Error(storeErr)) + } + if stored != nil && cfg.AuthMethod == "" { + cfg.AuthMethod = stored.AuthMethod + } + return cfg, secret, nil +} + +func validateConfigRequest(req *SetConfigRequest) error { + if req.AuthMethod == "" { + req.AuthMethod = AuthMethodAuthToken + } + if req.AuthMethod != AuthMethodAuthToken { + return fmt.Errorf("unknown auth method: %q", req.AuthMethod) + } + return nil +} diff --git a/apps/backend/internal/sentry/service_issue_watch.go b/apps/backend/internal/sentry/service_issue_watch.go new file mode 100644 index 0000000000..62f2b75976 --- /dev/null +++ b/apps/backend/internal/sentry/service_issue_watch.go @@ -0,0 +1,315 @@ +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" +) + +// 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) +} + +// 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 + } +} diff --git a/apps/backend/internal/sentry/service_issue_watch_test.go b/apps/backend/internal/sentry/service_issue_watch_test.go new file mode 100644 index 0000000000..bb3198febe --- /dev/null +++ b/apps/backend/internal/sentry/service_issue_watch_test.go @@ -0,0 +1,292 @@ +package sentry + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/integrations/optional" +) + +func intPtr(v int) *int { return &v } + +// withSearchResults returns a fakeClient that always returns the given issues +// for SearchIssues, ignoring the filter. +func (c *fakeClient) withSearchResults(issues []SentryIssue) *fakeClient { + c.searchIssuesFn = func(_ SearchFilter, _ string) (*SearchResult, error) { + return &SearchResult{Issues: issues, IsLast: true}, nil + } + return c +} + +func validFilter() SearchFilter { + return SearchFilter{OrgSlug: "acme", ProjectSlug: "frontend"} +} + +func TestService_CreateIssueWatch_DefaultsAndValidation(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + // Missing org/project is rejected — a watch must name what it polls. + for name, filter := range map[string]SearchFilter{ + "empty": {}, + "org only": {OrgSlug: "acme"}, + "whitespace org": {OrgSlug: " ", ProjectSlug: "frontend"}, + } { + if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: filter, + }); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("%s filter should be rejected, got %v", name, err) + } + } + + // Happy path assigns ID + defaults Enabled=true. + w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: validFilter(), + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if w.ID == "" { + t.Fatal("expected ID assigned") + } + if !w.Enabled { + t.Error("expected Enabled defaulted to true") + } + if w.Filter.OrgSlug != "acme" || w.Filter.ProjectSlug != "frontend" { + t.Errorf("filter not persisted: %+v", w.Filter) + } +} + +func TestService_UpdateIssueWatch_PartialPatch(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: validFilter(), + Prompt: "original", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Patch only the prompt; everything else must remain. + newPrompt := "updated" + updated, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{ + Prompt: &newPrompt, + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.Prompt != "updated" { + t.Errorf("prompt not patched: %q", updated.Prompt) + } + if updated.Filter.OrgSlug != "acme" || updated.WorkspaceID != created.WorkspaceID { + t.Errorf("unexpected mutation of unset fields: %+v", updated) + } + + // Patching the filter to something empty is rejected to keep watch rows valid. + empty := SearchFilter{} + if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{Filter: &empty}); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for empty filter patch, got %v", err) + } +} + +func TestService_IssueWatch_MaxInflightTasks(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + // A non-positive cap is rejected at create. + if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: validFilter(), + MaxInflightTasks: intPtr(0), + }); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for non-positive cap, got %v", err) + } + + // A positive cap round-trips through the store. + created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: validFilter(), + MaxInflightTasks: intPtr(3), + }) + if err != nil { + t.Fatalf("create: %v", err) + } + reloaded, err := f.svc.GetIssueWatch(ctx, created.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if reloaded.MaxInflightTasks == nil || *reloaded.MaxInflightTasks != 3 { + t.Fatalf("expected cap 3 persisted, got %v", reloaded.MaxInflightTasks) + } + + // PATCH tri-state: omitted leaves the cap unchanged. + unchanged, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{}) + if err != nil { + t.Fatalf("patch (omit): %v", err) + } + if unchanged.MaxInflightTasks == nil || *unchanged.MaxInflightTasks != 3 { + t.Errorf("omitted cap should stay 3, got %v", unchanged.MaxInflightTasks) + } + + // PATCH tri-state: explicit null clears the cap to uncapped. + uncapped, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{ + MaxInflightTasks: optional.Int{Present: true, Value: nil}, + }) + if err != nil { + t.Fatalf("patch (null): %v", err) + } + if uncapped.MaxInflightTasks != nil { + t.Errorf("null cap should clear to uncapped, got %v", *uncapped.MaxInflightTasks) + } + + // PATCH tri-state: a non-positive value is rejected. + if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{ + MaxInflightTasks: optional.Int{Present: true, Value: intPtr(-1)}, + }); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for negative cap patch, got %v", err) + } +} + +func TestService_CreateIssueWatch_RejectsOutOfRangeInterval(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + for _, n := range []int{1, 30, 59, 3601, 86400} { + _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: validFilter(), + PollIntervalSeconds: n, + }) + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for pollIntervalSeconds=%d, got %v", n, err) + } + } + // Zero is allowed (the store coerces to default). + if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: validFilter(), + }); err != nil { + t.Errorf("expected zero pollIntervalSeconds to be accepted, got %v", err) + } +} + +func TestService_UpdateIssueWatch_RejectsEmptyWorkflowFields(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + Filter: validFilter(), + }) + if err != nil { + t.Fatalf("create: %v", err) + } + empty := "" + for _, req := range []*UpdateIssueWatchRequest{ + {WorkflowID: &empty}, + {WorkflowStepID: &empty}, + } { + if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, req); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for %+v, got %v", req, err) + } + } +} + +func TestService_UpdateIssueWatch_NotFound(t *testing.T) { + f := newSvcFixture(t) + prompt := "x" + _, err := f.svc.UpdateIssueWatch(context.Background(), "ghost", &UpdateIssueWatchRequest{Prompt: &prompt}) + if !errors.Is(err, ErrIssueWatchNotFound) { + t.Errorf("expected ErrIssueWatchNotFound, got %v", err) + } +} + +func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, Secret: "sntrys_abc", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + f.client.withSearchResults([]SentryIssue{ + {ShortID: "PROJ-1", Title: "one", Permalink: "https://sentry.io/issues/PROJ-1"}, + {ShortID: "PROJ-2", Title: "two", Permalink: "https://sentry.io/issues/PROJ-2"}, + {ShortID: "PROJ-3", Title: "three", Permalink: "https://sentry.io/issues/PROJ-3"}, + }) + + w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + Filter: validFilter(), + }) + if err != nil { + t.Fatalf("create watch: %v", err) + } + + // Pre-seed PROJ-2 as already turned into a task. + if _, err := f.store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-2", "https://sentry.io/issues/PROJ-2"); err != nil { + t.Fatalf("seed reservation: %v", err) + } + + got, err := f.svc.CheckIssueWatch(ctx, w) + if err != nil { + t.Fatalf("check: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 unseen issues, got %d", len(got)) + } + for _, i := range got { + if i.ShortID == "PROJ-2" { + t.Error("PROJ-2 should have been filtered as already seen") + } + } + + refreshed, _ := f.store.GetIssueWatch(ctx, w.ID) + if refreshed.LastPolledAt == nil { + t.Error("expected last_polled_at stamped after check") + } +} + +func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, Secret: "sntrys_abc", + }); err != nil { + t.Fatalf("set config: %v", err) + } + f.client.searchIssuesFn = func(_ SearchFilter, _ string) (*SearchResult, error) { + return nil, errors.New("upstream 500") + } + w, _ := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + Filter: validFilter(), + }) + if _, err := f.svc.CheckIssueWatch(ctx, w); err == nil { + t.Error("expected error from search to surface to caller") + } + refreshed, _ := f.store.GetIssueWatch(ctx, w.ID) + if refreshed.LastPolledAt == nil { + t.Error("expected last_polled_at stamped even on search failure (liveness signal)") + } +} diff --git a/apps/backend/internal/sentry/service_test.go b/apps/backend/internal/sentry/service_test.go new file mode 100644 index 0000000000..828816005b --- /dev/null +++ b/apps/backend/internal/sentry/service_test.go @@ -0,0 +1,415 @@ +package sentry + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +// slowSecretStore wraps fakeSecretStore and calls a hook just before returning +// from Reveal, allowing tests to inject a concurrent invalidation mid-build. +type slowSecretStore struct { + *fakeSecretStore + revealHook func() +} + +func (s *slowSecretStore) Reveal(ctx context.Context, id string) (string, error) { + if s.revealHook != nil { + s.revealHook() + } + return s.fakeSecretStore.Reveal(ctx, id) +} + +type fakeSecretStore struct { + mu sync.Mutex + secrets map[string]string +} + +func newFakeSecretStore() *fakeSecretStore { + return &fakeSecretStore{secrets: map[string]string{}} +} + +func (f *fakeSecretStore) Reveal(_ context.Context, id string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.secrets[id] + if !ok { + return "", errors.New("not found") + } + return v, nil +} + +func (f *fakeSecretStore) Set(_ context.Context, id, _, value string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.secrets[id] = value + return nil +} + +func (f *fakeSecretStore) Delete(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.secrets, id) + return nil +} + +func (f *fakeSecretStore) Exists(_ context.Context, id string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.secrets[id] + return ok, nil +} + +// fakeClient is an in-memory Client for verifying service plumbing. +type fakeClient struct { + testAuthFn func() (*TestConnectionResult, error) + getIssueFn func(id string) (*SentryIssue, error) + listOrganizationsFn func() ([]SentryOrganization, error) + listProjectsFn func() ([]SentryProject, error) + searchIssuesFn func(filter SearchFilter, cursor string) (*SearchResult, error) +} + +func (c *fakeClient) TestAuth(context.Context) (*TestConnectionResult, error) { + if c.testAuthFn != nil { + return c.testAuthFn() + } + return &TestConnectionResult{OK: true}, nil +} + +func (c *fakeClient) ListOrganizations(context.Context) ([]SentryOrganization, error) { + if c.listOrganizationsFn != nil { + return c.listOrganizationsFn() + } + return nil, nil +} + +func (c *fakeClient) ListProjects(context.Context) ([]SentryProject, error) { + if c.listProjectsFn != nil { + return c.listProjectsFn() + } + return nil, nil +} + +func (c *fakeClient) SearchIssues(_ context.Context, filter SearchFilter, cursor string) (*SearchResult, error) { + if c.searchIssuesFn != nil { + return c.searchIssuesFn(filter, cursor) + } + return &SearchResult{}, nil +} + +func (c *fakeClient) GetIssue(_ context.Context, id string) (*SentryIssue, error) { + if c.getIssueFn != nil { + return c.getIssueFn(id) + } + return &SentryIssue{ID: id, ShortID: id}, nil +} + +type svcFixture struct { + svc *Service + store *Store + secrets *fakeSecretStore + client *fakeClient + factoryHit atomic.Int32 + probed chan struct{} +} + +func newSvcFixture(t *testing.T) *svcFixture { + t.Helper() + f := &svcFixture{ + store: newTestStore(t), + secrets: newFakeSecretStore(), + client: &fakeClient{}, + probed: make(chan struct{}, 8), + } + f.svc = NewService(f.store, f.secrets, func(_ *SentryConfig, _ string) Client { + f.factoryHit.Add(1) + return f.client + }, logger.Default()) + f.svc.SetProbeHook(func() { + select { + case f.probed <- struct{}{}: + default: + } + }) + return f +} + +func waitForAuthProbe(t *testing.T, f *svcFixture) *SentryConfig { + t.Helper() + select { + case <-f.probed: + cfg, err := f.svc.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get config after probe: %v", err) + } + return cfg + case <-time.After(2 * time.Second): + t.Fatalf("async probe hook did not fire within 2s") + return nil + } +} + +func TestService_SetConfig_PersistsAndStoresSecret(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + cfg, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, + Secret: "sntrys_xyz", + }) + if err != nil { + t.Fatalf("set: %v", err) + } + if cfg.AuthMethod != AuthMethodAuthToken { + t.Errorf("config not stored: %+v", cfg) + } + if !cfg.HasSecret { + t.Error("expected HasSecret=true") + } + if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "sntrys_xyz" { + t.Errorf("secret stored = %q", got) + } +} + +func TestService_GetConfig_HidesSecretValue(t *testing.T) { + // GetConfig must surface HasSecret=true but never the secret itself. + f := newSvcFixture(t) + ctx := context.Background() + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, Secret: "secret-tok", + }); err != nil { + t.Fatalf("set: %v", err) + } + cfg, err := f.svc.GetConfig(ctx) + if err != nil || cfg == nil { + t.Fatalf("get: %v / %v", err, cfg) + } + if !cfg.HasSecret { + t.Error("expected HasSecret=true") + } +} + +func TestService_SetConfig_EmptySecret_KeepsExisting(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, Secret: "first", + }); err != nil { + t.Fatalf("initial: %v", err) + } + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, + }); err != nil { + t.Fatalf("update: %v", err) + } + if got, _ := f.secrets.Reveal(ctx, SecretKey); got != "first" { + t.Errorf("secret should be preserved, got %q", got) + } +} + +func TestService_SetConfig_ProbesAsync(t *testing.T) { + f := newSvcFixture(t) + f.client.testAuthFn = func() (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil + } + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, Secret: "tok", + }); err != nil { + t.Fatalf("set: %v", err) + } + cfg := waitForAuthProbe(t, f) + if !cfg.LastOk { + t.Errorf("expected LastOk=true after async probe, got %+v", cfg) + } +} + +func TestService_Validation_RejectsBadAuth(t *testing.T) { + f := newSvcFixture(t) + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{ + AuthMethod: "bogus", + }); err == nil { + t.Error("expected validation error for unknown auth method") + } +} + +func TestService_Validation_DefaultsAuthMethod(t *testing.T) { + f := newSvcFixture(t) + if _, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{Secret: "tok"}); err != nil { + t.Fatalf("expected default auth method, got error: %v", err) + } +} + +func TestService_TestConnection_InlineSecret(t *testing.T) { + f := newSvcFixture(t) + called := false + f.client.testAuthFn = func() (*TestConnectionResult, error) { + called = true + return &TestConnectionResult{OK: true, DisplayName: "Alice"}, nil + } + res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, Secret: "inline-tok", + }) + if err != nil || !called { + t.Fatalf("called=%v err=%v", called, err) + } + if !res.OK { + t.Errorf("result: %+v", res) + } +} + +func TestService_TestConnection_UsesStoredSecretWhenRequestEmpty(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, Secret: "stored", + }); err != nil { + t.Fatalf("set: %v", err) + } + waitForAuthProbe(t, f) + var sawSecret string + f.svc.clientFn = func(_ *SentryConfig, secret string) Client { + sawSecret = secret + return f.client + } + if _, err := f.svc.TestConnection(ctx, &SetConfigRequest{AuthMethod: AuthMethodAuthToken}); err != nil { + t.Fatalf("test: %v", err) + } + if sawSecret != "stored" { + t.Errorf("expected stored secret used, got %q", sawSecret) + } +} + +func TestService_TestConnection_NoStoredSecret_ReturnsFailure(t *testing.T) { + f := newSvcFixture(t) + res, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if res.OK { + t.Error("expected OK=false when no secret stored") + } +} + +func TestService_SearchIssues_PassesFilterThrough(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, Secret: "t", + }); err != nil { + t.Fatalf("set: %v", err) + } + waitForAuthProbe(t, f) + var seenOrg string + f.client.searchIssuesFn = func(filter SearchFilter, _ string) (*SearchResult, error) { + seenOrg = filter.OrgSlug + return &SearchResult{IsLast: true}, nil + } + if _, err := f.svc.SearchIssues(ctx, SearchFilter{OrgSlug: "acme"}, ""); err != nil { + t.Fatalf("search: %v", err) + } + if seenOrg != "acme" { + t.Errorf("expected org passed through, got %q", seenOrg) + } +} + +func TestService_GetIssue_NotConfigured(t *testing.T) { + f := newSvcFixture(t) + _, err := f.svc.GetIssue(context.Background(), "PROJ-1") + if !errors.Is(err, ErrNotConfigured) { + t.Errorf("expected ErrNotConfigured, got %v", err) + } +} + +func TestService_DeleteConfig_RemovesSecretAndCache(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAuthToken, Secret: "t", + }) + waitForAuthProbe(t, f) + if err := f.svc.DeleteConfig(ctx); err != nil { + t.Fatalf("delete: %v", err) + } + if exists, _ := f.secrets.Exists(ctx, SecretKey); exists { + t.Error("expected secret removed") + } + cfg, _ := f.svc.GetConfig(ctx) + if cfg != nil { + t.Errorf("expected config gone, got %+v", cfg) + } +} + +// TestService_ClientFor_InvalidateDuringBuild verifies the TOCTOU fix: when +// invalidateClient runs while clientFor is blocked on I/O, the freshly built +// (now-stale) client must not be stored in s.client. The subsequent call must +// rebuild, hitting the factory a second time. +func TestService_ClientFor_InvalidateDuringBuild(t *testing.T) { + fakes := newFakeSecretStore() + _ = fakes.Set(context.Background(), SecretKey, "tok", "sntrys_xyz") + + store := newTestStore(t) + ctx := context.Background() + // Seed a config row so clientFor gets past the nil-config check. + if err := store.UpsertConfig(ctx, &SentryConfig{ + AuthMethod: AuthMethodAuthToken, + }); err != nil { + t.Fatalf("seed config: %v", err) + } + + var factoryHit atomic.Int32 + client := &fakeClient{} + + // invalidateCh is closed by the Reveal hook to signal the main goroutine + // to call invalidateClient while the first clientFor is mid-I/O. + invalidateCh := make(chan struct{}) + // doneCh is closed by the Reveal hook after it has signalled. + doneCh := make(chan struct{}) + + slow := &slowSecretStore{fakeSecretStore: fakes} + + svc := NewService(store, slow, func(_ *SentryConfig, _ string) Client { + factoryHit.Add(1) + return client + }, logger.Default()) + + slow.revealHook = func() { + // Signal that we're mid-I/O, then wait for the invalidation to complete. + close(invalidateCh) + // Give the invalidation goroutine time to run. + time.Sleep(10 * time.Millisecond) + close(doneCh) + } + + // First clientFor call — will block in Reveal while we invalidate. + errCh := make(chan error, 1) + go func() { + _, err := svc.clientFor(ctx) + errCh <- err + }() + + // Wait until clientFor is inside Reveal, then invalidate. + <-invalidateCh + svc.invalidateClient() + + // Wait for the first clientFor to finish. + if err := <-errCh; err != nil { + t.Fatalf("first clientFor: %v", err) + } + + // The invalidation must have reset the cache, so s.client should be nil now. + // A second clientFor must hit the factory again (not return the cached stale client). + slow.revealHook = nil // no more blocking + if _, err := svc.clientFor(ctx); err != nil { + t.Fatalf("second clientFor: %v", err) + } + + if got := factoryHit.Load(); got < 2 { + t.Errorf("expected factory called at least twice (once per clientFor after invalidation), got %d", got) + } +} diff --git a/apps/backend/internal/sentry/store.go b/apps/backend/internal/sentry/store.go new file mode 100644 index 0000000000..f5fcad384e --- /dev/null +++ b/apps/backend/internal/sentry/store.go @@ -0,0 +1,220 @@ +package sentry + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +// Store persists the install-wide Sentry configuration. The secret token is +// delegated to the shared encrypted secret store and not stored here. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewStore creates a new Store and initializes the schema if needed. +func NewStore(writer, reader *sqlx.DB) (*Store, error) { + s := &Store{db: writer, ro: reader} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("sentry schema init: %w", err) + } + return s, nil +} + +const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS sentry_configs ( + id TEXT PRIMARY KEY CHECK(id = 'singleton'), + auth_method TEXT NOT NULL, + last_checked_at DATETIME, + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sentry_issue_watches ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, + workflow_step_id TEXT NOT NULL, + filter_json TEXT NOT NULL DEFAULT '{}', + agent_profile_id TEXT NOT NULL DEFAULT '', + executor_profile_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT 1, + poll_interval_seconds INTEGER NOT NULL DEFAULT 300, + max_inflight_tasks INTEGER DEFAULT 5, + last_polled_at DATETIME, + last_error TEXT NOT NULL DEFAULT '', + last_error_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sentry_issue_watches_workspace + ON sentry_issue_watches(workspace_id); + + CREATE TABLE IF NOT EXISTS sentry_issue_watch_tasks ( + id TEXT PRIMARY KEY, + issue_watch_id TEXT NOT NULL, + issue_short_id TEXT NOT NULL, + issue_url TEXT NOT NULL, + task_id TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + UNIQUE(issue_watch_id, issue_short_id), + FOREIGN KEY(issue_watch_id) REFERENCES sentry_issue_watches(id) ON DELETE CASCADE + ); +` + +// singletonID is the synthetic primary key of the (only) row in sentry_configs. +const singletonID = "singleton" + +func (s *Store) initSchema() error { + if _, err := s.db.Exec(createTablesSQL); err != nil { + return err + } + if err := s.addMaxInflightTasksColumn(); err != nil { + return err + } + return s.addIssueWatchLastErrorColumns() +} + +// addMaxInflightTasksColumn brings older databases up to the current schema by +// adding the max_inflight_tasks column to sentry_issue_watches when missing. +// Existing rows backfill to the default (5). A fresh install hits the +// column-already-present branch since createTablesSQL declares the column. +func (s *Store) addMaxInflightTasksColumn() error { + cols, err := s.tableColumns("sentry_issue_watches") + if err != nil { + return err + } + if len(cols) == 0 { + return nil + } + if _, ok := cols["max_inflight_tasks"]; ok { + return nil + } + if _, err := s.db.Exec(`ALTER TABLE sentry_issue_watches ADD COLUMN max_inflight_tasks INTEGER DEFAULT 5`); err != nil { + return fmt.Errorf("add max_inflight_tasks column: %w", err) + } + return nil +} + +// addIssueWatchLastErrorColumns brings older databases up to the current +// schema by appending last_error / last_error_at to sentry_issue_watches when +// missing. Fresh installs hit the column-already-present branch since +// createTablesSQL declares both columns. Idempotent — column lookup before +// each ALTER avoids the "duplicate column name" error. +func (s *Store) addIssueWatchLastErrorColumns() error { + cols, err := s.tableColumns("sentry_issue_watches") + if err != nil { + return err + } + if len(cols) == 0 { + return nil + } + if _, ok := cols["last_error"]; !ok { + if _, err := s.db.Exec(`ALTER TABLE sentry_issue_watches ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add last_error column: %w", err) + } + } + if _, ok := cols["last_error_at"]; !ok { + if _, err := s.db.Exec(`ALTER TABLE sentry_issue_watches ADD COLUMN last_error_at DATETIME`); err != nil { + return fmt.Errorf("add last_error_at column: %w", err) + } + } + return nil +} + +// tableColumns returns the set of column names for a table via PRAGMA +// table_info, used by the lightweight ADD COLUMN migrations above. +func (s *Store) tableColumns(table string) (map[string]struct{}, error) { + rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + cols := make(map[string]struct{}) + for rows.Next() { + var ( + cid int + name string + ctype string + notnull int + dflt sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return nil, err + } + cols[name] = struct{}{} + } + return cols, rows.Err() +} + +const selectConfigColumns = `auth_method, + last_checked_at, last_ok, last_error, created_at, updated_at` + +// GetConfig returns the singleton Sentry config, or nil when no row exists. +func (s *Store) GetConfig(ctx context.Context) (*SentryConfig, error) { + var cfg SentryConfig + err := s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM sentry_configs WHERE id = ?`, singletonID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertConfig inserts or updates the singleton config row. The last_* health +// columns are owned by the poller and written via UpdateAuthHealth. +func (s *Store) UpsertConfig(ctx context.Context, cfg *SentryConfig) error { + now := time.Now().UTC() + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = now + } + cfg.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO sentry_configs (id, auth_method, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + auth_method = excluded.auth_method, + updated_at = excluded.updated_at`, + singletonID, cfg.AuthMethod, cfg.CreatedAt, cfg.UpdatedAt) + return err +} + +// DeleteConfig removes the singleton config row. +func (s *Store) DeleteConfig(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sentry_configs WHERE id = ?`, singletonID) + return err +} + +// HasConfig reports whether the singleton row exists. Used by the auth-health +// poller to decide whether to probe at all. +func (s *Store) HasConfig(ctx context.Context) (bool, error) { + var present int + err := s.ro.GetContext(ctx, &present, + `SELECT COUNT(*) FROM sentry_configs WHERE id = ?`, singletonID) + if err != nil { + return false, err + } + return present > 0, nil +} + +// UpdateAuthHealth records the result of a credential probe. +func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg string, checkedAt time.Time) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE sentry_configs + SET last_checked_at = ?, last_ok = ?, last_error = ? + WHERE id = ?`, + checkedAt, ok, errMsg, singletonID) + return err +} diff --git a/apps/backend/internal/sentry/store_issue_watch.go b/apps/backend/internal/sentry/store_issue_watch.go new file mode 100644 index 0000000000..5c1c4cc97d --- /dev/null +++ b/apps/backend/internal/sentry/store_issue_watch.go @@ -0,0 +1,318 @@ +package sentry + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +// issueWatchRow mirrors IssueWatch but stores the filter as a raw JSON string +// the way SQLite holds it. The store marshals/unmarshals at this boundary so +// service callers see a typed SearchFilter and never have to think about JSON. +type issueWatchRow struct { + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + WorkflowID string `db:"workflow_id"` + WorkflowStepID string `db:"workflow_step_id"` + FilterJSON string `db:"filter_json"` + AgentProfileID string `db:"agent_profile_id"` + ExecutorProfileID string `db:"executor_profile_id"` + Prompt string `db:"prompt"` + Enabled bool `db:"enabled"` + PollIntervalSeconds int `db:"poll_interval_seconds"` + MaxInflightTasks sql.NullInt64 `db:"max_inflight_tasks"` + LastPolledAt *time.Time `db:"last_polled_at"` + LastError string `db:"last_error"` + LastErrorAt *time.Time `db:"last_error_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (r *issueWatchRow) toIssueWatch() (*IssueWatch, error) { + var filter SearchFilter + if r.FilterJSON != "" { + if err := json.Unmarshal([]byte(r.FilterJSON), &filter); err != nil { + return nil, fmt.Errorf("decode filter: %w", err) + } + } + var maxInflight *int + if r.MaxInflightTasks.Valid { + v := int(r.MaxInflightTasks.Int64) + maxInflight = &v + } + return &IssueWatch{ + ID: r.ID, + WorkspaceID: r.WorkspaceID, + WorkflowID: r.WorkflowID, + WorkflowStepID: r.WorkflowStepID, + Filter: filter, + AgentProfileID: r.AgentProfileID, + ExecutorProfileID: r.ExecutorProfileID, + Prompt: r.Prompt, + Enabled: r.Enabled, + PollIntervalSeconds: r.PollIntervalSeconds, + MaxInflightTasks: maxInflight, + LastPolledAt: r.LastPolledAt, + LastError: r.LastError, + LastErrorAt: r.LastErrorAt, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + }, nil +} + +// nullableInt converts a *int into a value suitable for a nullable SQL column. +// A nil pointer becomes a SQL NULL; a non-nil pointer becomes the underlying +// int. Used for max_inflight_tasks where NULL means "uncapped". +func nullableInt(v *int) interface{} { + if v == nil { + return nil + } + return *v +} + +func encodeFilter(f SearchFilter) (string, error) { + b, err := json.Marshal(f) + if err != nil { + return "", fmt.Errorf("encode filter: %w", err) + } + return string(b), nil +} + +// issueWatchInsertColumns lists the writable column names in row-insert order. +// SELECTs use issueWatchSelectColumns which wraps the nullable last_error in +// COALESCE so older databases (pre-self-heal migration) read back as empty +// strings rather than NULL. +const issueWatchInsertColumns = `id, workspace_id, workflow_id, workflow_step_id, filter_json, + agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, max_inflight_tasks, last_polled_at, + last_error, last_error_at, + created_at, updated_at` + +const issueWatchSelectColumns = `id, workspace_id, workflow_id, workflow_step_id, filter_json, + agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, max_inflight_tasks, last_polled_at, + COALESCE(last_error, '') AS last_error, last_error_at, + created_at, updated_at` + +// CreateIssueWatch persists a new issue watch row. ID and timestamps are +// assigned here so callers can pass a partially-populated struct. +func (s *Store) CreateIssueWatch(ctx context.Context, w *IssueWatch) error { + if w.ID == "" { + w.ID = uuid.New().String() + } + now := time.Now().UTC() + w.CreatedAt = now + w.UpdatedAt = now + if w.PollIntervalSeconds <= 0 { + w.PollIntervalSeconds = DefaultIssueWatchPollInterval + } + filterJSON, err := encodeFilter(w.Filter) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO sentry_issue_watches (`+issueWatchInsertColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + w.ID, w.WorkspaceID, w.WorkflowID, w.WorkflowStepID, filterJSON, + w.AgentProfileID, w.ExecutorProfileID, w.Prompt, w.Enabled, + w.PollIntervalSeconds, nullableInt(w.MaxInflightTasks), w.LastPolledAt, + w.LastError, w.LastErrorAt, + w.CreatedAt, w.UpdatedAt) + return err +} + +// GetIssueWatch returns a single watch by ID, or nil when no row matches. +func (s *Store) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { + var row issueWatchRow + err := s.ro.GetContext(ctx, &row, + `SELECT `+issueWatchSelectColumns+` FROM sentry_issue_watches WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return row.toIssueWatch() +} + +// ListIssueWatches returns all watches configured for a workspace. +func (s *Store) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { + var rows []issueWatchRow + err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchSelectColumns+` FROM sentry_issue_watches + WHERE workspace_id = ? ORDER BY created_at`, workspaceID) + if err != nil { + return nil, err + } + return materializeWatches(rows) +} + +// ListAllIssueWatches returns every watch across all workspaces. +func (s *Store) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + var rows []issueWatchRow + err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchSelectColumns+` FROM sentry_issue_watches ORDER BY workspace_id, created_at`) + if err != nil { + return nil, err + } + return materializeWatches(rows) +} + +// ListEnabledIssueWatches returns every enabled watch across all workspaces, +// used by the poller to decide what to query each tick. +func (s *Store) ListEnabledIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + var rows []issueWatchRow + err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchSelectColumns+` FROM sentry_issue_watches + WHERE enabled = 1 ORDER BY created_at`) + if err != nil { + return nil, err + } + return materializeWatches(rows) +} + +func materializeWatches(rows []issueWatchRow) ([]*IssueWatch, error) { + out := make([]*IssueWatch, 0, len(rows)) + for i := range rows { + w, err := rows[i].toIssueWatch() + if err != nil { + return nil, err + } + out = append(out, w) + } + return out, nil +} + +// UpdateIssueWatch overwrites the mutable fields of an existing watch row. +// updated_at is bumped automatically. last_polled_at is deliberately NOT in the +// UPDATE: that column is owned exclusively by UpdateIssueWatchLastPolled, so a +// concurrent poller stamp can't be clobbered by a load-modify-write edit that +// carried a stale (often nil) pre-fetch value. +func (s *Store) UpdateIssueWatch(ctx context.Context, w *IssueWatch) error { + w.UpdatedAt = time.Now().UTC() + if w.PollIntervalSeconds <= 0 { + w.PollIntervalSeconds = DefaultIssueWatchPollInterval + } + filterJSON, err := encodeFilter(w.Filter) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + UPDATE sentry_issue_watches SET workflow_id = ?, workflow_step_id = ?, filter_json = ?, + agent_profile_id = ?, executor_profile_id = ?, prompt = ?, + enabled = ?, poll_interval_seconds = ?, max_inflight_tasks = ?, updated_at = ? + WHERE id = ?`, + w.WorkflowID, w.WorkflowStepID, filterJSON, + w.AgentProfileID, w.ExecutorProfileID, w.Prompt, + w.Enabled, w.PollIntervalSeconds, nullableInt(w.MaxInflightTasks), w.UpdatedAt, w.ID) + return err +} + +// UpdateIssueWatchLastPolled stamps the last-polled timestamp without touching +// the rest of the row. +func (s *Store) UpdateIssueWatchLastPolled(ctx context.Context, id string, t time.Time) error { + _, err := s.db.ExecContext(ctx, + `UPDATE sentry_issue_watches SET last_polled_at = ?, updated_at = ? WHERE id = ?`, + t, time.Now().UTC(), id) + return err +} + +// DisableIssueWatchWithError flips a watch to disabled and stamps a +// human-readable cause. Called by the dispatch coordinator's self-heal path +// when the watch's bound agent profile has been soft-deleted, so the settings +// UI can explain why the watch stopped firing. Mirrors the Linear/Jira stores. +func (s *Store) DisableIssueWatchWithError(ctx context.Context, id, cause string) error { + now := time.Now().UTC() + _, err := s.db.ExecContext(ctx, + `UPDATE sentry_issue_watches + SET enabled = 0, last_error = ?, last_error_at = ?, updated_at = ? + WHERE id = ?`, + cause, now, now, id) + return err +} + +// DeleteIssueWatch removes a watch and its dedup rows in a single transaction. +// The explicit child DELETE guards older databases where foreign_keys may not +// have been enabled at attach time. +func (s *Store) DeleteIssueWatch(ctx context.Context, id string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM sentry_issue_watch_tasks WHERE issue_watch_id = ?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM sentry_issue_watches WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() +} + +// ReserveIssueWatchTask atomically claims a slot for a (watch, issue) pair via +// INSERT OR IGNORE. Returns true when this caller won the race and should +// proceed to create the task. +func (s *Store) ReserveIssueWatchTask(ctx context.Context, watchID, shortID, issueURL string) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT OR IGNORE INTO sentry_issue_watch_tasks (id, issue_watch_id, issue_short_id, issue_url, task_id, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + uuid.New().String(), watchID, shortID, issueURL, "", time.Now().UTC()) + if err != nil { + return false, err + } + rows, err := res.RowsAffected() + if err != nil { + return false, err + } + return rows == 1, nil +} + +// AssignIssueWatchTaskID stamps the created task ID onto a previously-reserved +// dedup row. +func (s *Store) AssignIssueWatchTaskID(ctx context.Context, watchID, shortID, taskID string) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE sentry_issue_watch_tasks SET task_id = ? + WHERE issue_watch_id = ? AND issue_short_id = ?`, + taskID, watchID, shortID) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("assign task ID: reservation row not found for watch=%s issue=%s", watchID, shortID) + } + return nil +} + +// ReleaseIssueWatchTask drops a reservation so the next poll can retry. Used +// when task creation fails after a successful reserve. +func (s *Store) ReleaseIssueWatchTask(ctx context.Context, watchID, shortID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM sentry_issue_watch_tasks WHERE issue_watch_id = ? AND issue_short_id = ?`, + watchID, shortID) + return err +} + +// ListSeenIssueShortIDs returns the set of issue short IDs already reserved +// against a watch. +func (s *Store) ListSeenIssueShortIDs(ctx context.Context, watchID string) (map[string]struct{}, error) { + var keys []string + err := s.ro.SelectContext(ctx, &keys, + `SELECT issue_short_id FROM sentry_issue_watch_tasks WHERE issue_watch_id = ?`, watchID) + if err != nil { + return nil, err + } + out := make(map[string]struct{}, len(keys)) + for _, k := range keys { + out[k] = struct{}{} + } + return out, nil +} diff --git a/apps/backend/internal/sentry/store_issue_watch_disable_test.go b/apps/backend/internal/sentry/store_issue_watch_disable_test.go new file mode 100644 index 0000000000..116f580e67 --- /dev/null +++ b/apps/backend/internal/sentry/store_issue_watch_disable_test.go @@ -0,0 +1,59 @@ +package sentry + +import ( + "context" + "testing" + "time" +) + +// TestDisableIssueWatchWithError_SetsDisabledStateAndStampsError pins the +// self-heal contract: when an orphaned watcher is detected (its agent +// profile has been soft-deleted), DisableIssueWatchWithError flips Enabled +// to false, stamps a human-readable LastError, and records LastErrorAt so +// the settings UI can show a "disabled X ago because Y" banner. Mirrors the +// Linear/Jira store contract. +func TestDisableIssueWatchWithError_SetsDisabledStateAndStampsError(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + watch := &IssueWatch{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + AgentProfileID: "deleted-profile", + Enabled: true, + } + if err := store.CreateIssueWatch(ctx, watch); err != nil { + t.Fatalf("create: %v", err) + } + + const cause = `agent profile "Removed Kilo" (deleted-profile) was removed` + // Widen the window by 1s on each side: SQLite stores datetimes at second + // precision so a sub-second time.Now() reading can land outside a tighter + // bracket after round-tripping through the DB. + before := time.Now().UTC().Add(-time.Second) + if err := store.DisableIssueWatchWithError(ctx, watch.ID, cause); err != nil { + t.Fatalf("disable: %v", err) + } + after := time.Now().UTC().Add(time.Second) + + got, err := store.GetIssueWatch(ctx, watch.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected watch row, got nil") + } + if got.Enabled { + t.Error("Enabled should be false after self-heal") + } + if got.LastError != cause { + t.Errorf("LastError = %q, want %q", got.LastError, cause) + } + if got.LastErrorAt == nil { + t.Fatal("LastErrorAt should be set") + } + if got.LastErrorAt.Before(before) || got.LastErrorAt.After(after) { + t.Errorf("LastErrorAt %v outside expected window [%v, %v]", got.LastErrorAt, before, after) + } +} diff --git a/apps/backend/internal/sentry/store_issue_watch_test.go b/apps/backend/internal/sentry/store_issue_watch_test.go new file mode 100644 index 0000000000..a90e5a4aa0 --- /dev/null +++ b/apps/backend/internal/sentry/store_issue_watch_test.go @@ -0,0 +1,264 @@ +package sentry + +import ( + "context" + "testing" + "time" +) + +func newTestIssueWatch(workspaceID string) *IssueWatch { + return &IssueWatch{ + WorkspaceID: workspaceID, + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + Filter: SearchFilter{ + OrgSlug: "acme", + ProjectSlug: "frontend", + Levels: []string{"error"}, + }, + Prompt: "Investigate {{issue.short_id}}", + Enabled: true, + } +} + +func TestStore_IssueWatch_CreateGet(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if w.ID == "" { + t.Fatal("expected ID assigned") + } + if w.PollIntervalSeconds != DefaultIssueWatchPollInterval { + t.Errorf("expected default poll interval %d, got %d", DefaultIssueWatchPollInterval, w.PollIntervalSeconds) + } + if w.CreatedAt.IsZero() || w.UpdatedAt.IsZero() { + t.Error("expected timestamps assigned") + } + + got, err := store.GetIssueWatch(ctx, w.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected watch, got nil") + } + if got.WorkspaceID != w.WorkspaceID || got.Prompt != w.Prompt { + t.Errorf("round-trip mismatch: %+v vs %+v", got, w) + } + // Filter survives the JSON round-trip. + if got.Filter.OrgSlug != "acme" || got.Filter.ProjectSlug != "frontend" || + len(got.Filter.Levels) != 1 || got.Filter.Levels[0] != "error" { + t.Errorf("filter round-trip failed: %+v", got.Filter) + } +} + +func TestStore_IssueWatch_ListByWorkspace(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w1 := newTestIssueWatch("ws-1") + w2 := newTestIssueWatch("ws-1") + w3 := newTestIssueWatch("ws-2") + + for _, w := range []*IssueWatch{w1, w2, w3} { + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + } + + got, err := store.ListIssueWatches(ctx, "ws-1") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 watches for ws-1, got %d", len(got)) + } + for _, w := range got { + if w.WorkspaceID != "ws-1" { + t.Errorf("workspace leaked into list: %s", w.WorkspaceID) + } + } +} + +func TestStore_IssueWatch_ListEnabled(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + enabled := newTestIssueWatch("ws-1") + disabled := newTestIssueWatch("ws-2") + disabled.Enabled = false + for _, w := range []*IssueWatch{enabled, disabled} { + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + } + + got, err := store.ListEnabledIssueWatches(ctx) + if err != nil { + t.Fatalf("list enabled: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected only the enabled watch, got %d", len(got)) + } + if got[0].ID != enabled.ID { + t.Errorf("expected enabled watch returned, got %s", got[0].ID) + } +} + +func TestStore_IssueWatch_Update(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + originalCreated := w.CreatedAt + w.Filter = SearchFilter{OrgSlug: "acme", ProjectSlug: "backend", Query: "deadlock"} + w.Enabled = false + w.PollIntervalSeconds = 60 + if err := store.UpdateIssueWatch(ctx, w); err != nil { + t.Fatalf("update: %v", err) + } + got, _ := store.GetIssueWatch(ctx, w.ID) + if got.Filter.Query != "deadlock" || got.Enabled || got.PollIntervalSeconds != 60 { + t.Errorf("update did not persist: %+v", got) + } + if !got.CreatedAt.Equal(originalCreated) { + t.Errorf("update must not change created_at: %v vs %v", got.CreatedAt, originalCreated) + } +} + +func TestStore_IssueWatch_LastPolledStamp(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + t1 := time.Now().UTC().Truncate(time.Second) + if err := store.UpdateIssueWatchLastPolled(ctx, w.ID, t1); err != nil { + t.Fatalf("stamp: %v", err) + } + got, _ := store.GetIssueWatch(ctx, w.ID) + if got.LastPolledAt == nil || !got.LastPolledAt.Equal(t1) { + t.Errorf("expected last_polled_at=%v, got %v", t1, got.LastPolledAt) + } +} + +func TestStore_IssueWatch_DeleteCascadesDedupRows(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1"); err != nil { + t.Fatalf("reserve: %v", err) + } + + if err := store.DeleteIssueWatch(ctx, w.ID); err != nil { + t.Fatalf("delete: %v", err) + } + gone, _ := store.GetIssueWatch(ctx, w.ID) + if gone != nil { + t.Errorf("expected watch deleted, got %+v", gone) + } + // Re-creating with the same ID and reserving the same short_id should + // succeed without UNIQUE collision — proves cascade ran. + w2 := newTestIssueWatch("ws-1") + w2.ID = w.ID + if err := store.CreateIssueWatch(ctx, w2); err != nil { + t.Fatalf("recreate watch: %v", err) + } + ok, err := store.ReserveIssueWatchTask(ctx, w2.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1") + if err != nil { + t.Fatalf("re-reserve: %v", err) + } + if !ok { + t.Error("expected re-reserve to succeed after cascade delete") + } +} + +func TestStore_IssueWatchTask_ReserveDedup(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + + first, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-7", "https://sentry.io/issues/PROJ-7") + if err != nil { + t.Fatalf("first reserve: %v", err) + } + if !first { + t.Error("expected first reservation to win") + } + + second, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-7", "https://sentry.io/issues/PROJ-7") + if err != nil { + t.Fatalf("second reserve: %v", err) + } + if second { + t.Error("expected second reservation to lose due to UNIQUE constraint") + } + + seen, err := store.ListSeenIssueShortIDs(ctx, w.ID) + if err != nil { + t.Fatalf("list seen: %v", err) + } + if _, ok := seen["PROJ-7"]; !ok { + t.Error("expected PROJ-7 in seen set after reservation") + } +} + +func TestStore_IssueWatchTask_AssignTaskID(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1"); err != nil { + t.Fatalf("reserve: %v", err) + } + if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-1", "task-abc"); err != nil { + t.Fatalf("assign: %v", err) + } + + if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-NOPE", "task-zzz"); err == nil { + t.Error("expected error for missing reservation, got nil") + } +} + +func TestStore_IssueWatchTask_Release(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1"); err != nil { + t.Fatalf("reserve: %v", err) + } + if err := store.ReleaseIssueWatchTask(ctx, w.ID, "PROJ-1"); err != nil { + t.Fatalf("release: %v", err) + } + again, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1") + if err != nil { + t.Fatalf("re-reserve: %v", err) + } + if !again { + t.Error("expected reservation to succeed after release") + } +} diff --git a/apps/backend/internal/sentry/store_test.go b/apps/backend/internal/sentry/store_test.go new file mode 100644 index 0000000000..edb6c9873e --- /dev/null +++ b/apps/backend/internal/sentry/store_test.go @@ -0,0 +1,132 @@ +package sentry + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func TestStore_UpsertGetDelete(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + cfg := &SentryConfig{AuthMethod: AuthMethodAuthToken} + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, err := store.GetConfig(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected config, got nil") + } + if got.AuthMethod != cfg.AuthMethod { + t.Errorf("round-trip mismatch: %+v vs %+v", got, cfg) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Error("timestamps not set") + } + + // Idempotent upsert does not duplicate rows. + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("update upsert: %v", err) + } + + if err := store.DeleteConfig(ctx); err != nil { + t.Fatalf("delete: %v", err) + } + gone, err := store.GetConfig(ctx) + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if gone != nil { + t.Errorf("expected nil after delete, got %+v", gone) + } +} + +func TestStore_GetConfig_Missing(t *testing.T) { + store := newTestStore(t) + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg != nil { + t.Errorf("expected nil for missing config, got %+v", cfg) + } +} + +func TestStore_HasConfig(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + has, _ := store.HasConfig(ctx) + if has { + t.Errorf("expected HasConfig=false on empty store") + } + if err := store.UpsertConfig(ctx, &SentryConfig{AuthMethod: AuthMethodAuthToken}); err != nil { + t.Fatalf("upsert: %v", err) + } + has, _ = store.HasConfig(ctx) + if !has { + t.Errorf("expected HasConfig=true after upsert") + } +} + +func TestStore_UpdateAuthHealth(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.UpsertConfig(ctx, &SentryConfig{AuthMethod: AuthMethodAuthToken}); err != nil { + t.Fatalf("upsert: %v", err) + } + + cfg, _ := store.GetConfig(ctx) + if cfg.LastCheckedAt != nil { + t.Errorf("expected nil last_checked_at on fresh row, got %v", cfg.LastCheckedAt) + } + + now := time.Now().UTC().Truncate(time.Second) + if err := store.UpdateAuthHealth(ctx, true, "", now); err != nil { + t.Fatalf("update ok: %v", err) + } + cfg, _ = store.GetConfig(ctx) + if !cfg.LastOk { + t.Error("expected last_ok=true after successful probe") + } + if cfg.LastCheckedAt == nil || !cfg.LastCheckedAt.Equal(now) { + t.Errorf("expected last_checked_at=%v, got %v", now, cfg.LastCheckedAt) + } + + failAt := now.Add(time.Minute) + if err := store.UpdateAuthHealth(ctx, false, "401 unauthorized", failAt); err != nil { + t.Fatalf("update fail: %v", err) + } + cfg, _ = store.GetConfig(ctx) + if cfg.LastOk { + t.Error("expected last_ok=false after failure") + } + if cfg.LastError != "401 unauthorized" { + t.Errorf("expected last_error preserved, got %q", cfg.LastError) + } +} diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx index 4210f4f447..bed2b3a9df 100644 --- a/apps/web/app/settings/integrations/page.tsx +++ b/apps/web/app/settings/integrations/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { IconBrandGithub, IconBrandGitlab, + IconBrandSentry, IconBrandSlack, IconHexagon, IconTicket, @@ -33,6 +34,12 @@ const INTEGRATIONS = [ description: "Personal API key and team defaults.", Icon: IconHexagon, }, + { + href: "/settings/integrations/sentry", + label: "Sentry", + description: "Auth token, org/project defaults, and issue browsing.", + Icon: IconBrandSentry, + }, { href: "/settings/integrations/slack", label: "Slack", diff --git a/apps/web/app/settings/integrations/sentry/page.tsx b/apps/web/app/settings/integrations/sentry/page.tsx new file mode 100644 index 0000000000..1977cca074 --- /dev/null +++ b/apps/web/app/settings/integrations/sentry/page.tsx @@ -0,0 +1,5 @@ +import { SentryIntegrationPage } from "@/components/sentry/sentry-settings"; + +export default function IntegrationsSentryPage() { + return ; +} diff --git a/apps/web/components/sentry/sentry-issue-button.tsx b/apps/web/components/sentry/sentry-issue-button.tsx new file mode 100644 index 0000000000..73147d69ef --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-button.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useState } from "react"; +import { IconBrandSentry } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability"; +import { SentryIssueDialog } from "./sentry-issue-dialog"; + +// SentryIssueButton opens the browse/search dialog. It renders nothing when +// the Sentry integration is not available (toggle off or unauthenticated). +export function SentryIssueButton() { + const available = useSentryAvailable(); + const [open, setOpen] = useState(false); + + if (!available) return null; + + return ( + <> + + + + + Browse Sentry issues + + + + ); +} diff --git a/apps/web/components/sentry/sentry-issue-common.tsx b/apps/web/components/sentry/sentry-issue-common.tsx new file mode 100644 index 0000000000..6cbd723379 --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-common.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { formatDistanceToNow } from "date-fns"; +import { Badge } from "@kandev/ui/badge"; +import type { SentryIssue, SentryLevel, SentryStatus } from "@/lib/types/sentry"; +import { IntegrationAuthErrorMessage } from "@/components/integrations/auth-error-message"; + +// Sentry short IDs look like "PROJ-123" — alphanumeric uppercase project slug +// followed by a numeric counter. Underscores and hyphens are allowed in the +// project slug part. +export const SENTRY_SHORT_ID_RE = /^[A-Z][A-Z0-9_-]*-\d+$/; +export const SENTRY_SHORT_ID_EXTRACT_RE = /\b[A-Z][A-Z0-9_-]*-\d+\b/; + +export function extractSentryShortId(input: string | undefined | null): string | null { + if (!input) return null; + const match = input.toUpperCase().match(SENTRY_SHORT_ID_EXTRACT_RE); + return match ? match[0] : null; +} + +export function levelBadgeClass(level: SentryLevel | undefined): string { + switch (level) { + case "fatal": + return "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/30"; + case "error": + return "bg-orange-500/15 text-orange-700 dark:text-orange-400 border-orange-500/30"; + case "warning": + return "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30"; + case "info": + return "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30"; + case "debug": + return "bg-muted text-muted-foreground border-muted-foreground/30"; + default: + return ""; + } +} + +export function statusBadgeClass(status: SentryStatus | undefined): string { + switch (status) { + case "unresolved": + return "bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/30"; + case "resolved": + return "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30"; + case "ignored": + return "bg-muted text-muted-foreground border-muted-foreground/30"; + default: + return ""; + } +} + +export function formatRelative(iso: string | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return formatDistanceToNow(d, { addSuffix: true }); +} + +export function SentryIssueRow({ issue }: { issue: SentryIssue }) { + const lastSeen = formatRelative(issue.lastSeen); + return ( +
+
+ + {issue.level} + + + {issue.status} + + {issue.shortId} + {issue.projectName && ( + · {issue.projectName} + )} +
+
{issue.title}
+ {issue.culprit && ( +
{issue.culprit}
+ )} + +
+ ); +} + +function SentryIssueMeta({ issue, lastSeen }: { issue: SentryIssue; lastSeen: string }) { + return ( +
+ {issue.count != null && issue.count !== "" && events {issue.count}} + {typeof issue.userCount === "number" && users {issue.userCount}} + {lastSeen && Last seen {lastSeen}} +
+ ); +} + +// Backend wraps Sentry errors as `sentry api: status N: …`. 401/403 mean the +// token is invalid; everything else is propagated verbatim. +const AUTH_STATUS_RE = /\bstatus (?:401|403)\b/i; + +export function isSentryAuthError(error: string): boolean { + return AUTH_STATUS_RE.test(error); +} + +type SentryErrorMessageProps = { + error: string; + compact?: boolean; +}; + +export function SentryErrorMessage({ error, compact }: SentryErrorMessageProps) { + return ( + + ); +} diff --git a/apps/web/components/sentry/sentry-issue-dialog.tsx b/apps/web/components/sentry/sentry-issue-dialog.tsx new file mode 100644 index 0000000000..958d67a66f --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-dialog.tsx @@ -0,0 +1,424 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { IconRefresh, IconSearch } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { listSentryProjects, searchSentryIssues } from "@/lib/api/domains/sentry-api"; +import type { + SentryIssue, + SentryLevel, + SentryProject, + SentrySearchFilter, + SentryStatus, +} from "@/lib/types/sentry"; +import { + SentryErrorMessage, + SentryIssueRow, + levelBadgeClass, + statusBadgeClass, +} from "./sentry-issue-common"; + +const LEVELS: SentryLevel[] = ["fatal", "error", "warning", "info", "debug"]; +const STATUSES: SentryStatus[] = ["unresolved", "resolved", "ignored"]; +const PERIODS = ["1h", "24h", "7d", "14d", "30d"] as const; +type Period = (typeof PERIODS)[number]; + +type FilterState = { + orgSlug: string; + projectSlug: string; + environment: string; + query: string; + statsPeriod: Period; + levels: SentryLevel[]; + statuses: SentryStatus[]; +}; + +const initialFilter: FilterState = { + orgSlug: "", + projectSlug: "", + environment: "", + query: "", + statsPeriod: "24h", + levels: [], + statuses: ["unresolved"], +}; + +type SentryIssueDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function SentryIssueDialog({ open, onOpenChange }: SentryIssueDialogProps) { + const dialog = useDialogState(open); + + return ( + + + Browse Sentry issues + + + + + ); +} + +type DialogState = ReturnType; + +function useDialogState(open: boolean) { + const [filter, setFilter] = useState(initialFilter); + const [projects, setProjects] = useState([]); + const [issues, setIssues] = useState([]); + const [nextCursor, setNextCursor] = useState(); + const [isLast, setIsLast] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [configLoaded, setConfigLoaded] = useState(false); + // Monotonic request id: only the most recent search may write results, so an + // out-of-order response from a superseded search can't clobber the list. + const searchSeq = useRef(0); + + useBrowseProjects(open, configLoaded, setFilter, setConfigLoaded, setProjects); + + const updateFilter = useCallback( + (key: K, value: FilterState[K]) => + setFilter((prev) => ({ ...prev, [key]: value })), + [], + ); + + const search = useCallback( + async (nextCursorValue?: string) => { + if (!filter.orgSlug) { + setError("Organization slug is required"); + return; + } + const seq = ++searchSeq.current; + setLoading(true); + setError(null); + try { + const payload = toSearchFilter(filter); + const res = await searchSentryIssues(payload, nextCursorValue); + // A newer search started while this one was in flight — drop the result. + if (seq !== searchSeq.current) return; + const page = res.issues ?? []; + // nextCursorValue set => "Load more": append. Empty => fresh search: replace. + setIssues((prev) => (nextCursorValue ? [...prev, ...page] : page)); + setNextCursor(res.nextPageToken); + setIsLast(res.isLast); + } catch (err) { + if (seq !== searchSeq.current) return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (seq === searchSeq.current) setLoading(false); + } + }, + [filter], + ); + + return { + filter, + updateFilter, + projects, + issues, + nextCursor, + isLast, + loading, + error, + search, + }; +} + +function toSearchFilter(filter: FilterState): SentrySearchFilter { + return { + orgSlug: filter.orgSlug, + projectSlug: filter.projectSlug || undefined, + environment: filter.environment || undefined, + query: filter.query || undefined, + statsPeriod: filter.statsPeriod, + levels: filter.levels.length ? filter.levels : undefined, + statuses: filter.statuses.length ? filter.statuses : undefined, + }; +} + +function useBrowseProjects( + open: boolean, + loaded: boolean, + setFilter: (f: (prev: FilterState) => FilterState) => void, + setLoaded: (v: boolean) => void, + setProjects: (p: SentryProject[]) => void, +) { + useEffect(() => { + // Reset when the dialog closes so reopening (component stays mounted) + // refetches projects rather than showing a stale snapshot. + if (!open) { + setLoaded(false); + return; + } + if (loaded) return; + let cancelled = false; + (async () => { + try { + const res = await listSentryProjects().catch(() => ({ + projects: [] as SentryProject[], + })); + if (cancelled) return; + const projects = res.projects ?? []; + setProjects(projects); + // Auto-select the sole org so the required org field is pre-filled when + // the token only sees one organization. + const orgs = Array.from(new Set(projects.map((p) => p.orgSlug).filter(Boolean))); + if (orgs.length === 1) { + setFilter((prev) => (prev.orgSlug ? prev : { ...prev, orgSlug: orgs[0] })); + } + } finally { + if (!cancelled) setLoaded(true); + } + })(); + return () => { + cancelled = true; + }; + }, [open, loaded, setFilter, setLoaded, setProjects]); +} + +function FiltersBar({ state }: { state: DialogState }) { + return ( +
+ + state.updateFilter("levels", next as SentryLevel[])} + chipClass={(v) => levelBadgeClass(v as SentryLevel)} + /> + state.updateFilter("statuses", next as SentryStatus[])} + chipClass={(v) => statusBadgeClass(v as SentryStatus)} + /> + +
+ ); +} + +function FilterTopRow({ state }: { state: DialogState }) { + const { filter, updateFilter, projects } = state; + return ( +
+ updateFilter("orgSlug", v)} + placeholder="my-org" + /> + updateFilter("projectSlug", v)} + /> + updateFilter("environment", v)} + placeholder="production" + /> + updateFilter("statsPeriod", v)} /> +
+ ); +} + +type LabeledInputProps = { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; +}; + +function LabeledInput({ id, label, value, onChange, placeholder }: LabeledInputProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="h-8 text-xs" + /> +
+ ); +} + +type ProjectFilterSelectProps = { + value: string; + projects: SentryProject[]; + onChange: (v: string) => void; +}; + +function ProjectFilterSelect({ value, projects, onChange }: ProjectFilterSelectProps) { + return ( +
+ + +
+ ); +} + +function PeriodSelect({ value, onChange }: { value: Period; onChange: (v: Period) => void }) { + return ( +
+ + +
+ ); +} + +type ChipRowProps = { + label: string; + options: readonly string[]; + selected: string[]; + onToggle: (next: string[]) => void; + chipClass: (value: string) => string; +}; + +function FilterChipRow({ label, options, selected, onToggle, chipClass }: ChipRowProps) { + const toggle = (value: string) => { + if (selected.includes(value)) onToggle(selected.filter((v) => v !== value)); + else onToggle([...selected, value]); + }; + return ( +
+ {label} + {options.map((opt) => { + const active = selected.includes(opt); + return ( + toggle(opt)} + className={`cursor-pointer text-[10px] uppercase px-2 py-0.5 ${ + active ? chipClass(opt) : "opacity-60" + }`} + > + {opt} + + ); + })} +
+ ); +} + +function SearchActionRow({ state }: { state: DialogState }) { + const { filter, updateFilter, loading, search } = state; + return ( +
+
+ + updateFilter("query", e.target.value)} + placeholder="is:unresolved release:1.2.3" + className="h-8 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void search(); + } + }} + /> +
+ +
+ ); +} + +function ResultsBody({ state }: { state: DialogState }) { + const { issues, loading, error, isLast, nextCursor, search } = state; + const empty = useMemo(() => !loading && !error && issues.length === 0, [loading, error, issues]); + + return ( +
+ {error && } + {empty && ( +
+ No issues yet. Run a search to begin. +
+ )} + {issues.map((issue) => ( + + + + ))} + {!isLast && nextCursor && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/components/sentry/sentry-issue-watch-dialog.tsx b/apps/web/components/sentry/sentry-issue-watch-dialog.tsx new file mode 100644 index 0000000000..ad719b6a27 --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-watch-dialog.tsx @@ -0,0 +1,540 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@kandev/ui/button"; +import { Separator } from "@kandev/ui/separator"; +import { Switch } from "@kandev/ui/switch"; +import { Label } from "@kandev/ui/label"; +import { Input } from "@kandev/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@kandev/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useWorkflows } from "@/hooks/use-workflows"; +import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps"; +import { + ScriptEditor, + computeEditorHeight, +} from "@/components/settings/profile-edit/script-editor"; +import { listSentryProjects, listSentryOrganizations } from "@/lib/api/domains/sentry-api"; +import { SENTRY_ISSUE_WATCH_PLACEHOLDERS } from "./sentry-issue-watch-placeholders"; +import { LevelMultiSelect, StatusMultiSelect } from "./sentry-issue-watch-multiselect"; +import { MaxInflightTasksField } from "./sentry-issue-watch-throttle-field"; +import { + STATS_PERIOD_OPTIONS, + type FormState, + orgSelectItems, + projectSelectItems, + parseMaxInflightTasks, + isWatchFormReady, + buildFilterPayload, + formStateFromWatch, + makeEmptyForm, +} from "./sentry-issue-watch-form"; +import type { + CreateSentryIssueWatchRequest, + SentryIssueWatch, + SentryLevel, + SentryProject, + SentryStatus, + UpdateSentryIssueWatchRequest, +} from "@/lib/types/sentry"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + watch: SentryIssueWatch | null; + workspaceId?: string; + onCreate: (req: CreateSentryIssueWatchRequest) => Promise; + onUpdate: ( + id: string, + workspaceId: string, + req: UpdateSentryIssueWatchRequest, + ) => Promise; +}; + +function useFormData(workspaceId: string) { + useSettingsData(true); + useWorkflows(workspaceId, true); + const allWorkflows = useAppStore((s) => s.workflows.items); + const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); + const agentProfiles = useAppStore((s) => s.agentProfiles.items); + const executors = useAppStore((s) => s.executors.items); + const allExecutorProfiles = useMemo( + () => + executors + .filter((e) => e.type !== "local" && e.type !== "local_pc") + .flatMap((e) => e.profiles ?? []), + [executors], + ); + const filteredAgentProfiles = useMemo( + () => agentProfiles.filter((p) => !p.cli_passthrough), + [agentProfiles], + ); + return { workflows, agentProfiles: filteredAgentProfiles, allExecutorProfiles }; +} + +function useSentryProjects(orgSlug: string) { + const [projects, setProjects] = useState([]); + useEffect(() => { + let cancelled = false; + listSentryProjects() + .then((res) => { + if (!cancelled) setProjects(res.projects ?? []); + }) + .catch(() => { + if (!cancelled) setProjects([]); + }); + return () => { + cancelled = true; + }; + }, []); + // Sentry's auth-token endpoint already filters to the user's accessible orgs; + // if an orgSlug is set, restrict to projects that match. + return useMemo( + () => (orgSlug ? projects.filter((p) => p.orgSlug === orgSlug) : projects), + [projects, orgSlug], + ); +} + +function SelectField(props: { + label: string; + description?: string; + value: string; + onChange: (v: string) => void; + placeholder: string; + items: { id: string; label: string }[]; + disabled?: boolean; +}) { + return ( +
+ + {props.description &&

{props.description}

} + +
+ ); +} + +type FormSetter = React.Dispatch>; + +function OrgProjectRow({ + form, + setForm, + projects, + orgs, +}: { + form: FormState; + setForm: FormSetter; + projects: SentryProject[]; + orgs: string[]; +}) { + const onOrgChange = (v: string) => + // The selected project may belong to a different org — clear it so the + // project dropdown re-picks within the new org. + setForm((p) => ({ ...p, orgSlug: v, projectSlug: "" })); + const onProjectChange = (v: string) => setForm((p) => ({ ...p, projectSlug: v })); + const orgItems = orgSelectItems(orgs, form.orgSlug); + const projectItems = projectSelectItems(projects, form.projectSlug); + return ( +
+ + +
+ ); +} + +function FilterFields({ + form, + setForm, + orgs, +}: { + form: FormState; + setForm: FormSetter; + orgs: string[]; +}) { + const projects = useSentryProjects(form.orgSlug); + const toggleLevel = useCallback( + (level: SentryLevel) => + setForm((p) => ({ + ...p, + levels: p.levels.includes(level) + ? p.levels.filter((l) => l !== level) + : [...p.levels, level], + })), + [setForm], + ); + const toggleStatus = useCallback( + (status: SentryStatus) => + setForm((p) => ({ + ...p, + statuses: p.statuses.includes(status) + ? p.statuses.filter((s) => s !== status) + : [...p.statuses, status], + })), + [setForm], + ); + return ( + <> + +
+
+ +

Optional — restrict to one environment.

+ setForm((p) => ({ ...p, environment: e.target.value }))} + placeholder="production" + /> +
+ setForm((p) => ({ ...p, statsPeriod: v }))} + placeholder="(any)" + items={STATS_PERIOD_OPTIONS.map((o) => ({ id: o.value, label: o.label }))} + /> +
+
+ +

+ Click to toggle. Matches issues at ANY of the selected levels. +

+ +
+
+ +

+ Click to toggle. Matches issues at ANY of the selected statuses. +

+ +
+
+ +

Free-text Sentry search query (optional).

+ setForm((p) => ({ ...p, query: e.target.value }))} + placeholder="is:unresolved transaction:/api/checkout" + /> +
+ + ); +} + +function PlaceholdersHelp() { + return ( + + + + + + +

Available placeholders:

+
    + {SENTRY_ISSUE_WATCH_PLACEHOLDERS.map((p) => ( +
  • + {`{{${p.key}}}`}{" "} + {p.description} +
  • + ))} +
+
+
+
+ ); +} + +function PromptField({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( +
+
+ + +
+

+ The prompt sent to the agent for each new issue. Type {"{{"} to insert placeholders. +

+
+ +
+
+ ); +} + +function WorkspacePicker({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (v: string) => void; + disabled?: boolean; +}) { + const workspaces = useAppStore((s) => s.workspaces.items); + return ( + ({ id: w.id, label: w.name }))} + disabled={disabled} + /> + ); +} + +function AutomationFields({ form, setForm }: { form: FormState; setForm: FormSetter }) { + const { workflows, agentProfiles, allExecutorProfiles } = useFormData(form.workspaceId); + const { steps, loading: stepsLoading } = useWorkflowSteps(form.workflowId); + return ( + <> +
+ setForm((p) => ({ ...p, workflowId: v, workflowStepId: "" }))} + placeholder="Select workflow" + items={workflows.map((w) => ({ id: w.id, label: w.name }))} + /> + setForm((p) => ({ ...p, workflowStepId: v }))} + placeholder={stepPlaceholder(form.workflowId, stepsLoading, steps.length)} + items={steps.map((s) => ({ id: s.id, label: s.name }))} + disabled={!form.workflowId || stepsLoading || steps.length === 0} + /> +
+
+ setForm((p) => ({ ...p, agentProfileId: v }))} + placeholder="(use step default)" + items={agentProfiles.map((p) => ({ id: p.id, label: p.label }))} + /> + setForm((p) => ({ ...p, executorProfileId: v }))} + placeholder="(use step default)" + items={allExecutorProfiles.map((p) => ({ id: p.id, label: p.name }))} + /> +
+ + ); +} + +function SettingsFields({ form, setForm }: { form: FormState; setForm: FormSetter }) { + return ( + <> +
+ +

+ How often to re-run the search. Minimum 60s, maximum 3600s. +

+ setForm((p) => ({ ...p, pollInterval: Number(e.target.value) }))} + min={60} + max={3600} + /> +
+ +
+
+ +

Pause or resume polling.

+
+ setForm((p) => ({ ...p, enabled: v }))} + className="cursor-pointer" + /> +
+ + ); +} + +function savingLabel(saving: boolean, isEdit: boolean): string { + if (saving) return "Saving…"; + return isEdit ? "Update" : "Create"; +} + +// useWatchOrgs loads the org list for the org dropdown and auto-selects the +// sole org on a fresh create (with one choice there is nothing to pick). +function useWatchOrgs(open: boolean, hasWatch: boolean, setForm: FormSetter) { + const [orgs, setOrgs] = useState([]); + useEffect(() => { + if (!open) return; + let cancelled = false; + listSentryOrganizations() + .then((res) => { + if (!cancelled) setOrgs((res.organizations ?? []).map((o) => o.slug)); + }) + .catch(() => { + if (!cancelled) setOrgs([]); + }); + return () => { + cancelled = true; + }; + }, [open]); + useEffect(() => { + if (hasWatch || orgs.length !== 1) return; + setForm((p) => (p.orgSlug ? p : { ...p, orgSlug: orgs[0] })); + }, [hasWatch, orgs, setForm]); + return orgs; +} + +export function SentryIssueWatchDialog({ + open, + onOpenChange, + watch, + workspaceId, + onCreate, + onUpdate, +}: Props) { + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState(() => makeEmptyForm(workspaceId ?? "")); + + useEffect(() => { + if (watch) { + setForm(formStateFromWatch(watch)); + } else { + setForm(makeEmptyForm(workspaceId ?? activeWorkspaceId ?? "")); + } + }, [watch, open, workspaceId, activeWorkspaceId]); + + const orgs = useWatchOrgs(open, !!watch, setForm); + + const workspaceLocked = !!watch || !!workspaceId; + + const canSave = isWatchFormReady(form); + + const handleSave = useCallback(async () => { + const maxInflight = parseMaxInflightTasks(form.maxInflightTasks); + if (maxInflight === "invalid") return; + setSaving(true); + try { + const filter = buildFilterPayload(form); + const payload = { + filter, + workflowId: form.workflowId, + workflowStepId: form.workflowStepId, + agentProfileId: form.agentProfileId, + executorProfileId: form.executorProfileId, + prompt: form.prompt, + enabled: form.enabled, + pollIntervalSeconds: form.pollInterval, + maxInflightTasks: maxInflight, + }; + if (watch) { + await onUpdate(watch.id, watch.workspaceId, payload); + } else { + await onCreate({ ...payload, workspaceId: form.workspaceId }); + } + onOpenChange(false); + } catch { + // Error surfaced by caller's toast. + } finally { + setSaving(false); + } + }, [form, watch, onCreate, onUpdate, onOpenChange]); + + return ( + + + + {watch ? "Edit Sentry Watcher" : "Create Sentry Watcher"} + + Poll Sentry with a structured filter and auto-create a Kandev task for each + newly-matching issue. The workflow step's defaults decide where the task runs. + + +
+ + setForm((p) => ({ ...p, workspaceId: v, workflowId: "", workflowStepId: "" })) + } + disabled={workspaceLocked} + /> + + + + + + setForm((p) => ({ ...p, prompt: v }))} + /> + + +
+ + + + +
+
+ ); +} diff --git a/apps/web/components/sentry/sentry-issue-watch-form.test.ts b/apps/web/components/sentry/sentry-issue-watch-form.test.ts new file mode 100644 index 0000000000..bf008c6f1d --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-watch-form.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { + orgSelectItems, + projectSelectItems, + maxInflightTasksString, + parseMaxInflightTasks, + buildFilterPayload, + makeEmptyForm, +} from "./sentry-issue-watch-form"; +import type { SentryProject } from "@/lib/types/sentry"; + +const proj = (slug: string, name: string, orgSlug = "acme"): SentryProject => ({ + id: slug, + slug, + name, + orgSlug, +}); + +describe("orgSelectItems", () => { + it("lists the orgs the token can see", () => { + const items = orgSelectItems(["acme", "globex"], ""); + expect(items.map((i) => i.id)).toEqual(["acme", "globex"]); + }); + + it("keeps the current value even if the token can no longer see it", () => { + const items = orgSelectItems(["acme"], "legacy-org"); + expect(items.map((i) => i.id)).toEqual(["legacy-org", "acme"]); + }); + + it("does not duplicate the current value when it is also in the list", () => { + const items = orgSelectItems(["acme", "globex"], "acme"); + expect(items.map((i) => i.id)).toEqual(["acme", "globex"]); + }); +}); + +describe("projectSelectItems", () => { + const projects = [proj("frontend", "Frontend"), proj("api", "API")]; + + it("labels projects as 'name (slug)'", () => { + const items = projectSelectItems(projects, ""); + expect(items).toEqual([ + { id: "frontend", label: "Frontend (frontend)" }, + { id: "api", label: "API (api)" }, + ]); + }); + + it("keeps the current project even if not in the visible list", () => { + const items = projectSelectItems(projects, "archived"); + expect(items.map((i) => i.id)).toContain("archived"); + }); +}); + +describe("maxInflightTasksString", () => { + it("renders nil / non-positive caps as blank (uncapped)", () => { + expect(maxInflightTasksString(null)).toBe(""); + expect(maxInflightTasksString(undefined)).toBe(""); + expect(maxInflightTasksString(0)).toBe(""); + expect(maxInflightTasksString(-3)).toBe(""); + }); + + it("renders a positive cap as its string form", () => { + expect(maxInflightTasksString(5)).toBe("5"); + }); +}); + +describe("parseMaxInflightTasks", () => { + it("maps blank to null (uncapped)", () => { + expect(parseMaxInflightTasks("")).toBeNull(); + expect(parseMaxInflightTasks(" ")).toBeNull(); + }); + + it("parses a positive whole number", () => { + expect(parseMaxInflightTasks("5")).toBe(5); + expect(parseMaxInflightTasks(" 12 ")).toBe(12); + }); + + it("flags non-positive or non-integer input as invalid", () => { + expect(parseMaxInflightTasks("0")).toBe("invalid"); + expect(parseMaxInflightTasks("-1")).toBe("invalid"); + expect(parseMaxInflightTasks("1.5")).toBe("invalid"); + expect(parseMaxInflightTasks("abc")).toBe("invalid"); + }); +}); + +describe("buildFilterPayload", () => { + it("trims the org slug and drops an empty project slug", () => { + const form = { ...makeEmptyForm("ws-1"), orgSlug: " acme ", projectSlug: "" }; + const filter = buildFilterPayload(form); + expect(filter.orgSlug).toBe("acme"); + expect(filter.projectSlug).toBeUndefined(); + }); + + it("keeps a concrete project slug", () => { + const form = { ...makeEmptyForm("ws-1"), orgSlug: "acme", projectSlug: "web" }; + const filter = buildFilterPayload(form); + expect(filter.orgSlug).toBe("acme"); + expect(filter.projectSlug).toBe("web"); + }); +}); diff --git a/apps/web/components/sentry/sentry-issue-watch-form.ts b/apps/web/components/sentry/sentry-issue-watch-form.ts new file mode 100644 index 0000000000..59251c466d --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-watch-form.ts @@ -0,0 +1,164 @@ +import type { + SentryIssueWatch, + SentryLevel, + SentryProject, + SentrySearchFilter, + SentryStatus, +} from "@/lib/types/sentry"; +import { DEFAULT_SENTRY_ISSUE_WATCH_PROMPT } from "./sentry-issue-watch-placeholders"; + +export const LEVEL_OPTIONS: SentryLevel[] = ["fatal", "error", "warning", "info", "debug"]; +export const STATUS_OPTIONS: SentryStatus[] = ["unresolved", "resolved", "ignored"]; + +export const STATS_PERIOD_OPTIONS: { value: string; label: string }[] = [ + { value: "1h", label: "Last hour" }, + { value: "24h", label: "Last 24 hours" }, + { value: "7d", label: "Last 7 days" }, + { value: "14d", label: "Last 14 days" }, + { value: "30d", label: "Last 30 days" }, +]; + +export interface FormState { + workspaceId: string; + orgSlug: string; + projectSlug: string; + environment: string; + levels: SentryLevel[]; + statuses: SentryStatus[]; + query: string; + statsPeriod: string; + workflowId: string; + workflowStepId: string; + agentProfileId: string; + executorProfileId: string; + prompt: string; + enabled: boolean; + pollInterval: number; + maxInflightTasks: string; +} + +export function makeEmptyForm(workspaceId: string): FormState { + return { + workspaceId, + orgSlug: "", + projectSlug: "", + environment: "", + levels: ["fatal", "error"], + statuses: ["unresolved"], + query: "", + statsPeriod: "24h", + workflowId: "", + workflowStepId: "", + agentProfileId: "", + executorProfileId: "", + prompt: DEFAULT_SENTRY_ISSUE_WATCH_PROMPT, + enabled: true, + pollInterval: 300, + maxInflightTasks: "5", + }; +} + +export function formStateFromWatch(w: SentryIssueWatch): FormState { + const f: SentrySearchFilter = w.filter ?? { orgSlug: "" }; + return { + workspaceId: w.workspaceId, + orgSlug: f.orgSlug ?? "", + projectSlug: f.projectSlug ?? "", + environment: f.environment ?? "", + levels: f.levels ?? [], + statuses: f.statuses ?? [], + query: f.query ?? "", + statsPeriod: f.statsPeriod ?? "", + workflowId: w.workflowId, + workflowStepId: w.workflowStepId, + agentProfileId: w.agentProfileId, + executorProfileId: w.executorProfileId, + prompt: w.prompt || DEFAULT_SENTRY_ISSUE_WATCH_PROMPT, + enabled: w.enabled, + pollInterval: w.pollIntervalSeconds, + maxInflightTasks: maxInflightTasksString(w.maxInflightTasks), + }; +} + +/** + * Formats the throttle cap for the input. nil/undefined and non-positive + * (from a stale row) collapse to "" — an empty box reads as "no cap", and + * showing "0" would falsely imply a cap was enforced. + */ +export function maxInflightTasksString(v: number | null | undefined): string { + if (v === undefined || v === null) return ""; + if (!Number.isFinite(v) || v <= 0) return ""; + return String(v); +} + +/** + * Parses the throttle-cap input back into a payload value. Returns `null` for + * blank (uncapped), the integer for a positive whole number, or "invalid" when + * the input is non-empty but unparseable / non-positive so the dialog can show + * an inline error before submit. + */ +export function parseMaxInflightTasks(raw: string): number | null | "invalid" { + const t = raw.trim(); + if (t === "") return null; + const n = Number(t); + if (!Number.isInteger(n) || n <= 0) return "invalid"; + return n; +} + +export type SelectItemSpec = { id: string; label: string }; + +export function orgSelectItems(orgs: string[], current: string): SelectItemSpec[] { + const items: SelectItemSpec[] = []; + const seen = new Set(); + // Include the current value even if the token can no longer see it (editing an + // old watch) so the Select still shows the saved org. + for (const slug of [current, ...orgs]) { + if (!slug || seen.has(slug)) continue; + seen.add(slug); + items.push({ id: slug, label: slug }); + } + return items; +} + +export function projectSelectItems(projects: SentryProject[], current: string): SelectItemSpec[] { + const items: SelectItemSpec[] = []; + const seen = new Set(); + for (const p of projects) { + if (seen.has(p.slug)) continue; + seen.add(p.slug); + items.push({ id: p.slug, label: `${p.name} (${p.slug})` }); + } + if (current && !seen.has(current)) { + items.push({ id: current, label: current }); + } + return items; +} + +// isWatchFormReady aggregates the dialog's "can Save enable?" rule. Kept here +// so the rule has one named home and the dialog stays under its line limit. +export function isWatchFormReady(form: FormState): boolean { + return ( + !!form.workspaceId && + !!form.orgSlug.trim() && + !!form.projectSlug.trim() && + !!form.workflowId && + !!form.workflowStepId && + !!form.prompt.trim() && + Number.isInteger(form.pollInterval) && + form.pollInterval >= 60 && + form.pollInterval <= 3600 && + parseMaxInflightTasks(form.maxInflightTasks) !== "invalid" + ); +} + +export function buildFilterPayload(form: FormState): SentrySearchFilter { + return { + orgSlug: form.orgSlug.trim(), + projectSlug: form.projectSlug.trim() || undefined, + environment: form.environment.trim() || undefined, + levels: form.levels.length > 0 ? form.levels : undefined, + statuses: form.statuses.length > 0 ? form.statuses : undefined, + query: form.query.trim() || undefined, + statsPeriod: form.statsPeriod || undefined, + }; +} diff --git a/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx b/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx new file mode 100644 index 0000000000..f9ed2ffe12 --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Badge } from "@kandev/ui/badge"; +import { levelBadgeClass, statusBadgeClass } from "./sentry-issue-common"; +import { LEVEL_OPTIONS, STATUS_OPTIONS } from "./sentry-issue-watch-form"; +import type { SentryLevel, SentryStatus } from "@/lib/types/sentry"; + +// LevelMultiSelect / StatusMultiSelect are toggle-badge pickers: click a badge +// to add/remove it from the selection. A match means ANY selected value. Both +// are thin wrappers over the generic BadgeMultiSelect below. + +// BadgeMultiSelect renders a toggle-badge picker for any string-union option +// set. `colorClass` resolves the active-state styling for a given option. +function BadgeMultiSelect({ + options, + selected, + onToggle, + colorClass, +}: { + options: readonly T[]; + selected: T[]; + onToggle: (value: T) => void; + colorClass: (value: T) => string; +}) { + return ( +
+ {options.map((value) => { + const active = selected.includes(value); + return ( + + ); + })} +
+ ); +} + +export function LevelMultiSelect({ + selected, + onToggle, +}: { + selected: SentryLevel[]; + onToggle: (level: SentryLevel) => void; +}) { + return ( + + ); +} + +export function StatusMultiSelect({ + selected, + onToggle, +}: { + selected: SentryStatus[]; + onToggle: (status: SentryStatus) => void; +}) { + return ( + + ); +} diff --git a/apps/web/components/sentry/sentry-issue-watch-placeholders.ts b/apps/web/components/sentry/sentry-issue-watch-placeholders.ts new file mode 100644 index 0000000000..20c22a42e2 --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-watch-placeholders.ts @@ -0,0 +1,101 @@ +import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions"; + +export const SENTRY_ISSUE_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [ + { + key: "issue.short_id", + description: "Sentry short ID", + example: "PROJ-123", + executor_types: [], + }, + { + key: "issue.title", + description: "Issue title", + example: "TypeError: cannot read property of undefined", + executor_types: [], + }, + { + key: "issue.url", + description: "Sentry issue permalink", + example: "https://sentry.io/organizations/acme/issues/123/", + executor_types: [], + }, + { + key: "issue.project", + description: "Sentry project slug", + example: "frontend", + executor_types: [], + }, + { + key: "issue.level", + description: "Issue level", + example: "error", + executor_types: [], + }, + { + key: "issue.status", + description: "Issue status", + example: "unresolved", + executor_types: [], + }, + { + key: "issue.culprit", + description: "Culprit (function or file)", + example: "app/routes/checkout.tsx in handleSubmit", + executor_types: [], + }, + { + key: "issue.assignee", + description: "Assignee display name", + example: "Alice", + executor_types: [], + }, + { + key: "issue.count", + description: "Total event count", + example: "42", + executor_types: [], + }, + { + key: "issue.user_count", + description: "Unique users affected", + example: "7", + executor_types: [], + }, + { + key: "issue.first_seen", + description: "When the issue was first seen", + example: "2026-05-20T11:30:00Z", + executor_types: [], + }, + { + key: "issue.last_seen", + description: "When the issue was last seen", + example: "2026-05-23T08:14:00Z", + executor_types: [], + }, +]; + +// DEFAULT_SENTRY_ISSUE_WATCH_PROMPT mirrors apps/backend/config/prompts/sentry-issue-watch-default.md. +// Kept in sync by hand: the UI shows this when the user clears the field, and +// the backend reads the .md when the saved prompt is empty. +export const DEFAULT_SENTRY_ISSUE_WATCH_PROMPT = `You have been assigned a Sentry issue to triage and fix. + +**Issue:** {{issue.url}} +**Short ID:** {{issue.short_id}} +**Title:** {{issue.title}} +**Project:** {{issue.project}} +**Level:** {{issue.level}} +**Status:** {{issue.status}} +**Assignee:** {{issue.assignee}} +**Culprit:** {{issue.culprit}} +**Events:** {{issue.count}} ({{issue.user_count}} users affected) +**First seen:** {{issue.first_seen}} +**Last seen:** {{issue.last_seen}} + +## Instructions + +1. Open the issue in Sentry and read the full stack trace. +2. Reproduce the bug locally if possible. +3. Implement the fix and add a regression test. +4. Run the test suite to ensure nothing else broke. +5. Commit your changes with a descriptive message referencing {{issue.short_id}}.`; diff --git a/apps/web/components/sentry/sentry-issue-watch-table.tsx b/apps/web/components/sentry/sentry-issue-watch-table.tsx new file mode 100644 index 0000000000..bcc6ca070b --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-watch-table.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Badge } from "@kandev/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import type { SentryIssueWatch, SentrySearchFilter } from "@/lib/types/sentry"; + +type SentryIssueWatchTableProps = { + watches: SentryIssueWatch[]; + // showWorkspace renders a Workspace column when the table aggregates rows + // from every workspace (install-wide settings page). + showWorkspace?: boolean; + onEdit: (watch: SentryIssueWatch) => void; + onDelete: (id: string, workspaceId: string) => void; + onTrigger: (id: string, workspaceId: string) => void; + onToggleEnabled: (watch: SentryIssueWatch) => void; +}; + +function formatLastPolled(dateStr?: string | null): string { + if (!dateStr) return "Never"; + const ts = new Date(dateStr).getTime(); + if (Number.isNaN(ts)) return "Never"; + const diff = Date.now() - ts; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +// summarizeFilter renders the structured filter as a short tag-style label +// the user can scan at a glance. +function summarizeFilter(filter: SentrySearchFilter | undefined): string { + if (!filter) return "(any)"; + const parts: string[] = []; + if (filter.orgSlug) parts.push(`org:${filter.orgSlug}`); + if (filter.projectSlug) parts.push(`project:${filter.projectSlug}`); + if (filter.environment) parts.push(`env:${filter.environment}`); + if (filter.levels && filter.levels.length > 0) { + parts.push(`level:${filter.levels.join(",")}`); + } + if (filter.statuses && filter.statuses.length > 0) { + parts.push(`status:${filter.statuses.join(",")}`); + } + if (filter.statsPeriod) parts.push(`period:${filter.statsPeriod}`); + if (filter.query) parts.push(`q:"${filter.query}"`); + return parts.length === 0 ? "(any)" : parts.join(" · "); +} + +function WatchActions({ + watch, + onToggleEnabled, + onTrigger, + onDelete, +}: { + watch: SentryIssueWatch; + onToggleEnabled: (watch: SentryIssueWatch) => void; + onTrigger: (id: string, workspaceId: string) => void; + onDelete: (id: string, workspaceId: string) => void; +}) { + return ( +
+ + + + + {watch.enabled ? "Pause" : "Enable"} + + + + + + Check now + + + + + + Delete + +
+ ); +} + +export function SentryIssueWatchTable({ + watches, + showWorkspace, + onEdit, + onDelete, + onTrigger, + onToggleEnabled, +}: SentryIssueWatchTableProps) { + const workspaces = useAppStore((s) => s.workspaces.items); + const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; + + if (watches.length === 0) { + return ( +

+ No Sentry watchers configured. Create one to auto-create tasks from filtered issues. +

+ ); + } + + return ( + + + + {showWorkspace && Workspace} + Filter + Interval + Last Polled + Status + Actions + + + + {watches.map((watch) => ( + onEdit(watch)}> + {showWorkspace && ( + + {workspaceName(watch.workspaceId)} + + )} + + {summarizeFilter(watch.filter)} + + + {Math.round(watch.pollIntervalSeconds / 60)}m + + + {formatLastPolled(watch.lastPolledAt)} + + + + {watch.enabled ? "Active" : "Paused"} + + + + + + + ))} + +
+ ); +} diff --git a/apps/web/components/sentry/sentry-issue-watch-throttle-field.tsx b/apps/web/components/sentry/sentry-issue-watch-throttle-field.tsx new file mode 100644 index 0000000000..5e3683a493 --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-watch-throttle-field.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type React from "react"; +import { Label } from "@kandev/ui/label"; +import { Input } from "@kandev/ui/input"; +import { type FormState, parseMaxInflightTasks } from "./sentry-issue-watch-form"; + +// MaxInflightTasksField caps how many open tasks a watcher may hold at once. +// Blank means uncapped; new matches are deferred to the next poll once the cap +// is reached. Mirrors the Linear watcher's throttle field. +export function MaxInflightTasksField({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + const invalid = parseMaxInflightTasks(form.maxInflightTasks) === "invalid"; + return ( +
+ +

+ Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred + to the next poll when the cap is reached. +

+ setForm((p) => ({ ...p, maxInflightTasks: e.target.value }))} + min={1} + step={1} + placeholder="(no cap)" + aria-invalid={invalid} + /> + {invalid && ( +

Enter a positive integer or leave blank.

+ )} +
+ ); +} diff --git a/apps/web/components/sentry/sentry-issue-watchers-section.tsx b/apps/web/components/sentry/sentry-issue-watchers-section.tsx new file mode 100644 index 0000000000..dedd6dae77 --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-watchers-section.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { IconBellRinging, IconPlus } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useToast } from "@/components/toast-provider"; +import { useSentryIssueWatches } from "@/hooks/domains/sentry/use-sentry-issue-watches"; +import { SentryIssueWatchTable } from "./sentry-issue-watch-table"; +import { SentryIssueWatchDialog } from "./sentry-issue-watch-dialog"; +import type { SentryIssueWatch } from "@/lib/types/sentry"; + +// SentryIssueWatchersSection lists watches across every workspace in a single +// flat table on the install-wide settings page. The dialog's create flow asks +// the user to pick the workspace. +type RawActions = { + create: ReturnType["create"]; + update: ReturnType["update"]; + remove: ReturnType["remove"]; + trigger: ReturnType["trigger"]; +}; + +function useToastedActions({ create, update, remove, trigger }: RawActions) { + const { toast } = useToast(); + + const wrappedCreate = useCallback( + async (req: Parameters[0]) => { + try { + await create(req); + toast({ description: "Watcher created", variant: "success" }); + } catch (err) { + toast({ description: `Create failed: ${String(err)}`, variant: "error" }); + throw err; + } + }, + [create, toast], + ); + + const wrappedUpdate = useCallback( + async (id: string, workspaceId: string, req: Parameters[2]) => { + try { + await update(id, workspaceId, req); + toast({ description: "Watcher updated", variant: "success" }); + } catch (err) { + toast({ description: `Update failed: ${String(err)}`, variant: "error" }); + throw err; + } + }, + [update, toast], + ); + + const wrappedDelete = useCallback( + async (id: string, workspaceId: string) => { + if (!confirm("Delete this Sentry watcher?")) return; + try { + await remove(id, workspaceId); + toast({ description: "Watcher deleted", variant: "success" }); + } catch (err) { + toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + } + }, + [remove, toast], + ); + + const wrappedTrigger = useCallback( + async (id: string, workspaceId: string) => { + try { + const res = await trigger(id, workspaceId); + const n = res?.published ?? 0; + const description = + n > 0 ? `Found ${n} new issue(s) — tasks will appear shortly.` : "No new issues matched."; + toast({ description, variant: "success" }); + } catch (err) { + toast({ description: `Check failed: ${String(err)}`, variant: "error" }); + } + }, + [trigger, toast], + ); + + const toggleEnabled = useCallback( + async (w: SentryIssueWatch) => { + try { + await update(w.id, w.workspaceId, { enabled: !w.enabled }); + } catch (err) { + toast({ description: `Toggle failed: ${String(err)}`, variant: "error" }); + } + }, + [update, toast], + ); + + return { + create: wrappedCreate, + update: wrappedUpdate, + remove: wrappedDelete, + trigger: wrappedTrigger, + toggleEnabled, + }; +} + +export function SentryIssueWatchersSection() { + const { items, loading, create, update, remove, trigger } = useSentryIssueWatches(); + const actions = useToastedActions({ create, update, remove, trigger }); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + + const openCreate = useCallback(() => { + setEditing(null); + setDialogOpen(true); + }, []); + const openEdit = useCallback((w: SentryIssueWatch) => { + setEditing(w); + setDialogOpen(true); + }, []); + + return ( + } + title="Sentry watchers" + description="Poll a Sentry filter and auto-create a Kandev task for each newly-matching issue." + action={ + + } + > + + + {loading && items.length === 0 ? ( +

Loading…

+ ) : ( + + )} +
+
+ +
+ ); +} diff --git a/apps/web/components/sentry/sentry-link-button.tsx b/apps/web/components/sentry/sentry-link-button.tsx new file mode 100644 index 0000000000..ce8686550c --- /dev/null +++ b/apps/web/components/sentry/sentry-link-button.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useCallback } from "react"; +import { IconLink } from "@tabler/icons-react"; +import { toast } from "sonner"; +import { getSentryIssue } from "@/lib/api/domains/sentry-api"; +import { updateTask } from "@/lib/api/domains/kanban-api"; +import { SENTRY_SHORT_ID_RE } from "./sentry-issue-common"; +import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability"; +import { ValidatedPopover } from "@/components/integrations/validated-popover"; + +type SentryLinkButtonProps = { + taskId: string | null | undefined; + workspaceId: string | null | undefined; + taskTitle: string | undefined | null; +}; + +// SentryLinkButton attaches a Sentry issue to an existing task by prepending +// its short ID to the title ("PROJ-123: ..."). +export function SentryLinkButton({ taskId, workspaceId, taskTitle }: SentryLinkButtonProps) { + const available = useSentryAvailable(); + + const buildLinkedTitle = useCallback( + (key: string) => { + const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9_-]*-\d+:\s*/, ""); + return stripped ? `${key}: ${stripped}` : key; + }, + [taskTitle], + ); + + // Require taskTitle to be loaded (== null catches null + undefined but allows + // an empty string): otherwise linking would overwrite the real title with + // just the Sentry key while the title is still in flight. + if (!available || !taskId || !workspaceId || taskTitle == null) return null; + + return ( + } + triggerLabel="Link Sentry" + tooltip="Link this task to a Sentry issue" + headline="Link to Sentry issue" + placeholder="PROJ-123 or paste issue URL" + extractKey={(raw) => { + const upper = raw.toUpperCase().trim(); + return SENTRY_SHORT_ID_RE.test(upper) + ? upper + : (upper.match(/[A-Z][A-Z0-9_-]*-\d+/)?.[0] ?? null); + }} + validationHint="Paste a Sentry issue URL or short ID (PROJ-123)" + fetch={async (key) => { + const issue = await getSentryIssue(key); + await updateTask(taskId, { title: buildLinkedTitle(key) }); + return issue; + }} + onSuccess={(key) => { + toast.success(`Linked to ${key}`); + }} + submitLabel="Link" + submittingLabel="Linking..." + /> + ); +} diff --git a/apps/web/components/sentry/sentry-settings.tsx b/apps/web/components/sentry/sentry-settings.tsx new file mode 100644 index 0000000000..8c438451ec --- /dev/null +++ b/apps/web/components/sentry/sentry-settings.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { IconBrandSentry, IconInfoCircle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Separator } from "@kandev/ui/separator"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Switch } from "@kandev/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useToast } from "@/components/toast-provider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useSentryEnabled } from "@/hooks/domains/sentry/use-sentry-enabled"; +import { + IntegrationAuthStatusBanner, + type IntegrationAuthHealth, +} from "@/components/integrations/auth-status-banner"; +import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; +import { + fetchSentryConfig, + saveSentryConfig, + deleteSentryConfig, + testSentryConnection, +} from "@/lib/api/domains/sentry-api"; +import { + SENTRY_AUTH_METHOD, + type SentryConfig, + type TestSentryConnectionResult, +} from "@/lib/types/sentry"; +import { SentryIssueWatchersSection } from "./sentry-issue-watchers-section"; + +type FormState = { + secret: string; +}; + +const emptyForm: FormState = { secret: "" }; + +function configToForm(_cfg: SentryConfig | null): FormState { + // Only the (write-only) secret is editable here; org/project are chosen + // per-watcher and per-browse, not stored install-wide. + return { secret: "" }; +} + +function saveLabel(saving: boolean, hasConfig: boolean): string { + if (saving) return "Saving..."; + return hasConfig ? "Update" : "Save"; +} + +function configToHealth(config: SentryConfig | null): IntegrationAuthHealth | null { + if (!config?.hasSecret) return null; + if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null }; + return { + ok: !!config.lastOk, + error: config.lastError ?? "", + checkedAt: new Date(config.lastCheckedAt), + }; +} + +type UpdateFn = (key: K, value: FormState[K]) => void; + +type SecretFieldProps = { + form: FormState; + loading: boolean; + update: UpdateFn; + hasSavedSecret: boolean; +}; + +function SecretField({ form, loading, update, hasSavedSecret }: SecretFieldProps) { + return ( +
+
+ + + + + + +

Grant Read access to these scopes:

+
    +
  • + org:read{" "} + Organization — resolve the org and list issues +
  • +
  • + project:read{" "} + Project — list projects and scope searches +
  • +
  • + event:read{" "} + + Issue & Event — browse issues and run watchers + +
  • +
+
+
+
+ update("secret", e.target.value)} + disabled={loading} + /> +

+ Create a new personal token at{" "} + + sentry.io → Settings → Auth Tokens + +

+
+ ); +} + +function TestResultAlert({ result }: { result: TestSentryConnectionResult | null }) { + if (!result) return null; + return ( + + + {result.ok + ? `Connected as ${result.displayName || result.email || result.userId}` + : `Failed: ${result.error}`} + + + ); +} + +type ActionBarProps = { + saving: boolean; + testing: boolean; + loading: boolean; + hasConfig: boolean; + disableSave: boolean; + disableTest: boolean; + onTest: () => void; + onSave: () => void; + onDelete: () => void; +}; + +function ActionBar({ + saving, + testing, + loading, + hasConfig, + disableSave, + disableTest, + onTest, + onSave, + onDelete, +}: ActionBarProps) { + return ( +
+ + + {hasConfig && ( + + )} +
+ ); +} + +type SettingsActionsArgs = { + form: FormState; + setConfig: (cfg: SentryConfig | null) => void; + setForm: (form: FormState) => void; + setTestResult: (r: TestSentryConnectionResult | null) => void; +}; + +function useSettingsActions({ form, setConfig, setForm, setTestResult }: SettingsActionsArgs) { + const { toast } = useToast(); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + + const handleTest = useCallback(async () => { + setTesting(true); + setTestResult(null); + try { + const res = await testSentryConnection(form.secret || undefined); + setTestResult(res); + } catch (err) { + setTestResult({ ok: false, error: String(err) }); + } finally { + setTesting(false); + } + }, [form, setTestResult]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const saved = await saveSentryConfig({ + authMethod: SENTRY_AUTH_METHOD, + secret: form.secret, + }); + setConfig(saved); + setForm(configToForm(saved)); + setTestResult(null); + toast({ description: "Sentry configuration saved", variant: "success" }); + } catch (err) { + toast({ description: `Save failed: ${String(err)}`, variant: "error" }); + } finally { + setSaving(false); + } + }, [form, toast, setConfig, setForm, setTestResult]); + + const handleDelete = useCallback(async () => { + if (!confirm("Remove Sentry configuration?")) return; + try { + await deleteSentryConfig(); + setConfig(null); + setForm(emptyForm); + setTestResult(null); + toast({ description: "Sentry configuration removed", variant: "success" }); + } catch (err) { + toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + } + }, [toast, setConfig, setForm, setTestResult]); + + return { saving, testing, handleTest, handleSave, handleDelete }; +} + +function useSentrySettings() { + const { toast } = useToast(); + const [config, setConfig] = useState(null); + const [form, setForm] = useState(emptyForm); + const [loading, setLoading] = useState(true); + const [testResult, setTestResult] = useState(null); + const health = configToHealth(config); + + const load = useCallback(async () => { + setLoading(true); + try { + const cfg = (await fetchSentryConfig()) ?? null; + setConfig(cfg); + setForm(configToForm(cfg)); + } catch (err) { + toast({ description: `Failed to load Sentry config: ${String(err)}`, variant: "error" }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + const id = setInterval(() => { + fetchSentryConfig() + .then((cfg) => setConfig(cfg ?? null)) + .catch(() => { + /* transient failures are fine — next tick retries */ + }); + }, INTEGRATION_STATUS_REFRESH_MS); + return () => clearInterval(id); + }, []); + + const update = useCallback( + (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })), + [], + ); + + const { saving, testing, handleTest, handleSave, handleDelete } = useSettingsActions({ + form, + setConfig, + setForm, + setTestResult, + }); + + return { + config, + form, + loading, + saving, + testing, + testResult, + health, + update, + handleTest, + handleSave, + handleDelete, + }; +} + +function EnabledPill() { + const { enabled, setEnabled } = useSentryEnabled(); + return ( +
+ + +
+ ); +} + +export function SentryConnectionSection() { + const s = useSentrySettings(); + const missingSecret = !s.config?.hasSecret && !s.form.secret; + const disableSave = s.saving || missingSecret; + const disableTest = missingSecret; + + return ( + } + title="Sentry integration" + description="Connect Kandev to Sentry with a user auth token. Credentials are stored encrypted server-side and shared across all workspaces." + action={} + > + + + + + + + + + + + ); +} + +export function SentryIntegrationPage() { + return ( +
+ + +
+ ); +} diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 1ea9843970..e58ecd4ed4 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -15,6 +15,7 @@ import { IconMessageCircle, IconBrandGithub, IconBrandGitlab, + IconBrandSentry, IconBrandSlack, IconHexagon, IconWand, @@ -203,6 +204,7 @@ function IntegrationsSidebarSection({ pathname }: { pathname: string }) { { href: "/settings/integrations/gitlab", label: "GitLab", Icon: IconBrandGitlab }, { href: "/settings/integrations/jira", label: "Jira", Icon: IconTicket }, { href: "/settings/integrations/linear", label: "Linear", Icon: IconHexagon }, + { href: "/settings/integrations/sentry", label: "Sentry", Icon: IconBrandSentry }, { href: "/settings/integrations/slack", label: "Slack", Icon: IconBrandSlack }, ]; return ( diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 2b789f89bf..096e95ffb8 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1313,7 +1313,7 @@ export class ApiClient { * navigating, otherwise the import bar can race the first render. */ async waitForIntegrationAuthHealthy( - integration: "jira" | "linear", + integration: "jira" | "linear" | "sentry", timeoutMs = 5_000, ): Promise { const deadline = Date.now() + timeoutMs; @@ -1416,6 +1416,34 @@ export class ApiClient { await this.request("PUT", "/api/v1/linear/mock/get-issue-error", args); } + // --- Sentry Mock Control --- + + async mockSentryReset(): Promise { + await this.request("DELETE", "/api/v1/sentry/mock/reset"); + } + + async mockSentrySetAuthResult(result: { + ok: boolean; + userId?: string; + displayName?: string; + email?: string; + error?: string; + }): Promise { + await this.request("PUT", "/api/v1/sentry/mock/auth-result", result); + } + + async mockSentrySetAuthHealth(args: { ok: boolean; error?: string }): Promise { + await this.request("PUT", "/api/v1/sentry/mock/auth-health", args); + } + + async mockSentrySetOrganizations(organizations: MockSentryOrganization[]): Promise { + await this.request("POST", "/api/v1/sentry/mock/organizations", { organizations }); + } + + async mockSentrySetProjects(projects: MockSentryProject[]): Promise { + await this.request("POST", "/api/v1/sentry/mock/projects", { projects }); + } + // --- Linear issue watch CRUD --- // Used by the agent-profile-delete spec to exercise the watcher dependency // surface added in the watcher self-heal PR. Filter shape matches @@ -1764,3 +1792,9 @@ export type MockLinearIssue = { priority?: number; url?: string; }; + +// --- Sentry mock payload types --- + +export type MockSentryOrganization = { id: string; slug: string; name: string }; + +export type MockSentryProject = { id: string; slug: string; name: string; orgSlug: string }; diff --git a/apps/web/e2e/pages/sentry-settings-page.ts b/apps/web/e2e/pages/sentry-settings-page.ts new file mode 100644 index 0000000000..0a157b3970 --- /dev/null +++ b/apps/web/e2e/pages/sentry-settings-page.ts @@ -0,0 +1,22 @@ +import { type Locator, type Page } from "@playwright/test"; + +export class SentrySettingsPage { + readonly secretInput: Locator; + readonly testButton: Locator; + readonly saveButton: Locator; + readonly deleteButton: Locator; + readonly statusBanner: Locator; + + constructor(private page: Page) { + this.secretInput = page.getByTestId("sentry-secret-input"); + this.testButton = page.getByTestId("sentry-test-button"); + this.saveButton = page.getByTestId("sentry-save-button"); + this.deleteButton = page.getByTestId("sentry-delete-button"); + this.statusBanner = page.getByTestId("integration-auth-status-banner"); + } + + async goto() { + await this.page.goto("/settings/integrations/sentry"); + await this.secretInput.waitFor({ state: "visible" }); + } +} diff --git a/apps/web/e2e/tests/integrations/sentry-settings.spec.ts b/apps/web/e2e/tests/integrations/sentry-settings.spec.ts new file mode 100644 index 0000000000..336fa14899 --- /dev/null +++ b/apps/web/e2e/tests/integrations/sentry-settings.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SentrySettingsPage } from "../../pages/sentry-settings-page"; + +const TOKEN = "sntrys_xxx"; + +test.describe("Sentry settings", () => { + // The settings page configures only the auth token; org/project are chosen + // per-watcher and per-browse, not stored install-wide. This covers the + // connect → save → validated flow and persistence of the saved token. + test("connects, saves, and reports a healthy token", async ({ testPage, apiClient }) => { + await apiClient.mockSentryReset(); + await apiClient.mockSentrySetAuthResult({ + ok: true, + userId: "u-1", + displayName: "Sentry Tester", + email: "tester@example.com", + }); + + const settings = new SentrySettingsPage(testPage); + await settings.goto(); + + // Connection test surfaces inline success ("Connected as "). + await settings.secretInput.fill(TOKEN); + await settings.testButton.click(); + await expect(testPage.getByText(/Connected as Sentry Tester/)).toBeVisible(); + + // Save persists the secret and triggers the auth-health probe that flips + // lastOk -> true. + await settings.saveButton.click(); + await expect(settings.saveButton).toHaveText(/Update/i); + await apiClient.waitForIntegrationAuthHealthy("sentry"); + + // Reload: the saved token survives (placeholder shows the masked state and + // the action becomes "Update"/"Remove configuration"). + await settings.goto(); + await expect(settings.saveButton).toHaveText(/Update/i); + await expect(settings.deleteButton).toBeVisible(); + }); +}); diff --git a/apps/web/hooks/domains/sentry/use-sentry-availability.ts b/apps/web/hooks/domains/sentry/use-sentry-availability.ts new file mode 100644 index 0000000000..f8097fb44a --- /dev/null +++ b/apps/web/hooks/domains/sentry/use-sentry-availability.ts @@ -0,0 +1,21 @@ +"use client"; + +import { fetchSentryConfig } from "@/lib/api/domains/sentry-api"; +import { + useIntegrationAuthed, + useIntegrationAvailable, +} from "../integrations/use-integration-availability"; +import { useSentryEnabled } from "./use-sentry-enabled"; + +const loadSentryConfig = async () => (await fetchSentryConfig()) ?? null; + +export function useSentryAuthed(): boolean { + return useIntegrationAuthed(loadSentryConfig); +} + +export function useSentryAvailable(): boolean { + return useIntegrationAvailable({ + useEnabled: useSentryEnabled, + fetchConfig: loadSentryConfig, + }); +} diff --git a/apps/web/hooks/domains/sentry/use-sentry-enabled.test.ts b/apps/web/hooks/domains/sentry/use-sentry-enabled.test.ts new file mode 100644 index 0000000000..c33cba731f --- /dev/null +++ b/apps/web/hooks/domains/sentry/use-sentry-enabled.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useSentryEnabled } from "./use-sentry-enabled"; + +const STORAGE_KEY = "kandev:sentry:enabled:v1"; + +function makeLocalStorageMock() { + const store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + get length() { + return store.size; + }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + }; +} + +const localStorageMock = makeLocalStorageMock(); +vi.stubGlobal("localStorage", localStorageMock); +Object.defineProperty(window, "localStorage", { + value: localStorageMock, + configurable: true, +}); + +describe("useSentryEnabled", () => { + beforeEach(() => { + localStorageMock.clear(); + }); + afterEach(() => { + localStorageMock.clear(); + }); + + it("defaults to enabled=true when no localStorage entry exists", async () => { + const { result } = renderHook(() => useSentryEnabled()); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(true); + }); + + it('reads enabled=false when stored as the literal string "false"', async () => { + window.localStorage.setItem(STORAGE_KEY, "false"); + const { result } = renderHook(() => useSentryEnabled()); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(false); + }); + + it.each(["true", "1", "yes", "legacy"])( + 'treats persisted value %p as enabled — only the literal "false" disables', + async (storedValue) => { + window.localStorage.setItem(STORAGE_KEY, storedValue); + const { result } = renderHook(() => useSentryEnabled()); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(true); + }, + ); + + it("setEnabled persists to localStorage and updates state", async () => { + const { result } = renderHook(() => useSentryEnabled()); + await waitFor(() => expect(result.current.loaded).toBe(true)); + + act(() => result.current.setEnabled(false)); + + expect(result.current.enabled).toBe(false); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("false"); + }); + + it("propagates updates dispatched via the kandev:sentry:enabled-changed event", async () => { + const { result } = renderHook(() => useSentryEnabled()); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.enabled).toBe(true); + + act(() => { + window.localStorage.setItem(STORAGE_KEY, "false"); + window.dispatchEvent(new Event("kandev:sentry:enabled-changed")); + }); + + await waitFor(() => expect(result.current.enabled).toBe(false)); + }); +}); diff --git a/apps/web/hooks/domains/sentry/use-sentry-enabled.ts b/apps/web/hooks/domains/sentry/use-sentry-enabled.ts new file mode 100644 index 0000000000..89b45129e8 --- /dev/null +++ b/apps/web/hooks/domains/sentry/use-sentry-enabled.ts @@ -0,0 +1,11 @@ +"use client"; + +import { useIntegrationEnabled } from "../integrations/use-integration-enabled"; + +const STORAGE_KEY = "kandev:sentry:enabled:v1"; +const LEGACY_KEY_PREFIX = "kandev:sentry:enabled:"; +const SYNC_EVENT = "kandev:sentry:enabled-changed"; + +export function useSentryEnabled() { + return useIntegrationEnabled(STORAGE_KEY, LEGACY_KEY_PREFIX, SYNC_EVENT); +} diff --git a/apps/web/hooks/domains/sentry/use-sentry-issue-watches.ts b/apps/web/hooks/domains/sentry/use-sentry-issue-watches.ts new file mode 100644 index 0000000000..170346492b --- /dev/null +++ b/apps/web/hooks/domains/sentry/use-sentry-issue-watches.ts @@ -0,0 +1,108 @@ +"use client"; + +import { useEffect, useCallback, useRef, useState } from "react"; +import { + listSentryIssueWatches, + createSentryIssueWatch, + updateSentryIssueWatch, + deleteSentryIssueWatch, + triggerSentryIssueWatch, +} from "@/lib/api/domains/sentry-api"; +import type { + CreateSentryIssueWatchRequest, + SentryIssueWatch, + UpdateSentryIssueWatchRequest, +} from "@/lib/types/sentry"; + +/** + * useSentryIssueWatches owns the Sentry-watcher list: + * - workspaceId: string → fetch and operate on watches in one workspace + * - workspaceId: undefined → fetch every watch across all workspaces + * - workspaceId: null → don't fetch + * + * Mirrors `useLinearIssueWatches`: update/delete/trigger pass the watch's + * workspace id as the `workspace_id` query param so the backend can guard + * cross-workspace mutations. + */ +export function useSentryIssueWatches(workspaceId?: string | null) { + const [items, setItems] = useState([]); + const [loaded, setLoaded] = useState(false); + const [loading, setLoading] = useState(false); + + // Track the raw workspaceId (not `?? null`) so the three scopes stay distinct: + // undefined = fetch all, null = don't fetch, string = one workspace. Collapsing + // undefined→null would hide an "all → none" transition and leave stale data. + const initialized = useRef(false); + const lastWorkspaceId = useRef(undefined); + + useEffect(() => { + if (initialized.current && lastWorkspaceId.current !== workspaceId) { + // Reset cached list on scope change (incl. → null). Also clear `loading`: + // a fetch from the old scope can no longer complete into the new one (its + // .finally is gated by `ignore`), and a → null scope starts no replacement + // fetch, so without this the hook would stay stuck at loading=true. + /* eslint-disable react-hooks/set-state-in-effect -- resetting cached state when scope changes */ + setItems([]); + setLoaded(false); + setLoading(false); + /* eslint-enable react-hooks/set-state-in-effect */ + } + initialized.current = true; + lastWorkspaceId.current = workspaceId; + }, [workspaceId]); + + useEffect(() => { + if (workspaceId === null || loaded) return; + // `loading` is intentionally NOT a dependency: setLoading(true) below would + // otherwise re-run this effect, whose cleanup sets ignore=true on the + // in-flight request, so its .finally skips setLoading(false) and the hook + // sticks at loading=true forever. The `loaded` guard already prevents a + // duplicate fetch, and `ignore` handles a workspaceId change mid-flight. + let ignore = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- starting external fetch + setLoading(true); + listSentryIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) + .then((res) => { + if (ignore) return; + setItems(res ?? []); + setLoaded(true); + }) + .catch(() => { + if (ignore) return; + setItems([]); + setLoaded(true); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + return () => { + ignore = true; + }; + }, [workspaceId, loaded]); + + const create = useCallback(async (req: CreateSentryIssueWatchRequest) => { + const watch = await createSentryIssueWatch(req); + setItems((prev) => [...prev, watch]); + return watch; + }, []); + + const update = useCallback( + async (id: string, watchWorkspaceId: string, req: UpdateSentryIssueWatchRequest) => { + const watch = await updateSentryIssueWatch(id, watchWorkspaceId, req); + setItems((prev) => prev.map((w) => (w.id === watch.id ? watch : w))); + return watch; + }, + [], + ); + + const remove = useCallback(async (id: string, watchWorkspaceId: string) => { + await deleteSentryIssueWatch(id, watchWorkspaceId); + setItems((prev) => prev.filter((w) => w.id !== id)); + }, []); + + const trigger = useCallback(async (id: string, watchWorkspaceId: string) => { + return triggerSentryIssueWatch(id, watchWorkspaceId); + }, []); + + return { items, loaded, loading, create, update, remove, trigger }; +} diff --git a/apps/web/lib/api/domains/sentry-api.ts b/apps/web/lib/api/domains/sentry-api.ts new file mode 100644 index 0000000000..f6b3e85216 --- /dev/null +++ b/apps/web/lib/api/domains/sentry-api.ts @@ -0,0 +1,161 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { + CreateSentryIssueWatchRequest, + SentryConfig, + SentryIssue, + SentryIssueWatch, + SentryOrganization, + SentryProject, + SentrySearchFilter, + SentrySearchResult, + SetSentryConfigRequest, + TestSentryConnectionResult, + UpdateSentryIssueWatchRequest, +} from "@/lib/types/sentry"; + +// fetchSentryConfig returns undefined when the backend responds 204 (no config yet). +export async function fetchSentryConfig( + options?: ApiRequestOptions, +): Promise { + return fetchJson(`/api/v1/sentry/config`, options); +} + +export async function saveSentryConfig( + payload: SetSentryConfigRequest, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/sentry/config`, { + ...options, + init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(payload) }, + }); +} + +export async function deleteSentryConfig(options?: ApiRequestOptions) { + return fetchJson<{ deleted: boolean }>(`/api/v1/sentry/config`, { + ...options, + init: { ...(options?.init ?? {}), method: "DELETE" }, + }); +} + +export async function testSentryConnection(secret?: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/sentry/config/test`, { + ...options, + init: { + ...(options?.init ?? {}), + method: "POST", + body: JSON.stringify(secret ? { secret } : {}), + }, + }); +} + +export async function listSentryOrganizations(options?: ApiRequestOptions) { + return fetchJson<{ organizations: SentryOrganization[] }>( + `/api/v1/sentry/organizations`, + options, + ); +} + +export async function listSentryProjects(options?: ApiRequestOptions) { + return fetchJson<{ projects: SentryProject[] }>(`/api/v1/sentry/projects`, options); +} + +function appendFilter(search: URLSearchParams, filter: SentrySearchFilter): void { + search.set("orgSlug", filter.orgSlug); + if (filter.projectSlug) search.set("projectSlug", filter.projectSlug); + if (filter.environment) search.set("environment", filter.environment); + if (filter.query) search.set("query", filter.query); + if (filter.statsPeriod) search.set("statsPeriod", filter.statsPeriod); + for (const level of filter.levels ?? []) search.append("level", level); + for (const status of filter.statuses ?? []) search.append("status", status); +} + +export async function searchSentryIssues( + filter: SentrySearchFilter, + cursor?: string, + options?: ApiRequestOptions, +) { + const search = new URLSearchParams(); + appendFilter(search, filter); + if (cursor) search.set("cursor", cursor); + return fetchJson(`/api/v1/sentry/issues?${search.toString()}`, options); +} + +export async function getSentryIssue(idOrShortId: string, options?: ApiRequestOptions) { + return fetchJson( + `/api/v1/sentry/issues/${encodeURIComponent(idOrShortId)}`, + options, + ); +} + +// --- Issue watches --- + +// listSentryIssueWatches fetches watches across all workspaces when +// workspaceId is omitted, or scoped to one workspace when provided. +export async function listSentryIssueWatches(workspaceId?: string, options?: ApiRequestOptions) { + const path = workspaceId + ? `/api/v1/sentry/watches/issue?workspace_id=${encodeURIComponent(workspaceId)}` + : `/api/v1/sentry/watches/issue`; + const res = await fetchJson<{ watches: SentryIssueWatch[] }>(path, options); + return res.watches ?? []; +} + +export async function getSentryIssueWatch( + id: string, + workspaceId: string, + options?: ApiRequestOptions, +) { + return fetchJson( + `/api/v1/sentry/watches/issue/${encodeURIComponent(id)}?workspace_id=${encodeURIComponent(workspaceId)}`, + options, + ); +} + +export async function createSentryIssueWatch( + payload: CreateSentryIssueWatchRequest, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/sentry/watches/issue`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, + }); +} + +export async function updateSentryIssueWatch( + id: string, + workspaceId: string, + payload: UpdateSentryIssueWatchRequest, + options?: ApiRequestOptions, +) { + return fetchJson( + `/api/v1/sentry/watches/issue/${encodeURIComponent(id)}?workspace_id=${encodeURIComponent(workspaceId)}`, + { + ...options, + init: { ...(options?.init ?? {}), method: "PATCH", body: JSON.stringify(payload) }, + }, + ); +} + +export async function deleteSentryIssueWatch( + id: string, + workspaceId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ deleted: boolean }>( + `/api/v1/sentry/watches/issue/${encodeURIComponent(id)}?workspace_id=${encodeURIComponent(workspaceId)}`, + { + ...options, + init: { ...(options?.init ?? {}), method: "DELETE" }, + }, + ); +} + +export async function triggerSentryIssueWatch( + id: string, + workspaceId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ published: number }>( + `/api/v1/sentry/watches/issue/${encodeURIComponent(id)}/trigger?workspace_id=${encodeURIComponent(workspaceId)}`, + { ...options, init: { ...(options?.init ?? {}), method: "POST" } }, + ); +} diff --git a/apps/web/lib/types/sentry.ts b/apps/web/lib/types/sentry.ts new file mode 100644 index 0000000000..44c8b1b2dd --- /dev/null +++ b/apps/web/lib/types/sentry.ts @@ -0,0 +1,118 @@ +export type SentryAuthMethod = "auth_token"; + +export const SENTRY_AUTH_METHOD: SentryAuthMethod = "auth_token"; + +export interface SentryConfig { + authMethod: SentryAuthMethod; + hasSecret: boolean; + lastCheckedAt?: string | null; + lastOk: boolean; + lastError?: string; + createdAt: string; + updatedAt: string; +} + +export interface SetSentryConfigRequest { + authMethod: SentryAuthMethod; + secret: string; +} + +export interface TestSentryConnectionResult { + ok: boolean; + userId?: string; + displayName?: string; + email?: string; + error?: string; +} + +export interface SentryOrganization { + id: string; + slug: string; + name: string; +} + +export interface SentryProject { + id: string; + slug: string; + name: string; + orgSlug: string; +} + +export type SentryLevel = "fatal" | "error" | "warning" | "info" | "debug"; + +export type SentryStatus = "unresolved" | "resolved" | "ignored"; + +export interface SentryIssue { + id: string; + shortId: string; + title: string; + culprit?: string; + permalink: string; + projectSlug: string; + projectName?: string; + level: SentryLevel; + status: SentryStatus; + count?: string; + userCount?: number; + firstSeen?: string; + lastSeen?: string; + assigneeName?: string; +} + +export interface SentrySearchFilter { + orgSlug: string; + projectSlug?: string; + environment?: string; + levels?: SentryLevel[]; + statuses?: SentryStatus[]; + query?: string; + statsPeriod?: string; +} + +export interface SentrySearchResult { + issues: SentryIssue[]; + nextPageToken?: string; + isLast: boolean; +} + +export interface SentryIssueWatch { + id: string; + workspaceId: string; + workflowId: string; + workflowStepId: string; + filter: SentrySearchFilter; + agentProfileId: string; + executorProfileId: string; + prompt: string; + enabled: boolean; + pollIntervalSeconds: number; + maxInflightTasks?: number | null; + lastPolledAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateSentryIssueWatchRequest { + workspaceId: string; + workflowId: string; + workflowStepId: string; + filter: SentrySearchFilter; + agentProfileId: string; + executorProfileId: string; + prompt: string; + pollIntervalSeconds: number; + maxInflightTasks?: number | null; + enabled?: boolean; +} + +export interface UpdateSentryIssueWatchRequest { + workflowId?: string; + workflowStepId?: string; + filter?: SentrySearchFilter; + agentProfileId?: string; + executorProfileId?: string; + prompt?: string; + enabled?: boolean; + pollIntervalSeconds?: number; + maxInflightTasks?: number | null; +}