feat(sentry): support polling multiple projects per issue watcher (#1978)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(",")}`);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface SentryIssue {
|
||||
|
||||
export interface SentrySearchFilter {
|
||||
orgSlug: string;
|
||||
projectSlug?: string;
|
||||
projectSlugs?: string[];
|
||||
environment?: string;
|
||||
levels?: SentryLevel[];
|
||||
statuses?: SentryStatus[];
|
||||
|
||||
Reference in New Issue
Block a user