feat(sentry): support polling multiple projects per issue watcher (#1978)

This commit is contained in:
Clément Fiere
2026-07-28 10:29:33 +02:00
committed by GitHub
parent 4411f1c057
commit a303ef7d6f
26 changed files with 980 additions and 299 deletions
+19 -7
View File
@@ -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 {
+22 -1
View File
@@ -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")
+1 -1
View File
@@ -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) {
@@ -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"})
+38 -7
View File
@@ -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
@@ -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)
}
}
+18 -1
View File
@@ -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
@@ -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
@@ -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 != "" {
@@ -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",
}
}
@@ -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)
}
}
@@ -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 {
@@ -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,
@@ -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<typeof SentryIssueWatchMultiselectModule>();
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(
<SentryIssueWatchDialog
open
onOpenChange={vi.fn()}
watch={null}
workspaceId={WORKSPACE_ID}
onCreate={vi.fn()}
onUpdate={vi.fn()}
/>,
);
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);
});
});
@@ -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<SentryProject[]>([]);
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 (
<div className="space-y-1.5">
<Label>{props.label}</Label>
{props.description && <p className="text-xs text-muted-foreground">{props.description}</p>}
<Select
value={props.value || undefined}
onValueChange={props.onChange}
disabled={props.disabled}
>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent>
{props.items.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
type FormSetter = React.Dispatch<React.SetStateAction<FormState>>;
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 (
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Organization slug"
description="The Sentry org to poll."
value={form.orgSlug}
onChange={onOrgChange}
placeholder={orgItems.length === 0 ? "No organizations available" : "Select organization"}
items={orgItems}
disabled={orgItems.length === 0}
/>
<SelectField
label="Project slug"
description="The Sentry project to poll."
value={form.projectSlug}
onChange={onProjectChange}
placeholder={projectItems.length === 0 ? "No projects available" : "Select project"}
items={projectItems}
disabled={projectItems.length === 0}
/>
</div>
);
}
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 (
<>
<OrgProjectRow form={form} setForm={setForm} projects={projects} orgs={orgs} />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Environment</Label>
<p className="text-xs text-muted-foreground">Optional — restrict to one environment.</p>
<Input
value={form.environment}
onChange={(e) => setForm((p) => ({ ...p, environment: e.target.value }))}
placeholder="production"
/>
</div>
<SelectField
label="Stats period"
description="How far back to look for matching issues."
value={form.statsPeriod}
onChange={(v) => setForm((p) => ({ ...p, statsPeriod: v }))}
placeholder="(any)"
items={STATS_PERIOD_OPTIONS.map((o) => ({ id: o.value, label: o.label }))}
/>
</div>
<div className="space-y-1.5">
<Label>Levels</Label>
<p className="text-xs text-muted-foreground">
Click to toggle. Matches issues at ANY of the selected levels.
</p>
<LevelMultiSelect selected={form.levels} onToggle={toggleLevel} />
</div>
<div className="space-y-1.5">
<Label>Statuses</Label>
<p className="text-xs text-muted-foreground">
Click to toggle. Matches issues at ANY of the selected statuses.
</p>
<StatusMultiSelect selected={form.statuses} onToggle={toggleStatus} />
</div>
<div className="space-y-1.5">
<Label>Query</Label>
<p className="text-xs text-muted-foreground">Free-text Sentry search query (optional).</p>
<Input
value={form.query}
onChange={(e) => setForm((p) => ({ ...p, query: e.target.value }))}
placeholder="is:unresolved transaction:/api/checkout"
/>
</div>
</>
);
}
function PlaceholdersHelp() {
return (
<TooltipProvider>
@@ -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}
/>
@@ -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<React.SetStateAction<FormState>>;
// 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<SentryProject[]>([]);
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 (
<div className="space-y-1.5">
<Label>{props.label}</Label>
{props.description && <p className="text-xs text-muted-foreground">{props.description}</p>}
<Select
value={props.value || undefined}
onValueChange={props.onChange}
disabled={props.disabled}
>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent>
{props.items.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function ProjectMultiSelectField(props: {
label: string;
description?: string;
selected: string[];
onChange: (v: string[]) => void;
placeholder: string;
items: { id: string; label: string }[];
disabled?: boolean;
}) {
return (
<div className="space-y-1.5">
<Label>{props.label}</Label>
{props.description && <p className="text-xs text-muted-foreground">{props.description}</p>}
<ProjectMultiSelect
options={props.items}
selected={props.selected}
onChange={props.onChange}
placeholder={props.placeholder}
disabled={props.disabled}
/>
</div>
);
}
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 (
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Organization slug"
description="The Sentry org to poll."
value={form.orgSlug}
onChange={onOrgChange}
placeholder={orgItems.length === 0 ? "No organizations available" : "Select organization"}
items={orgItems}
disabled={orgItems.length === 0}
/>
<ProjectMultiSelectField
label="Project slug"
description="The Sentry project(s) to poll. Select one or more."
selected={form.projectSlugs}
onChange={(v: string[]) => setForm((p) => ({ ...p, projectSlugs: v }))}
placeholder={projectItems.length === 0 ? "No projects available" : "Select projects"}
items={projectItems}
disabled={projectItems.length === 0}
/>
</div>
);
}
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 (
<>
<OrgProjectRow form={form} setForm={setForm} projects={projects} orgs={orgs} />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Environment</Label>
<p className="text-xs text-muted-foreground">Optional — restrict to one environment.</p>
<Input
value={form.environment}
onChange={(e) => setForm((p) => ({ ...p, environment: e.target.value }))}
placeholder="production"
/>
</div>
<SelectField
label="Stats period"
description="How far back to look for matching issues."
value={form.statsPeriod}
onChange={(v) => setForm((p) => ({ ...p, statsPeriod: v }))}
placeholder="(any)"
items={STATS_PERIOD_OPTIONS.map((o) => ({ id: o.value, label: o.label }))}
/>
</div>
<div className="space-y-1.5">
<Label>Levels</Label>
<p className="text-xs text-muted-foreground">
Click to toggle. Matches issues at ANY of the selected levels.
</p>
<LevelMultiSelect selected={form.levels} onToggle={toggleLevel} />
</div>
<div className="space-y-1.5">
<Label>Statuses</Label>
<p className="text-xs text-muted-foreground">
Click to toggle. Matches issues at ANY of the selected statuses.
</p>
<StatusMultiSelect selected={form.statuses} onToggle={toggleStatus} />
</div>
<div className="space-y-1.5">
<Label>Query</Label>
<p className="text-xs text-muted-foreground">Free-text Sentry search query (optional).</p>
<Input
value={form.query}
onChange={(e) => setForm((p) => ({ ...p, query: e.target.value }))}
placeholder="is:unresolved transaction:/api/checkout"
/>
</div>
</>
);
}
@@ -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);
});
});
@@ -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<string>();
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,
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
disabled={disabled}
data-testid="sentry-watch-project-trigger"
className="flex h-9 w-full cursor-pointer items-center justify-between rounded-md border border-input bg-transparent px-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
<ProjectMultiSelectSummary
selected={selected}
labelById={labelById}
placeholder={placeholder}
/>
<IconChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput placeholder="Search projects..." />
<CommandList>
<CommandEmpty>No projects available.</CommandEmpty>
{options.map((opt) => {
const checked = selected.includes(opt.id);
return (
<CommandItem key={opt.id} value={opt.label} onSelect={() => toggle(opt.id)}>
<IconCheck
className={cn("mr-2 h-4 w-4", checked ? "opacity-100" : "opacity-0")}
/>
<span className="truncate">{opt.label}</span>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function ProjectMultiSelectSummary({
selected,
labelById,
placeholder,
}: {
selected: string[];
labelById: Map<string, string>;
placeholder: string;
}) {
if (selected.length === 0) {
return <span className="truncate text-muted-foreground">{placeholder}</span>;
}
if (selected.length === 1) {
return <span className="truncate">{labelById.get(selected[0]) ?? selected[0]}</span>;
}
return <span className="truncate">{selected.length} projects selected</span>;
}
@@ -13,7 +13,7 @@ function watch(over: Partial<SentryIssueWatch>): 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();
});
});
@@ -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(",")}`);
+2 -2
View File
@@ -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}}",
@@ -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 `<div class="space-y-1.5"><Label>…</Label><Select>…</Select></div>`,
// 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",
});
});
});
+36 -1
View File
@@ -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<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[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([]);
});
});
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -95,7 +95,7 @@ export interface SentryIssue {
export interface SentrySearchFilter {
orgSlug: string;
projectSlug?: string;
projectSlugs?: string[];
environment?: string;
levels?: SentryLevel[];
statuses?: SentryStatus[];