From a303ef7d6ff20f8d1059ca69892bbde03fbc0468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Fiere?= Date: Tue, 28 Jul 2026 10:29:33 +0200 Subject: [PATCH] feat(sentry): support polling multiple projects per issue watcher (#1978) --- apps/backend/internal/sentry/handlers.go | 26 +- apps/backend/internal/sentry/handlers_test.go | 23 +- apps/backend/internal/sentry/mock_client.go | 2 +- .../internal/sentry/mock_client_test.go | 26 +- apps/backend/internal/sentry/models.go | 45 +++- apps/backend/internal/sentry/models_test.go | 73 ++++++ apps/backend/internal/sentry/rest_client.go | 19 +- .../internal/sentry/rest_client_test.go | 28 ++- .../internal/sentry/service_issue_watch.go | 81 +++++-- .../service_issue_watch_repository_test.go | 8 +- .../sentry/service_issue_watch_test.go | 130 +++++++++- .../internal/sentry/store_issue_watch_test.go | 10 +- .../components/sentry/sentry-issue-dialog.tsx | 2 +- .../sentry/sentry-issue-watch-dialog.test.tsx | 69 +++++- .../sentry/sentry-issue-watch-dialog.tsx | 201 +-------------- .../sentry-issue-watch-filter-fields.tsx | 228 ++++++++++++++++++ .../sentry/sentry-issue-watch-form.test.ts | 57 ++++- .../sentry/sentry-issue-watch-form.ts | 24 +- .../sentry/sentry-issue-watch-multiselect.tsx | 92 +++++++ .../sentry/sentry-issue-watch-table.test.tsx | 9 +- .../sentry/sentry-issue-watch-table.tsx | 4 +- apps/web/e2e/helpers/api-client.ts | 4 +- .../integrations/sentry-settings.spec.ts | 77 +++++- apps/web/lib/api/domains/sentry-api.test.ts | 37 ++- apps/web/lib/api/domains/sentry-api.ts | 2 +- apps/web/lib/types/sentry.ts | 2 +- 26 files changed, 980 insertions(+), 299 deletions(-) create mode 100644 apps/backend/internal/sentry/models_test.go create mode 100644 apps/web/components/sentry/sentry-issue-watch-filter-fields.tsx diff --git a/apps/backend/internal/sentry/handlers.go b/apps/backend/internal/sentry/handlers.go index a5ddc283b3..aea7a25099 100644 --- a/apps/backend/internal/sentry/handlers.go +++ b/apps/backend/internal/sentry/handlers.go @@ -217,14 +217,26 @@ func (c *Controller) httpSearchIssues(ctx *gin.Context) { return } q := ctx.Request.URL.Query() + projectSlugs := trimAll(q["projectSlug"]) + if len(projectSlugs) > 1 { + // Sentry's project-scoped issue-search endpoint only accepts one + // project per request, and the browse dialog's UI is single-select + // (the plural filter is reserved for watcher polling, which fans a + // multi-project watch out into one request per project itself — see + // Service.CheckIssueWatch). Reject early with a clear message + // instead of round-tripping to Sentry for the REST client's generic + // "at most one project slug per request" 400. + writeErr(ctx, http.StatusBadRequest, "issue browse supports at most one project") + return + } 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"]), + OrgSlug: q.Get("orgSlug"), + ProjectSlugs: projectSlugs, + Environment: q.Get("environment"), + Query: q.Get("query"), + StatsPeriod: q.Get("statsPeriod"), + Levels: trimAll(q["level"]), + Statuses: trimAll(q["status"]), } result, err := c.service.SearchIssues(ctx.Request.Context(), workspaceID, c.instanceID(ctx), filter, q.Get("cursor")) if err != nil { diff --git a/apps/backend/internal/sentry/handlers_test.go b/apps/backend/internal/sentry/handlers_test.go index 90930db856..40020890cc 100644 --- a/apps/backend/internal/sentry/handlers_test.go +++ b/apps/backend/internal/sentry/handlers_test.go @@ -236,7 +236,7 @@ func TestHTTP_SearchIssues_ForwardsFilter(t *testing.T) { if w := do(router, http.MethodGet, target, ""); 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" { + if seen.OrgSlug != "acme" || !sameStrings(seen.ProjectSlugs, []string{"fe"}) || seen.Query != "boom" || seen.StatsPeriod != "24h" { t.Errorf("filter = %+v", seen) } if !sameStrings(seen.Levels, []string{"error", "fatal"}) { @@ -247,6 +247,27 @@ func TestHTTP_SearchIssues_ForwardsFilter(t *testing.T) { } } +// TestHTTP_SearchIssues_RejectsMultipleProjectSlugs pins the P2 fix: Sentry's +// project-scoped issue-search endpoint (and the browse dialog's single-select +// UI) only ever deal with one project at a time, so the browse endpoint +// rejects several repeated `projectSlug` query params with 400 up front +// instead of forwarding a slice the real REST client would 400 on anyway. +// The plural filter remains watcher-only (see Service.CheckIssueWatch's +// per-project fan-out). +func TestHTTP_SearchIssues_RejectsMultipleProjectSlugs(t *testing.T) { + ctrl, router, client := newTestController(t) + inst := seedInstance(t, ctrl, "ws-1", "A", "tok") + client.searchIssuesFn = func(filter SearchFilter, _ string) (*SearchResult, error) { + t.Fatalf("client should not be called with multiple project slugs, got %+v", filter) + return nil, nil + } + target := "/api/v1/sentry/issues?workspace_id=ws-1&instanceId=" + inst.ID + + "&orgSlug=acme&projectSlug=frontend&projectSlug=backend" + if w := do(router, http.MethodGet, target, ""); w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body=%s", w.Code, w.Body.String()) + } +} + func TestHTTP_GetIssue_ForwardsID(t *testing.T) { ctrl, router, client := newTestController(t) inst := seedInstance(t, ctrl, "ws-1", "A", "tok") diff --git a/apps/backend/internal/sentry/mock_client.go b/apps/backend/internal/sentry/mock_client.go index 1e1d640bf9..46efb712b8 100644 --- a/apps/backend/internal/sentry/mock_client.go +++ b/apps/backend/internal/sentry/mock_client.go @@ -206,7 +206,7 @@ func (m *MockClient) Reset() { // analog on SentryIssue): OrgSlug, Environment, and StatsPeriod. 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 { + if len(f.ProjectSlugs) > 0 && !containsFold(f.ProjectSlugs, issue.ProjectSlug) { return false } if len(f.Levels) > 0 && !containsFold(f.Levels, issue.Level) { diff --git a/apps/backend/internal/sentry/mock_client_test.go b/apps/backend/internal/sentry/mock_client_test.go index f2a2551e44..8de2c60c5d 100644 --- a/apps/backend/internal/sentry/mock_client_test.go +++ b/apps/backend/internal/sentry/mock_client_test.go @@ -25,7 +25,7 @@ func TestMockClient_SearchFiltersByProject(t *testing.T) { m.AddIssue("inst-1", &SentryIssue{ShortID: "BE-1", Title: "Crash", Level: "fatal", Status: "unresolved", ProjectSlug: "backend"}) res, err := mockInstance(m, "inst-1").SearchIssues(context.Background(), SearchFilter{ - OrgSlug: "acme", ProjectSlug: "frontend", + OrgSlug: "acme", ProjectSlugs: []string{"frontend"}, }, "") if err != nil { t.Fatalf("search: %v", err) @@ -35,6 +35,30 @@ func TestMockClient_SearchFiltersByProject(t *testing.T) { } } +// TestMockClient_SearchMatchesAnyConfiguredProject pins the ANY-of semantics +// (same as Levels/Statuses) a multi-project watch relies on: a filter listing +// several projects matches an issue from any one of them. +func TestMockClient_SearchMatchesAnyConfiguredProject(t *testing.T) { + m := NewMockClient() + m.AddIssue("inst-1", &SentryIssue{ShortID: "FE-1", ProjectSlug: "frontend"}) + m.AddIssue("inst-1", &SentryIssue{ShortID: "BE-1", ProjectSlug: "backend"}) + m.AddIssue("inst-1", &SentryIssue{ShortID: "OTHER-1", ProjectSlug: "other"}) + + res, err := mockInstance(m, "inst-1").SearchIssues(context.Background(), SearchFilter{ + OrgSlug: "acme", ProjectSlugs: []string{"frontend", "backend"}, + }, "") + 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, []string{"FE-1", "BE-1"}) { + t.Errorf("got %v, want FE-1 and BE-1 only", got) + } +} + func TestMockClient_SearchFiltersByLevelStatusQuery(t *testing.T) { m := NewMockClient() m.AddIssue("inst-1", &SentryIssue{ShortID: "A-1", Title: "Login failed", Level: "error", Status: "unresolved", ProjectSlug: "fe"}) diff --git a/apps/backend/internal/sentry/models.go b/apps/backend/internal/sentry/models.go index de43db0c87..00968cf2f0 100644 --- a/apps/backend/internal/sentry/models.go +++ b/apps/backend/internal/sentry/models.go @@ -7,6 +7,7 @@ package sentry import ( + "encoding/json" "time" "github.com/kandev/kandev/internal/integrations/optional" @@ -141,14 +142,44 @@ type SentryIssue struct { // 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. +// +// ProjectSlugs supports polling/searching several projects at once (e.g. a +// watcher scoped to "frontend" and "backend"). A single-request Sentry search +// (RESTClient.SearchIssues) only accepts one project per call — callers that +// need several issue an underlying request per slug and merge the results +// (see Service.CheckIssueWatch). Empty = browse "all projects" in the org, +// used only by the manual issue browser, whose single-select UI and the +// httpSearchIssues handler both cap it at one entry when non-empty; a +// watch's filter requires at least one entry (enforced in validateFilter). 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"` + OrgSlug string `json:"orgSlug"` + ProjectSlugs []string `json:"projectSlugs,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"` +} + +// UnmarshalJSON accepts the legacy singular `projectSlug` string (stored by +// every issue watch persisted before multi-project support) alongside the +// current `projectSlugs` array, so existing rows keep their configured +// project after this upgrade instead of silently losing it on next read. +// When both are present (should not happen in practice) the plural field +// wins. Marshaling always emits the plural key — this is read-compat only. +func (f *SearchFilter) UnmarshalJSON(data []byte) error { + type plain SearchFilter // avoid recursing back into this method + aux := struct { + *plain + ProjectSlug *string `json:"projectSlug"` + }{plain: (*plain)(f)} + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + if len(f.ProjectSlugs) == 0 && aux.ProjectSlug != nil && *aux.ProjectSlug != "" { + f.ProjectSlugs = []string{*aux.ProjectSlug} + } + return nil } // SearchResult is a page of issues from a search. Sentry uses opaque cursors diff --git a/apps/backend/internal/sentry/models_test.go b/apps/backend/internal/sentry/models_test.go new file mode 100644 index 0000000000..252780e1aa --- /dev/null +++ b/apps/backend/internal/sentry/models_test.go @@ -0,0 +1,73 @@ +package sentry + +import ( + "encoding/json" + "testing" +) + +// TestSearchFilter_MarshalJSON_EmitsPluralProjectSlugs pins the current wire +// shape: a filter with multiple projects serializes to a `projectSlugs` array, +// never the legacy singular `projectSlug` key. +func TestSearchFilter_MarshalJSON_EmitsPluralProjectSlugs(t *testing.T) { + f := SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend", "backend"}} + raw, err := json.Marshal(f) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode raw: %v", err) + } + if _, ok := decoded["projectSlug"]; ok { + t.Errorf("expected no legacy projectSlug key in output, got %s", raw) + } + slugs, ok := decoded["projectSlugs"].([]interface{}) + if !ok || len(slugs) != 2 || slugs[0] != "frontend" || slugs[1] != "backend" { + t.Errorf("expected projectSlugs=[frontend backend], got %s", raw) + } +} + +// TestSearchFilter_UnmarshalJSON_LegacySingularProjectSlug pins backward +// compatibility: a watch persisted before multi-project support stored +// `projectSlug` (singular string) in filter_json. Existing rows must keep +// their configured project after this upgrade instead of silently losing it. +func TestSearchFilter_UnmarshalJSON_LegacySingularProjectSlug(t *testing.T) { + var f SearchFilter + raw := `{"orgSlug":"acme","projectSlug":"frontend","environment":"prod"}` + if err := json.Unmarshal([]byte(raw), &f); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if f.OrgSlug != "acme" || f.Environment != "prod" { + t.Errorf("unrelated fields lost: %+v", f) + } + if len(f.ProjectSlugs) != 1 || f.ProjectSlugs[0] != "frontend" { + t.Errorf("expected legacy projectSlug promoted to ProjectSlugs=[frontend], got %+v", f.ProjectSlugs) + } +} + +// TestSearchFilter_UnmarshalJSON_PluralTakesPrecedence pins that a payload +// carrying both keys (should never happen in practice, but decoding must be +// deterministic) prefers the current plural field. +func TestSearchFilter_UnmarshalJSON_PluralTakesPrecedence(t *testing.T) { + var f SearchFilter + raw := `{"orgSlug":"acme","projectSlug":"legacy","projectSlugs":["new-a","new-b"]}` + if err := json.Unmarshal([]byte(raw), &f); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(f.ProjectSlugs) != 2 || f.ProjectSlugs[0] != "new-a" || f.ProjectSlugs[1] != "new-b" { + t.Errorf("expected plural projectSlugs to win, got %+v", f.ProjectSlugs) + } +} + +// TestSearchFilter_UnmarshalJSON_NoProjectFields pins that a filter with +// neither key decodes to an empty (nil) ProjectSlugs rather than erroring — +// validation of "at least one project" happens in the service layer, not here. +func TestSearchFilter_UnmarshalJSON_NoProjectFields(t *testing.T) { + var f SearchFilter + if err := json.Unmarshal([]byte(`{"orgSlug":"acme"}`), &f); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(f.ProjectSlugs) != 0 { + t.Errorf("expected no project slugs, got %+v", f.ProjectSlugs) + } +} diff --git a/apps/backend/internal/sentry/rest_client.go b/apps/backend/internal/sentry/rest_client.go index 8512ecd112..1264e97512 100644 --- a/apps/backend/internal/sentry/rest_client.go +++ b/apps/backend/internal/sentry/rest_client.go @@ -385,6 +385,13 @@ func (c *RESTClient) searchIssues( if filter.OrgSlug == "" { return nil, &APIError{StatusCode: http.StatusBadRequest, Message: "orgSlug required"} } + if len(filter.ProjectSlugs) > 1 { + // One Sentry request is scoped to at most one project (see + // issuesSearchPath). A watch polling several projects issues one + // request per slug — see Service.CheckIssueWatch — instead of + // reaching this method with more than one. + return nil, &APIError{StatusCode: http.StatusBadRequest, Message: "searchIssues: at most one project slug per request"} + } q := url.Values{} if limit > 0 { q.Set("per_page", strconv.Itoa(limit)) @@ -406,7 +413,7 @@ func (c *RESTClient) searchIssues( q.Set("query", built) } var nodes []issueNode - resp, err := c.do(ctx, issuesSearchPath(filter.OrgSlug, filter.ProjectSlug), q, &nodes) + resp, err := c.do(ctx, issuesSearchPath(filter.OrgSlug, projectSlugForRequest(filter)), q, &nodes) if err != nil { return nil, err } @@ -438,6 +445,16 @@ func issuesSearchPath(orgSlug, projectSlug string) string { return "/projects/" + url.PathEscape(orgSlug) + "/" + url.PathEscape(projectSlug) + "/issues/" } +// projectSlugForRequest returns the sole project slug to scope a single +// Sentry request to, or "" for the org-wide fallback. searchIssues already +// rejects more than one entry, so this is just an ergonomic accessor. +func projectSlugForRequest(f SearchFilter) string { + if len(f.ProjectSlugs) == 0 { + return "" + } + return f.ProjectSlugs[0] +} + // buildIssueQueryString assembles Sentry's search-bar syntax from a // SearchFilter. Multiple levels use Sentry's IN-filter bracket syntax // (`level:[error, fatal]`), which matches any of the listed values; a single diff --git a/apps/backend/internal/sentry/rest_client_test.go b/apps/backend/internal/sentry/rest_client_test.go index 68fb074716..8733c57380 100644 --- a/apps/backend/internal/sentry/rest_client_test.go +++ b/apps/backend/internal/sentry/rest_client_test.go @@ -124,12 +124,12 @@ func TestRESTClient_SearchIssues_BuildsQueryStringAndPaginates(t *testing.T) { }) 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", + OrgSlug: "acme", + ProjectSlugs: []string{"frontend"}, + Environment: "prod", + Levels: []string{"error"}, + Statuses: []string{"unresolved"}, + Query: "boom", }, "abc") if err != nil { t.Fatalf("search: %v", err) @@ -171,6 +171,22 @@ func TestRESTClient_SearchIssues_RequiresOrgSlug(t *testing.T) { } } +// TestRESTClient_SearchIssues_RejectsMultipleProjectSlugs pins that a single +// REST request can only scope to one project (Sentry's project-scoped issue +// endpoint takes exactly one slug in its path). Watches spanning several +// projects issue one request per slug at the service layer instead of +// reaching this method with more than one — see Service.CheckIssueWatch. +func TestRESTClient_SearchIssues_RejectsMultipleProjectSlugs(t *testing.T) { + c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), "http://nope") + _, err := c.SearchIssues(context.Background(), SearchFilter{ + OrgSlug: "acme", + ProjectSlugs: []string{"frontend", "backend"}, + }, "") + if err == nil { + t.Fatal("expected error for multiple project slugs in one request") + } +} + // 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 diff --git a/apps/backend/internal/sentry/service_issue_watch.go b/apps/backend/internal/sentry/service_issue_watch.go index 9e3dc9e2da..cc2faeba23 100644 --- a/apps/backend/internal/sentry/service_issue_watch.go +++ b/apps/backend/internal/sentry/service_issue_watch.go @@ -179,12 +179,13 @@ func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, err return res.TasksDeleted, err } -// CheckIssueWatch runs the watch's filter once and returns the Sentry -// instance actually polled (resolved via resolveWatchInstanceID — never -// w.SentryInstanceID directly, which is empty for an unbound legacy watch) -// plus 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". +// CheckIssueWatch runs the watch's filter once per configured project and +// returns the Sentry instance actually polled (resolved via +// resolveWatchInstanceID — never w.SentryInstanceID directly, which is empty +// for an unbound legacy watch) plus the issues that haven't been turned into +// tasks yet, merged across every project. 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 @@ -192,6 +193,15 @@ func (s *Service) ResetIssueWatch(ctx context.Context, watchID string) (int, err // bails. Same pattern as the Linear / Jira watchers. func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) (string, []*SentryIssue, error) { defer s.stampWatchLastPolled(w.ID) + if len(w.Filter.ProjectSlugs) == 0 { + // A watch with no configured project is invalid, not "browse the whole + // org" — validateFilter rejects this at create/update time, so reaching + // it here means corrupted/legacy data. Fail loudly instead of silently + // polling (and creating tasks for) every project in the org. + err := fmt.Errorf("%w: watch has no configured project", ErrInvalidConfig) + s.stampWatchError(w.ID, err.Error()) + return "", nil, err + } instanceID, err := s.resolveWatchInstanceID(ctx, w) if err != nil { s.stampWatchError(w.ID, err.Error()) @@ -201,28 +211,38 @@ func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) (string, [ if err != nil { return instanceID, 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 instanceID, 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. + // a nil map would let the whole page (up to 100 issues per project) + // publish as events. The next tick retries with a working dedup set. return instanceID, 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 := make([]*SentryIssue, 0, len(w.Filter.ProjectSlugs)) + for _, projectSlug := range w.Filter.ProjectSlugs { + projectFilter := w.Filter + projectFilter.ProjectSlugs = []string{projectSlug} + // Intentionally reads only the first page per project 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, projectFilter, "") + if err != nil { + return instanceID, nil, fmt.Errorf("search issues for project %q: %w", projectSlug, err) + } + for i := range res.Issues { + issue := res.Issues[i] + if _, ok := seen[issue.ShortID]; ok { + continue + } + // Guards against the same short_id surfacing twice within this + // tick (e.g. a duplicate page entry) so the orchestrator never + // gets handed the same issue twice from a single CheckIssueWatch + // call. + seen[issue.ShortID] = struct{}{} + out = append(out, &issue) } - out = append(out, &issue) } s.clearWatchError(w.ID) return instanceID, out, nil @@ -438,8 +458,8 @@ 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) + if len(f.ProjectSlugs) == 0 { + return fmt.Errorf("%w: filter.projectSlugs must contain at least one project", ErrInvalidConfig) } return nil } @@ -471,11 +491,22 @@ func validatePollInterval(seconds int) error { 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), } + seenProjects := make(map[string]struct{}, len(f.ProjectSlugs)) + for _, v := range f.ProjectSlugs { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, dup := seenProjects[v]; dup { + continue + } + seenProjects[v] = struct{}{} + out.ProjectSlugs = append(out.ProjectSlugs, v) + } for _, v := range f.Levels { v = strings.TrimSpace(v) if v != "" { diff --git a/apps/backend/internal/sentry/service_issue_watch_repository_test.go b/apps/backend/internal/sentry/service_issue_watch_repository_test.go index dc45d4dc1d..47618693fb 100644 --- a/apps/backend/internal/sentry/service_issue_watch_repository_test.go +++ b/apps/backend/internal/sentry/service_issue_watch_repository_test.go @@ -25,7 +25,7 @@ func TestService_CreateIssueWatch_RepositoryBinding(t *testing.T) { SentryInstanceID: instID, WorkflowID: "wf", WorkflowStepID: "step", - Filter: SearchFilter{OrgSlug: "acme", ProjectSlug: "frontend"}, + Filter: SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend"}}, } } @@ -102,7 +102,7 @@ func TestService_UpdateIssueWatch_RepositoryBinding(t *testing.T) { SentryInstanceID: f.ensureInstance(t, "ws-1"), WorkflowID: "wf", WorkflowStepID: "step", - Filter: SearchFilter{OrgSlug: "acme", ProjectSlug: "frontend"}, + Filter: SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend"}}, }) if err != nil { t.Fatalf("create: %v", err) @@ -138,7 +138,7 @@ func TestService_UpdateIssueWatch_RebindAndDeletedRepo(t *testing.T) { w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ WorkspaceID: "ws-1", SentryInstanceID: f.ensureInstance(t, "ws-1"), WorkflowID: "wf", WorkflowStepID: "step", - Filter: SearchFilter{OrgSlug: "acme", ProjectSlug: "frontend"}, + Filter: SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend"}}, }) if err != nil { t.Fatalf("create: %v", err) @@ -178,7 +178,7 @@ func TestService_IssueWatch_RejectsInvalidBaseBranch(t *testing.T) { return &CreateIssueWatchRequest{ WorkspaceID: "ws-1", SentryInstanceID: instID, WorkflowID: "wf", WorkflowStepID: "step", - Filter: SearchFilter{OrgSlug: "acme", ProjectSlug: "frontend"}, RepositoryID: "repo-1", + Filter: SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend"}}, RepositoryID: "repo-1", } } diff --git a/apps/backend/internal/sentry/service_issue_watch_test.go b/apps/backend/internal/sentry/service_issue_watch_test.go index 9bbca83a78..1baf227b67 100644 --- a/apps/backend/internal/sentry/service_issue_watch_test.go +++ b/apps/backend/internal/sentry/service_issue_watch_test.go @@ -19,7 +19,7 @@ func (c *fakeClient) withSearchResults(issues []SentryIssue) *fakeClient { } func validFilter() SearchFilter { - return SearchFilter{OrgSlug: "acme", ProjectSlug: "frontend"} + return SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend"}} } func intPtr(value int) *int { return &value @@ -34,8 +34,8 @@ func TestService_CreateIssueWatch_DefaultsAndValidation(t *testing.T) { for name, filter := range map[string]SearchFilter{ "empty": {}, "org only": {OrgSlug: "acme"}, - "whitespace org": {OrgSlug: " ", ProjectSlug: "frontend"}, - "multi status": {OrgSlug: "acme", ProjectSlug: "frontend", Statuses: []string{"unresolved", "ignored"}}, + "whitespace org": {OrgSlug: " ", ProjectSlugs: []string{"frontend"}}, + "multi status": {OrgSlug: "acme", ProjectSlugs: []string{"frontend"}, Statuses: []string{"unresolved", "ignored"}}, } { if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ WorkspaceID: "ws-1", SentryInstanceID: instID, @@ -96,7 +96,7 @@ func TestService_UpdateIssueWatch_LegacyMultiStatusToggle(t *testing.T) { if _, err := f.svc.UpdateIssueWatch(ctx, w.ID, &UpdateIssueWatchRequest{Enabled: &disabled}); err != nil { t.Fatalf("toggle on legacy multi-status watch should succeed, got %v", err) } - bad := SearchFilter{OrgSlug: "acme", ProjectSlug: "frontend", Statuses: []string{"unresolved", "ignored"}} + bad := SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend"}, Statuses: []string{"unresolved", "ignored"}} if _, err := f.svc.UpdateIssueWatch(ctx, w.ID, &UpdateIssueWatchRequest{Filter: &bad}); !errors.Is(err, ErrInvalidConfig) { t.Errorf("filter patch with multiple statuses should be rejected, got %v", err) } @@ -458,3 +458,125 @@ func TestService_CheckIssueWatch_UnboundNoInstanceStampsError(t *testing.T) { t.Error("watch must stay enabled (stamp + skip, not disable) so it auto-heals") } } + +// TestService_CheckIssueWatch_PollsEveryConfiguredProject pins multi-project +// support: a watch bound to several projects issues one SearchIssues call per +// project and merges the results into a single unseen-issue list. +func TestService_CheckIssueWatch_PollsEveryConfiguredProject(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + inst := f.seedInstance(t, "ws-1", "A", "sntrys_abc") + + var seenFilters []SearchFilter + f.client.searchIssuesFn = func(filter SearchFilter, _ string) (*SearchResult, error) { + seenFilters = append(seenFilters, filter) + if len(filter.ProjectSlugs) != 1 { + t.Fatalf("expected exactly one project slug per request, got %+v", filter) + } + switch filter.ProjectSlugs[0] { + case "frontend": + return &SearchResult{Issues: []SentryIssue{{ShortID: "FE-1", Title: "fe issue"}}, IsLast: true}, nil + case "backend": + return &SearchResult{Issues: []SentryIssue{{ShortID: "BE-1", Title: "be issue"}}, IsLast: true}, nil + default: + t.Fatalf("unexpected project slug forwarded: %q", filter.ProjectSlugs[0]) + return nil, nil + } + } + + w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", SentryInstanceID: inst.ID, + WorkflowID: "wf", WorkflowStepID: "step", + Filter: SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend", "backend"}}, + }) + if err != nil { + t.Fatalf("create watch: %v", err) + } + + _, got, err := f.svc.CheckIssueWatch(ctx, w) + if err != nil { + t.Fatalf("check: %v", err) + } + if len(seenFilters) != 2 { + t.Fatalf("expected one SearchIssues call per project, got %d", len(seenFilters)) + } + gotIDs := make([]string, 0, len(got)) + for _, i := range got { + gotIDs = append(gotIDs, i.ShortID) + } + if !sameStrings(gotIDs, []string{"FE-1", "BE-1"}) { + t.Errorf("expected issues merged across both projects, got %v", gotIDs) + } +} + +// TestService_CheckIssueWatch_ProjectFailureAbortsCheck pins that a search +// failure on any one of a watch's several projects surfaces to the caller +// (fail-fast, matching the single-project behavior) rather than silently +// returning a partial result. +func TestService_CheckIssueWatch_ProjectFailureAbortsCheck(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + inst := f.seedInstance(t, "ws-1", "A", "sntrys_abc") + f.client.searchIssuesFn = func(filter SearchFilter, _ string) (*SearchResult, error) { + if len(filter.ProjectSlugs) == 1 && filter.ProjectSlugs[0] == "backend" { + return nil, errors.New("upstream 500") + } + return &SearchResult{Issues: []SentryIssue{{ShortID: "FE-1"}}, IsLast: true}, nil + } + w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", SentryInstanceID: inst.ID, + WorkflowID: "wf", WorkflowStepID: "step", + Filter: SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend", "backend"}}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if _, _, err := f.svc.CheckIssueWatch(ctx, w); err == nil { + t.Error("expected error when any configured project's search fails") + } +} + +// TestService_CheckIssueWatch_NoProjectConfigured_ReturnsError pins the +// safety rule for corrupted/legacy data: a watch that somehow persisted with +// zero project slugs must fail loudly, never fall back to polling (and +// creating tasks for) every project in the org. +func TestService_CheckIssueWatch_NoProjectConfigured_ReturnsError(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + inst := f.seedInstance(t, "ws-1", "A", "sntrys_abc") + w := newTestIssueWatch("ws-1") + w.SentryInstanceID = inst.ID + w.Filter.ProjectSlugs = nil + if err := f.store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("seed watch: %v", err) + } + if _, _, err := f.svc.CheckIssueWatch(ctx, w); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for watch with no configured project, got %v", err) + } + refreshed, _ := f.store.GetIssueWatch(ctx, w.ID) + if refreshed.LastError == "" { + t.Error("expected last_error stamped for unpollable watch") + } +} + +func TestValidateFilter_RequiresAtLeastOneProjectSlug(t *testing.T) { + if err := validateFilter(SearchFilter{OrgSlug: "acme"}); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for filter with no projects, got %v", err) + } + if err := validateFilter(SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend"}}); err != nil { + t.Errorf("expected valid single-project filter to pass, got %v", err) + } + if err := validateFilter(SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"frontend", "backend"}}); err != nil { + t.Errorf("expected valid multi-project filter to pass, got %v", err) + } +} + +func TestNormalizeFilter_TrimsDedupesAndDropsEmptyProjectSlugs(t *testing.T) { + got := normalizeFilter(SearchFilter{ + OrgSlug: "acme", + ProjectSlugs: []string{" frontend ", "backend", "", "frontend", " "}, + }) + if !sameStrings(got.ProjectSlugs, []string{"frontend", "backend"}) { + t.Errorf("expected [frontend backend], got %v", got.ProjectSlugs) + } +} diff --git a/apps/backend/internal/sentry/store_issue_watch_test.go b/apps/backend/internal/sentry/store_issue_watch_test.go index 4e84d4d50d..507f93effd 100644 --- a/apps/backend/internal/sentry/store_issue_watch_test.go +++ b/apps/backend/internal/sentry/store_issue_watch_test.go @@ -12,9 +12,9 @@ func newTestIssueWatch(workspaceID string) *IssueWatch { WorkflowID: "wf-1", WorkflowStepID: "step-1", Filter: SearchFilter{ - OrgSlug: "acme", - ProjectSlug: "frontend", - Levels: []string{"error"}, + OrgSlug: "acme", + ProjectSlugs: []string{"frontend"}, + Levels: []string{"error"}, }, Prompt: "Investigate {{issue.short_id}}", Enabled: true, @@ -50,7 +50,7 @@ func TestStore_IssueWatch_CreateGet(t *testing.T) { 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" || + if got.Filter.OrgSlug != "acme" || !sameStrings(got.Filter.ProjectSlugs, []string{"frontend"}) || len(got.Filter.Levels) != 1 || got.Filter.Levels[0] != "error" { t.Errorf("filter round-trip failed: %+v", got.Filter) } @@ -118,7 +118,7 @@ func TestStore_IssueWatch_Update(t *testing.T) { t.Fatalf("create: %v", err) } originalCreated := w.CreatedAt - w.Filter = SearchFilter{OrgSlug: "acme", ProjectSlug: "backend", Query: "deadlock"} + w.Filter = SearchFilter{OrgSlug: "acme", ProjectSlugs: []string{"backend"}, Query: "deadlock"} w.Enabled = false w.PollIntervalSeconds = 60 if err := store.UpdateIssueWatch(ctx, w); err != nil { diff --git a/apps/web/components/sentry/sentry-issue-dialog.tsx b/apps/web/components/sentry/sentry-issue-dialog.tsx index 23b986564b..3ef8f94095 100644 --- a/apps/web/components/sentry/sentry-issue-dialog.tsx +++ b/apps/web/components/sentry/sentry-issue-dialog.tsx @@ -221,7 +221,7 @@ function useDialogState(open: boolean, workspaceId?: string): DialogState { function toSearchFilter(filter: FilterState): SentrySearchFilter { return { orgSlug: filter.orgSlug, - projectSlug: filter.projectSlug || undefined, + projectSlugs: filter.projectSlug ? [filter.projectSlug] : undefined, environment: filter.environment || undefined, query: filter.query || undefined, statsPeriod: filter.statsPeriod, diff --git a/apps/web/components/sentry/sentry-issue-watch-dialog.test.tsx b/apps/web/components/sentry/sentry-issue-watch-dialog.test.tsx index 24910ae7c1..2f96ac1fd5 100644 --- a/apps/web/components/sentry/sentry-issue-watch-dialog.test.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-dialog.test.tsx @@ -6,6 +6,7 @@ import { listSentryProjects, } from "@/lib/api/domains/sentry-api"; import type { SentryConfig, SentryIssueWatch } from "@/lib/types/sentry"; +import type * as SentryIssueWatchMultiselectModule from "./sentry-issue-watch-multiselect"; import { SentryIssueWatchDialog } from "./sentry-issue-watch-dialog"; const { WORKSPACE_ID } = vi.hoisted(() => ({ WORKSPACE_ID: "ws-1" })); @@ -30,10 +31,14 @@ vi.mock("@/components/settings/profile-edit/script-editor", () => ({ ScriptEditor: () => null, computeEditorHeight: () => 0, })); -vi.mock("./sentry-issue-watch-multiselect", () => ({ - LevelMultiSelect: () => null, - StatusMultiSelect: () => null, -})); +vi.mock("./sentry-issue-watch-multiselect", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + LevelMultiSelect: () => null, + StatusMultiSelect: () => null, + }; +}); vi.mock("./sentry-issue-watch-throttle-field", () => ({ MaxInflightTasksField: () => null })); vi.mock("@/lib/api/domains/sentry-api", () => ({ listSentryInstances: vi.fn(), @@ -42,6 +47,8 @@ vi.mock("@/lib/api/domains/sentry-api", () => ({ })); const PRIMARY_INSTANCE_ID = "instance-a"; +const FRONTEND_OPTION = "Frontend (frontend)"; +const SENTRY_INSTANCE_FIELD = "Sentry instance"; function sentryInstance(id: string, name: string): SentryConfig { return { @@ -66,7 +73,7 @@ function legacyUnboundWatch(): SentryIssueWatch { workflowStepId: "step-1", repositoryId: "", baseBranch: "", - filter: { orgSlug: "acme", projectSlug: "frontend" }, + filter: { orgSlug: "acme", projectSlugs: ["frontend"] }, agentProfileId: "", executorProfileId: "", prompt: "Handle the issue.", @@ -133,20 +140,64 @@ describe("SentryIssueWatchDialog", () => { expect(listSentryInstances).toHaveBeenCalledWith(WORKSPACE_ID); }); - await choose("Sentry instance", "Production"); + await choose(SENTRY_INSTANCE_FIELD, "Production"); await waitFor(() => { expect(listSentryOrganizations).toHaveBeenCalledWith(WORKSPACE_ID, PRIMARY_INSTANCE_ID); expect(listSentryProjects).toHaveBeenCalledWith(WORKSPACE_ID, PRIMARY_INSTANCE_ID); }); - await choose("Sentry instance", "Self-hosted"); + await choose(SENTRY_INSTANCE_FIELD, "Self-hosted"); fireEvent.click(selectTrigger("Organization slug")); expect(screen.queryByRole("option", { name: "acme" })).toBeNull(); fireEvent.keyDown(document, { key: "Escape" }); fireEvent.click(selectTrigger("Project slug")); - expect(screen.queryByRole("option", { name: "Frontend (frontend)" })).toBeNull(); + expect(screen.queryByRole("option", { name: FRONTEND_OPTION })).toBeNull(); + }); + + it("allows selecting multiple projects for a single Sentry watcher", async () => { + vi.mocked(listSentryProjects).mockResolvedValue({ + projects: [ + { id: "frontend", slug: "frontend", name: "Frontend", orgSlug: "acme" }, + { id: "backend", slug: "backend", name: "Backend", orgSlug: "acme" }, + ], + }); + + render( + , + ); + + await waitFor(() => { + expect(listSentryInstances).toHaveBeenCalledWith(WORKSPACE_ID); + }); + await choose(SENTRY_INSTANCE_FIELD, "Production"); + await waitFor(() => { + expect(listSentryProjects).toHaveBeenCalledWith(WORKSPACE_ID, PRIMARY_INSTANCE_ID); + }); + + fireEvent.click(selectTrigger("Project slug")); + fireEvent.click(await screen.findByRole("option", { name: FRONTEND_OPTION })); + fireEvent.click(await screen.findByRole("option", { name: "Backend (backend)" })); + + expect(screen.getByTestId("sentry-watch-project-trigger").textContent).toContain( + "2 projects selected", + ); + + // Deselecting one falls back to showing the sole remaining project's name. + fireEvent.click(screen.getByRole("option", { name: FRONTEND_OPTION })); + await waitFor(() => { + expect(screen.getByTestId("sentry-watch-project-trigger").textContent).toContain( + "Backend (backend)", + ); + }); }); it("permits mutable updates to a legacy unbound watch while its instance remains immutable", async () => { @@ -166,6 +217,6 @@ describe("SentryIssueWatchDialog", () => { false, ); }); - expect(selectTrigger("Sentry instance").disabled).toBe(true); + expect(selectTrigger(SENTRY_INSTANCE_FIELD).disabled).toBe(true); }); }); diff --git a/apps/web/components/sentry/sentry-issue-watch-dialog.tsx b/apps/web/components/sentry/sentry-issue-watch-dialog.tsx index ca874eeaa8..4063ec71c4 100644 --- a/apps/web/components/sentry/sentry-issue-watch-dialog.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-dialog.tsx @@ -14,7 +14,6 @@ import { 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"; @@ -25,21 +24,14 @@ import { ScriptEditor, computeEditorHeight, } from "@/components/settings/profile-edit/script-editor"; -import { - listSentryInstances, - listSentryProjects, - listSentryOrganizations, -} from "@/lib/api/domains/sentry-api"; +import { listSentryInstances, listSentryOrganizations } from "@/lib/api/domains/sentry-api"; import { WatcherRepositoryFields } from "@/components/watcher-repository-fields"; import { clearWorkspaceScopedForm } from "@/lib/watcher-repository-default"; 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 { SelectField, FilterFields, type FormSetter } from "./sentry-issue-watch-filter-fields"; import { - STATS_PERIOD_OPTIONS, type FormState, - orgSelectItems, - projectSelectItems, parseMaxInflightTasks, isWatchFormReady, buildWatchPayload, @@ -50,9 +42,6 @@ import type { CreateSentryIssueWatchRequest, SentryConfig, SentryIssueWatch, - SentryLevel, - SentryProject, - SentryStatus, UpdateSentryIssueWatchRequest, } from "@/lib/types/sentry"; @@ -90,190 +79,6 @@ function useFormData(workspaceId: string) { return { workflows, agentProfiles: filteredAgentProfiles, allExecutorProfiles }; } -function useSentryProjects(workspaceId: string, instanceId: string, orgSlug: string) { - const [projects, setProjects] = useState([]); - useEffect(() => { - if (!workspaceId || !instanceId) { - setProjects([]); - return; - } - setProjects([]); - let cancelled = false; - listSentryProjects(workspaceId, instanceId) - .then((res) => { - if (!cancelled) setProjects(res.projects ?? []); - }) - .catch(() => { - if (!cancelled) setProjects([]); - }); - return () => { - cancelled = true; - }; - }, [workspaceId, instanceId]); - // 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.workspaceId, form.sentryInstanceId, 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 ( @@ -600,7 +405,7 @@ export function SentryIssueWatchDialog({ instances={instances} value={form.sentryInstanceId} onChange={(v) => - setForm((p) => ({ ...p, sentryInstanceId: v, orgSlug: "", projectSlug: "" })) + setForm((p) => ({ ...p, sentryInstanceId: v, orgSlug: "", projectSlugs: [] })) } disabled={!!watch} /> diff --git a/apps/web/components/sentry/sentry-issue-watch-filter-fields.tsx b/apps/web/components/sentry/sentry-issue-watch-filter-fields.tsx new file mode 100644 index 0000000000..29a6a025b4 --- /dev/null +++ b/apps/web/components/sentry/sentry-issue-watch-filter-fields.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Label } from "@kandev/ui/label"; +import { Input } from "@kandev/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { listSentryProjects } from "@/lib/api/domains/sentry-api"; +import { + LevelMultiSelect, + StatusMultiSelect, + ProjectMultiSelect, +} from "./sentry-issue-watch-multiselect"; +import { + STATS_PERIOD_OPTIONS, + type FormState, + orgSelectItems, + projectMultiSelectItems, +} from "./sentry-issue-watch-form"; +import type { SentryLevel, SentryProject, SentryStatus } from "@/lib/types/sentry"; + +export type FormSetter = React.Dispatch>; + +// useSentryProjects loads the workspace/instance's projects and narrows them +// to the currently-selected org — Sentry's auth-token endpoint already +// filters to the user's accessible orgs, so this is a client-side scope, not +// a second network filter. +function useSentryProjects(workspaceId: string, instanceId: string, orgSlug: string) { + const [projects, setProjects] = useState([]); + useEffect(() => { + if (!workspaceId || !instanceId) { + setProjects([]); + return; + } + setProjects([]); + let cancelled = false; + listSentryProjects(workspaceId, instanceId) + .then((res) => { + if (!cancelled) setProjects(res.projects ?? []); + }) + .catch(() => { + if (!cancelled) setProjects([]); + }); + return () => { + cancelled = true; + }; + }, [workspaceId, instanceId]); + return useMemo( + () => (orgSlug ? projects.filter((p) => p.orgSlug === orgSlug) : projects), + [projects, orgSlug], + ); +} + +export 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}

} + +
+ ); +} + +function ProjectMultiSelectField(props: { + label: string; + description?: string; + selected: string[]; + onChange: (v: string[]) => void; + placeholder: string; + items: { id: string; label: string }[]; + disabled?: boolean; +}) { + return ( +
+ + {props.description &&

{props.description}

} + +
+ ); +} + +function OrgProjectRow({ + form, + setForm, + projects, + orgs, +}: { + form: FormState; + setForm: FormSetter; + projects: SentryProject[]; + orgs: string[]; +}) { + const onOrgChange = (v: string) => + // The selected projects may belong to a different org — clear them so the + // project picker re-populates within the new org. + setForm((p) => ({ ...p, orgSlug: v, projectSlugs: [] })); + const orgItems = orgSelectItems(orgs, form.orgSlug); + const projectItems = projectMultiSelectItems(projects, form.projectSlugs); + return ( +
+ + setForm((p) => ({ ...p, projectSlugs: v }))} + placeholder={projectItems.length === 0 ? "No projects available" : "Select projects"} + items={projectItems} + disabled={projectItems.length === 0} + /> +
+ ); +} + +export function FilterFields({ + form, + setForm, + orgs, +}: { + form: FormState; + setForm: FormSetter; + orgs: string[]; +}) { + const projects = useSentryProjects(form.workspaceId, form.sentryInstanceId, 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" + /> +
+ + ); +} diff --git a/apps/web/components/sentry/sentry-issue-watch-form.test.ts b/apps/web/components/sentry/sentry-issue-watch-form.test.ts index 2b8738373d..068ac1d6f0 100644 --- a/apps/web/components/sentry/sentry-issue-watch-form.test.ts +++ b/apps/web/components/sentry/sentry-issue-watch-form.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { orgSelectItems, - projectSelectItems, + projectMultiSelectItems, maxInflightTasksString, parseMaxInflightTasks, buildFilterPayload, @@ -34,20 +34,20 @@ describe("orgSelectItems", () => { }); }); -describe("projectSelectItems", () => { +describe("projectMultiSelectItems", () => { const projects = [proj("frontend", "Frontend"), proj("api", "API")]; it("labels projects as 'name (slug)'", () => { - const items = projectSelectItems(projects, ""); + const items = projectMultiSelectItems(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"); + it("keeps every currently-selected project even if not in the visible list", () => { + const items = projectMultiSelectItems(projects, ["archived", "frontend"]); + expect(items.map((i) => i.id)).toEqual(["frontend", "api", "archived"]); }); }); @@ -84,18 +84,24 @@ describe("parseMaxInflightTasks", () => { }); describe("buildFilterPayload", () => { - it("trims the org slug and drops an empty project slug", () => { - const form = { ...makeEmptyForm("ws-1"), orgSlug: " acme ", projectSlug: "" }; + it("trims the org slug and drops an empty project selection", () => { + const form = { ...makeEmptyForm("ws-1"), orgSlug: " acme ", projectSlugs: [] }; const filter = buildFilterPayload(form); expect(filter.orgSlug).toBe("acme"); - expect(filter.projectSlug).toBeUndefined(); + expect(filter.projectSlugs).toBeUndefined(); }); - it("keeps a concrete project slug", () => { - const form = { ...makeEmptyForm("ws-1"), orgSlug: "acme", projectSlug: "web" }; + it("keeps a single selected project", () => { + const form = { ...makeEmptyForm("ws-1"), orgSlug: "acme", projectSlugs: ["web"] }; const filter = buildFilterPayload(form); expect(filter.orgSlug).toBe("acme"); - expect(filter.projectSlug).toBe("web"); + expect(filter.projectSlugs).toEqual(["web"]); + }); + + it("keeps every selected project when several are chosen", () => { + const form = { ...makeEmptyForm("ws-1"), orgSlug: "acme", projectSlugs: ["web", "api"] }; + const filter = buildFilterPayload(form); + expect(filter.projectSlugs).toEqual(["web", "api"]); }); }); @@ -104,7 +110,7 @@ describe("isWatchFormReady", () => { const legacyUnbound = { ...makeEmptyForm("ws-1"), orgSlug: "acme", - projectSlug: "web", + projectSlugs: ["web"], workflowId: "workflow-1", workflowStepId: "step-1", sentryInstanceId: "", @@ -112,4 +118,29 @@ describe("isWatchFormReady", () => { expect(isWatchFormReady(legacyUnbound, { requiresInstance: false })).toBe(true); }); + + it("rejects a form with no project selected", () => { + const form = { + ...makeEmptyForm("ws-1"), + orgSlug: "acme", + projectSlugs: [], + workflowId: "workflow-1", + workflowStepId: "step-1", + }; + + expect(isWatchFormReady(form)).toBe(false); + }); + + it("accepts a form with several projects selected", () => { + const form = { + ...makeEmptyForm("ws-1"), + sentryInstanceId: "instance-a", + orgSlug: "acme", + projectSlugs: ["web", "api"], + workflowId: "workflow-1", + workflowStepId: "step-1", + }; + + expect(isWatchFormReady(form)).toBe(true); + }); }); diff --git a/apps/web/components/sentry/sentry-issue-watch-form.ts b/apps/web/components/sentry/sentry-issue-watch-form.ts index 928943d447..2eb60e1340 100644 --- a/apps/web/components/sentry/sentry-issue-watch-form.ts +++ b/apps/web/components/sentry/sentry-issue-watch-form.ts @@ -23,7 +23,7 @@ export interface FormState { /** The Sentry instance to poll. Required; immutable after create. */ sentryInstanceId: string; orgSlug: string; - projectSlug: string; + projectSlugs: string[]; environment: string; levels: SentryLevel[]; statuses: SentryStatus[]; @@ -48,7 +48,7 @@ export function makeEmptyForm(workspaceId: string): FormState { workspaceId, sentryInstanceId: "", orgSlug: "", - projectSlug: "", + projectSlugs: [], environment: "", levels: ["fatal", "error"], statuses: ["unresolved"], @@ -73,7 +73,7 @@ export function formStateFromWatch(w: SentryIssueWatch): FormState { workspaceId: w.workspaceId, sentryInstanceId: w.sentryInstanceId ?? "", orgSlug: f.orgSlug ?? "", - projectSlug: f.projectSlug ?? "", + projectSlugs: f.projectSlugs ?? [], environment: f.environment ?? "", levels: f.levels ?? [], statuses: f.statuses ?? [], @@ -132,7 +132,10 @@ export function orgSelectItems(orgs: string[], current: string): SelectItemSpec[ return items; } -export function projectSelectItems(projects: SentryProject[], current: string): SelectItemSpec[] { +export function projectMultiSelectItems( + projects: SentryProject[], + current: string[], +): SelectItemSpec[] { const items: SelectItemSpec[] = []; const seen = new Set(); for (const p of projects) { @@ -140,8 +143,13 @@ export function projectSelectItems(projects: SentryProject[], current: string): 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 }); + // Include currently-selected slugs even if the token can no longer see them + // (editing an old watch, or a project since renamed/removed) so the picker + // still shows the saved selection. + for (const slug of current) { + if (!slug || seen.has(slug)) continue; + seen.add(slug); + items.push({ id: slug, label: slug }); } return items; } @@ -156,7 +164,7 @@ export function isWatchFormReady( !!form.workspaceId && (!requiresInstance || !!form.sentryInstanceId) && !!form.orgSlug.trim() && - !!form.projectSlug.trim() && + form.projectSlugs.length > 0 && !!form.workflowId && !!form.workflowStepId && !!form.prompt.trim() && @@ -170,7 +178,7 @@ export function isWatchFormReady( export function buildFilterPayload(form: FormState): SentrySearchFilter { return { orgSlug: form.orgSlug.trim(), - projectSlug: form.projectSlug.trim() || undefined, + projectSlugs: form.projectSlugs.length > 0 ? form.projectSlugs : undefined, environment: form.environment.trim() || undefined, levels: form.levels.length > 0 ? form.levels : undefined, statuses: form.statuses.length > 0 ? form.statuses : undefined, diff --git a/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx b/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx index f9ed2ffe12..7a28b2bf41 100644 --- a/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-multiselect.tsx @@ -1,6 +1,11 @@ "use client"; +import { useState } from "react"; +import { IconCheck, IconChevronDown } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; +import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@kandev/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { cn } from "@/lib/utils"; 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"; @@ -77,3 +82,90 @@ export function StatusMultiSelect({ /> ); } + +export type ProjectMultiSelectOption = { id: string; label: string }; + +// ProjectMultiSelect is a dropdown checklist: click the trigger to open a +// searchable list, click a row to toggle it in/out of the selection. Unlike +// LevelMultiSelect/StatusMultiSelect's inline badges, a workspace can have +// dozens of Sentry projects, so a dropdown keeps the form compact. A match +// means ANY selected project (same semantics as levels/statuses). +export function ProjectMultiSelect({ + options, + selected, + onChange, + placeholder = "Select projects", + disabled = false, +}: { + options: ProjectMultiSelectOption[]; + selected: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const labelById = new Map(options.map((o) => [o.id, o.label])); + + function toggle(id: string) { + onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]); + } + + return ( + + + + + + + + + No projects available. + {options.map((opt) => { + const checked = selected.includes(opt.id); + return ( + toggle(opt.id)}> + + {opt.label} + + ); + })} + + + + + ); +} + +function ProjectMultiSelectSummary({ + selected, + labelById, + placeholder, +}: { + selected: string[]; + labelById: Map; + placeholder: string; +}) { + if (selected.length === 0) { + return {placeholder}; + } + if (selected.length === 1) { + return {labelById.get(selected[0]) ?? selected[0]}; + } + return {selected.length} projects selected; +} diff --git a/apps/web/components/sentry/sentry-issue-watch-table.test.tsx b/apps/web/components/sentry/sentry-issue-watch-table.test.tsx index a704bd5ef8..da90e3f7f4 100644 --- a/apps/web/components/sentry/sentry-issue-watch-table.test.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-table.test.tsx @@ -13,7 +13,7 @@ function watch(over: Partial): SentryIssueWatch { workflowStepId: "step", repositoryId: "", baseBranch: "", - filter: { orgSlug: "acme", projectSlug: "web" }, + filter: { orgSlug: "acme", projectSlugs: ["web"] }, agentProfileId: "ap", executorProfileId: "", prompt: "", @@ -88,4 +88,11 @@ describe("SentryIssueWatchTable", () => { "false", ); }); + + it("renders a comma-joined project list in the filter summary for a multi-project watch", () => { + renderTable([ + watch({ id: "w1", filter: { orgSlug: "acme", projectSlugs: ["frontend", "backend"] } }), + ]); + expect(screen.getByText("org:acme · project:frontend,backend")).toBeTruthy(); + }); }); diff --git a/apps/web/components/sentry/sentry-issue-watch-table.tsx b/apps/web/components/sentry/sentry-issue-watch-table.tsx index db0bf2a5e5..4caef036c8 100644 --- a/apps/web/components/sentry/sentry-issue-watch-table.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-table.tsx @@ -46,7 +46,9 @@ 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.projectSlugs && filter.projectSlugs.length > 0) { + parts.push(`project:${filter.projectSlugs.join(",")}`); + } if (filter.environment) parts.push(`env:${filter.environment}`); if (filter.levels && filter.levels.length > 0) { parts.push(`level:${filter.levels.join(",")}`); diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index f9bf140ca5..04d2ab6ab7 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -2340,7 +2340,7 @@ export class ApiClient { agentProfileId: string; executorProfileId?: string; orgSlug: string; - projectSlug?: string; + projectSlugs?: string[]; prompt?: string; pollIntervalSeconds?: number; }): Promise<{ id: string; sentryInstanceId: string; enabled: boolean }> { @@ -2354,7 +2354,7 @@ export class ApiClient { workflowStepId: opts.workflowStepId, repositoryId: "", baseBranch: "", - filter: { orgSlug: opts.orgSlug, projectSlug: opts.projectSlug }, + filter: { orgSlug: opts.orgSlug, projectSlugs: opts.projectSlugs }, agentProfileId: opts.agentProfileId, executorProfileId: opts.executorProfileId ?? "", prompt: opts.prompt ?? "Investigate {{issue.title}}", diff --git a/apps/web/e2e/tests/integrations/sentry-settings.spec.ts b/apps/web/e2e/tests/integrations/sentry-settings.spec.ts index 5914a0508d..b82908307b 100644 --- a/apps/web/e2e/tests/integrations/sentry-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/sentry-settings.spec.ts @@ -1,3 +1,4 @@ +import type { Locator } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import { SentrySettingsPage } from "../../pages/sentry-settings-page"; @@ -91,7 +92,7 @@ test.describe("Sentry settings — instances", () => { workflowStepId: seedData.startStepId, agentProfileId: seedData.agentProfileId, orgSlug: "acme", - projectSlug: "web", + projectSlugs: ["web"], }); // API contract: delete-in-use rejects 409 and reports the blocking count. @@ -117,3 +118,77 @@ test.describe("Sentry settings — instances", () => { await expect(settings.cardByName("Primary")).toBeVisible(); }); }); + +// Scopes a Radix Select (or the ProjectMultiSelect trigger, which shares the +// same role="combobox" contract) by its field label. The dialog renders each +// field as `
`, +// so the exact label text uniquely identifies the combobox via its parent. +function comboboxByLabel(root: Locator, label: string) { + return root.getByText(label, { exact: true }).locator("xpath=..").getByRole("combobox"); +} + +test.describe("Sentry settings — issue watchers", () => { + test("creates a watcher scoped to multiple projects and persists all of them", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + await apiClient.mockSentryReset(); + const instance = await apiClient.createSentryInstance({ + workspaceId: seedData.workspaceId, + name: "Multi-project Sentry", + secret: TOKEN, + }); + await apiClient.mockSentrySetAuthHealth({ instanceId: instance.id, ok: true }); + await apiClient.mockSentrySetOrganizations(instance.id, [ + { id: "acme", slug: "acme", name: "Acme" }, + ]); + await apiClient.mockSentrySetProjects(instance.id, [ + { id: "frontend", slug: "frontend", name: "Frontend", orgSlug: "acme" }, + { id: "backend", slug: "backend", name: "Backend", orgSlug: "acme" }, + ]); + + const settings = new SentrySettingsPage(testPage); + await settings.goto(); + + await testPage.getByRole("button", { name: "New watcher" }).click(); + const dialog = testPage.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + const pick = async (label: string, option: string | RegExp) => { + await comboboxByLabel(dialog, label).click(); + await testPage.getByRole("listbox").getByRole("option", { name: option }).click(); + }; + + await pick("Sentry instance", "Multi-project Sentry"); + // The sole org auto-selects once the lookup resolves. + await expect(comboboxByLabel(dialog, "Organization slug")).toContainText("acme"); + + await comboboxByLabel(dialog, "Project slug").click(); + const projectListbox = testPage.getByRole("listbox"); + await projectListbox.getByRole("option", { name: "Frontend (frontend)" }).click(); + await projectListbox.getByRole("option", { name: "Backend (backend)" }).click(); + await expect(comboboxByLabel(dialog, "Project slug")).toContainText("2 projects selected"); + await prCapture.screenshot("watcher-project-multiselect-open", { + caption: "Selecting multiple Sentry projects for one issue watcher", + }); + await testPage.keyboard.press("Escape"); + + await pick("Workflow", "E2E Workflow"); + await comboboxByLabel(dialog, "Workflow Step").click(); + await testPage.getByRole("listbox").getByRole("option").first().click(); + + const createButton = dialog.getByRole("button", { name: "Create" }); + await expect(createButton).toBeEnabled(); + await createButton.click(); + await expect(dialog).toBeHidden(); + + // The table's filter summary lists every selected project, proving the + // watch persisted with both — not just the last one picked. + await expect(testPage.getByText("project:frontend,backend")).toBeVisible(); + await prCapture.screenshot("watcher-table-multi-project-persisted", { + caption: "Saved watcher polling both selected projects", + }); + }); +}); diff --git a/apps/web/lib/api/domains/sentry-api.test.ts b/apps/web/lib/api/domains/sentry-api.test.ts index c8ae32a52f..a3ba3aec11 100644 --- a/apps/web/lib/api/domains/sentry-api.test.ts +++ b/apps/web/lib/api/domains/sentry-api.test.ts @@ -4,7 +4,7 @@ vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), })); -import { copySentryInstances } from "./sentry-api"; +import { copySentryInstances, searchSentryIssues } from "./sentry-api"; type FetchInput = Parameters[0]; type FetchInit = Parameters[1]; @@ -37,3 +37,38 @@ describe("copySentryInstances", () => { expect(JSON.parse(String(init?.body))).toEqual({ targetWorkspaceId: "ws-target" }); }); }); + +describe("searchSentryIssues", () => { + it("serializes multiple project slugs as repeated projectSlug query params", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], isLast: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await searchSentryIssues("ws-1", "instance-a", { + orgSlug: "acme", + projectSlugs: ["frontend", "backend"], + }); + + const [url] = fetchSpy.mock.calls.at(-1) ?? []; + const params = new URL(String(url)).searchParams; + expect(params.getAll("projectSlug")).toEqual(["frontend", "backend"]); + }); + + it("omits projectSlug entirely when no project is selected", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ issues: [], isLast: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await searchSentryIssues("ws-1", "instance-a", { orgSlug: "acme" }); + + const [url] = fetchSpy.mock.calls.at(-1) ?? []; + const params = new URL(String(url)).searchParams; + expect(params.getAll("projectSlug")).toEqual([]); + }); +}); diff --git a/apps/web/lib/api/domains/sentry-api.ts b/apps/web/lib/api/domains/sentry-api.ts index 60bf8983c5..5f7cbe75cf 100644 --- a/apps/web/lib/api/domains/sentry-api.ts +++ b/apps/web/lib/api/domains/sentry-api.ts @@ -219,7 +219,7 @@ export async function listSentryProjects( function appendFilter(search: URLSearchParams, filter: SentrySearchFilter): void { search.set("orgSlug", filter.orgSlug); - if (filter.projectSlug) search.set("projectSlug", filter.projectSlug); + for (const projectSlug of filter.projectSlugs ?? []) search.append("projectSlug", 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); diff --git a/apps/web/lib/types/sentry.ts b/apps/web/lib/types/sentry.ts index 53d281b01d..d7698e5cd7 100644 --- a/apps/web/lib/types/sentry.ts +++ b/apps/web/lib/types/sentry.ts @@ -95,7 +95,7 @@ export interface SentryIssue { export interface SentrySearchFilter { orgSlug: string; - projectSlug?: string; + projectSlugs?: string[]; environment?: string; levels?: SentryLevel[]; statuses?: SentryStatus[];