diff --git a/apps/backend/internal/jira/client.go b/apps/backend/internal/jira/client.go index 0e4ccc556d..7c3112b1a1 100644 --- a/apps/backend/internal/jira/client.go +++ b/apps/backend/internal/jira/client.go @@ -18,6 +18,7 @@ type Client interface { ListTransitions(ctx context.Context, ticketKey string) ([]JiraTransition, error) DoTransition(ctx context.Context, ticketKey, transitionID string) error ListProjects(ctx context.Context) ([]JiraProject, error) + ListProjectStatuses(ctx context.Context, projectKey string) ([]JiraStatus, error) SearchTickets(ctx context.Context, jql, pageToken string, maxResults int) (*SearchResult, error) } diff --git a/apps/backend/internal/jira/cloud_client.go b/apps/backend/internal/jira/cloud_client.go index 22c20a555b..823ddfb623 100644 --- a/apps/backend/internal/jira/cloud_client.go +++ b/apps/backend/internal/jira/cloud_client.go @@ -387,6 +387,44 @@ func (c *CloudClient) ListProjects(ctx context.Context) ([]JiraProject, error) { return out, nil } +// ListProjectStatuses returns the workflow statuses defined for a project. +// Jira's GET /project/{key}/statuses returns one entry per issue type, each +// carrying that issue type's status list; the same status often appears under +// several issue types. We flatten them all and de-dupe by status id so the UI +// gets the union of statuses reachable anywhere in the project. Works against +// both Cloud (v3) and Server/DC (v2) since the payload shape is identical. +func (c *CloudClient) ListProjectStatuses(ctx context.Context, projectKey string) ([]JiraStatus, error) { + var body []struct { + Statuses []struct { + ID string `json:"id"` + Name string `json:"name"` + StatusCategory struct { + Key string `json:"key"` // "new" | "indeterminate" | "done" + } `json:"statusCategory"` + } `json:"statuses"` + } + path := c.apiBase + "/project/" + url.PathEscape(projectKey) + "/statuses" + if err := c.do(ctx, http.MethodGet, path, nil, &body); err != nil { + return nil, err + } + out := make([]JiraStatus, 0) + seen := make(map[string]struct{}) + for _, it := range body { + for _, s := range it.Statuses { + if _, dup := seen[s.ID]; dup { + continue + } + seen[s.ID] = struct{}{} + out = append(out, JiraStatus{ + ID: s.ID, + Name: s.Name, + StatusCategory: s.StatusCategory.Key, + }) + } + } + return out, nil +} + // cloudSearchResponse mirrors the subset of /rest/api/3/search/jql we consume. // The token-paginated endpoint exposes no total count; pagination is driven by // `nextPageToken`. Transitions are intentionally omitted from search results diff --git a/apps/backend/internal/jira/cloud_client_test.go b/apps/backend/internal/jira/cloud_client_test.go index 177a9b82c3..23d8fbfe7b 100644 --- a/apps/backend/internal/jira/cloud_client_test.go +++ b/apps/backend/internal/jira/cloud_client_test.go @@ -228,6 +228,74 @@ func TestCloudClient_ListProjects(t *testing.T) { } } +func TestCloudClient_ListProjectStatuses_FlattensAndDedupes(t *testing.T) { + var gotPath string + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + // Two issue types share the "In Development" status (id 10001); the + // flattened result must contain it exactly once. + _, _ = w.Write([]byte(`[ + {"name":"Bug","statuses":[ + {"id":"10001","name":"In Development","statusCategory":{"key":"indeterminate"}}, + {"id":"3","name":"Done","statusCategory":{"key":"done"}} + ]}, + {"name":"Story","statuses":[ + {"id":"10001","name":"In Development","statusCategory":{"key":"indeterminate"}}, + {"id":"1","name":"To Do","statusCategory":{"key":"new"}} + ]} + ]`)) + }) + c := clientTo(ts, AuthMethodAPIToken, "t") + statuses, err := c.ListProjectStatuses(context.Background(), "PROJ") + if err != nil { + t.Fatalf("list statuses: %v", err) + } + if gotPath != "/rest/api/3/project/PROJ/statuses" { + t.Errorf("path: got %q", gotPath) + } + if len(statuses) != 3 { + t.Fatalf("expected 3 de-duped statuses, got %d: %+v", len(statuses), statuses) + } + if statuses[0].ID != "10001" || statuses[0].Name != "In Development" || statuses[0].StatusCategory != "indeterminate" { + t.Errorf("first status unexpected: %+v", statuses[0]) + } +} + +func TestCloudClient_ListProjectStatuses_ServerMode_UsesV2(t *testing.T) { + var gotPath string + ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`[{"name":"Task","statuses":[{"id":"1","name":"Open","statusCategory":{"key":"new"}}]}]`)) + }) + c := serverClient(ts, AuthMethodPAT, "tok") + statuses, err := c.ListProjectStatuses(context.Background(), "P") + if err != nil { + t.Fatalf("list statuses: %v", err) + } + if gotPath != "/rest/api/2/project/P/statuses" { + t.Errorf("path: got %q, want v2", gotPath) + } + if len(statuses) != 1 || statuses[0].Name != "Open" { + t.Errorf("unexpected statuses: %+v", statuses) + } +} + +func TestCloudClient_ListProjectStatuses_NonOK_ReturnsAPIError(t *testing.T) { + ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"errorMessages":["No project"]}`)) + }) + c := clientTo(ts, AuthMethodAPIToken, "t") + _, err := c.ListProjectStatuses(context.Background(), "NONE") + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != http.StatusNotFound { + t.Errorf("status: got %d", apiErr.StatusCode) + } +} + func TestCloudClient_SiteURLTrailingSlash_Stripped(t *testing.T) { ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { // If trailing slash wasn't stripped, we'd see "//rest/..." diff --git a/apps/backend/internal/jira/handlers.go b/apps/backend/internal/jira/handlers.go index 73c0ef54c7..c392aa297d 100644 --- a/apps/backend/internal/jira/handlers.go +++ b/apps/backend/internal/jira/handlers.go @@ -34,6 +34,7 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.DELETE("/config", c.httpDeleteConfig) api.POST("/config/test", c.httpTestConfig) api.GET("/projects", c.httpListProjects) + api.GET("/projects/:key/statuses", c.httpListProjectStatuses) api.GET("/tickets", c.httpSearchTickets) api.GET("/tickets/:key", c.httpGetTicket) api.POST("/tickets/:key/transitions", c.httpDoTransition) @@ -111,6 +112,16 @@ func (c *Controller) httpListProjects(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"projects": projects}) } +func (c *Controller) httpListProjectStatuses(ctx *gin.Context) { + key := ctx.Param("key") + statuses, err := c.service.ListProjectStatuses(ctx.Request.Context(), key) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"statuses": statuses}) +} + func (c *Controller) httpSearchTickets(ctx *gin.Context) { jql := ctx.Query("jql") pageToken := ctx.Query("page_token") diff --git a/apps/backend/internal/jira/mock_client.go b/apps/backend/internal/jira/mock_client.go index 45fbef40ca..5736046907 100644 --- a/apps/backend/internal/jira/mock_client.go +++ b/apps/backend/internal/jira/mock_client.go @@ -17,7 +17,8 @@ type MockClient struct { tickets map[string]*JiraTicket // key → ticket transitions map[string][]JiraTransition // ticketKey → transitions projects []JiraProject - searchHits []JiraTicket // returned by SearchTickets regardless of JQL + statuses map[string][]JiraStatus // projectKey → statuses + searchHits []JiraTicket // returned by SearchTickets regardless of JQL doneCalls []doneTransitionCall getError *APIError } @@ -39,6 +40,7 @@ func NewMockClient() *MockClient { }, tickets: make(map[string]*JiraTicket), transitions: make(map[string][]JiraTransition), + statuses: make(map[string][]JiraStatus), } } @@ -93,6 +95,15 @@ func (m *MockClient) ListProjects(context.Context) ([]JiraProject, error) { return out, nil } +func (m *MockClient) ListProjectStatuses(_ context.Context, projectKey string) ([]JiraStatus, error) { + m.mu.RLock() + defer m.mu.RUnlock() + src := m.statuses[projectKey] + out := make([]JiraStatus, len(src)) + copy(out, src) + return out, nil +} + // SearchTickets returns the tickets seeded via SetSearchHits. When jql is // empty (or no seeded ticket key appears literally in the query) the full // seeded set is returned — tests are expected to seed exactly the result @@ -111,14 +122,39 @@ func (m *MockClient) SearchTickets(_ context.Context, jql, _ string, maxResults } // filterByJQL is a stand-in for real JQL parsing. An empty query passes every -// hit through; a non-empty query that mentions a seeded ticket key restricts -// to that key; a non-empty query that mentions no seeded key returns every -// hit (so tests don't have to construct a parseable JQL string just to fetch -// what they seeded). +// hit through; a `project in (...)` clause narrows to hits in those projects; +// a `status in (...)` clause narrows to hits whose StatusName is listed; a +// mentioned seeded ticket key restricts to that key. All three compose, so a +// query combining project/status with a key still narrows to the key. A +// non-empty query that carries none of these clauses returns every hit (so +// tests don't have to construct a fully parseable JQL string just to fetch what +// they seeded). The clause handling lets E2E assert the filters narrow the list +// rather than being a no-op. func filterByJQL(hits []JiraTicket, jql string) []JiraTicket { if jql == "" { return hits } + if keys := projectKeysFromJQL(jql); len(keys) > 0 { + hits = filterByProjectKeys(hits, keys) + } + if names := statusNamesFromJQL(jql); len(names) > 0 { + hits = filterByStatusNames(hits, names) + } + // Ticket-key narrowing composes with project/status filters: apply it + // whenever the (already narrowed) hits mention a seeded key, so a query that + // combines project/status with a key still restricts to that key. When no + // key is mentioned, return whatever the project/status clauses left (which + // is every hit when the query carried none of these clauses). + if matched := filterByTicketKey(hits, jql); matched != nil { + return matched + } + return hits +} + +// filterByTicketKey narrows hits to those whose key appears in the JQL. Returns +// nil (not an empty slice) when no seeded key is mentioned, so callers can tell +// "no key clause" apart from "key clause matched nothing". +func filterByTicketKey(hits []JiraTicket, jql string) []JiraTicket { matched := make([]JiraTicket, 0, len(hits)) for _, t := range hits { if t.Key != "" && strings.Contains(jql, t.Key) { @@ -126,7 +162,79 @@ func filterByJQL(hits []JiraTicket, jql string) []JiraTicket { } } if len(matched) == 0 { - return hits + return nil + } + return matched +} + +// projectKeysFromJQL extracts the quoted project keys from a `project in (...)` +// clause. Returns nil when the JQL carries no such clause. +func projectKeysFromJQL(jql string) map[string]struct{} { + lower := strings.ToLower(jql) + idx := strings.Index(lower, "project in (") + if idx == -1 { + return nil + } + rest := jql[idx+len("project in ("):] + end := strings.Index(rest, ")") + if end == -1 { + return nil + } + keys := make(map[string]struct{}) + for _, part := range strings.Split(rest[:end], ",") { + key := strings.Trim(strings.TrimSpace(part), `"`) + if key != "" { + keys[key] = struct{}{} + } + } + return keys +} + +func filterByProjectKeys(hits []JiraTicket, keys map[string]struct{}) []JiraTicket { + matched := make([]JiraTicket, 0, len(hits)) + for _, t := range hits { + if _, ok := keys[t.ProjectKey]; ok { + matched = append(matched, t) + } + } + return matched +} + +// statusNamesFromJQL extracts the quoted status names from a `status in (...)` +// clause. Returns nil when the JQL carries no such clause. +// +// NOTE: the clause boundary is the first ")" in the remaining string, so a +// status name that itself contains ")" (e.g. "Ready (for review)") would be +// misextracted. The frontend never emits such names in these mock-driven tests, +// so a full quote-aware scan isn't warranted for this test stand-in. The same +// caveat applies to projectKeysFromJQL above. +func statusNamesFromJQL(jql string) map[string]struct{} { + lower := strings.ToLower(jql) + idx := strings.Index(lower, "status in (") + if idx == -1 { + return nil + } + rest := jql[idx+len("status in ("):] + end := strings.Index(rest, ")") + if end == -1 { + return nil + } + names := make(map[string]struct{}) + for _, part := range strings.Split(rest[:end], ",") { + name := strings.Trim(strings.TrimSpace(part), `"`) + if name != "" { + names[name] = struct{}{} + } + } + return names +} + +func filterByStatusNames(hits []JiraTicket, names map[string]struct{}) []JiraTicket { + matched := make([]JiraTicket, 0, len(hits)) + for _, t := range hits { + if _, ok := names[t.StatusName]; ok { + matched = append(matched, t) + } } return matched } @@ -167,6 +275,16 @@ func (m *MockClient) SetProjects(projects []JiraProject) { m.projects = cp } +// SetProjectStatuses replaces the statuses returned by ListProjectStatuses for +// a project key. +func (m *MockClient) SetProjectStatuses(projectKey string, statuses []JiraStatus) { + m.mu.Lock() + defer m.mu.Unlock() + cp := make([]JiraStatus, len(statuses)) + copy(cp, statuses) + m.statuses[projectKey] = cp +} + // SetSearchHits replaces the tickets returned by SearchTickets. func (m *MockClient) SetSearchHits(hits []JiraTicket) { m.mu.Lock() @@ -205,6 +323,7 @@ func (m *MockClient) Reset() { } m.tickets = make(map[string]*JiraTicket) m.transitions = make(map[string][]JiraTransition) + m.statuses = make(map[string][]JiraStatus) m.projects = nil m.searchHits = nil m.doneCalls = nil diff --git a/apps/backend/internal/jira/mock_client_test.go b/apps/backend/internal/jira/mock_client_test.go index a7cd5c91be..26367a1035 100644 --- a/apps/backend/internal/jira/mock_client_test.go +++ b/apps/backend/internal/jira/mock_client_test.go @@ -70,11 +70,97 @@ func TestMockClient_DoTransitionRecordsCall(t *testing.T) { } } +func TestMockClient_ProjectStatusesSeedAndRead(t *testing.T) { + m := NewMockClient() + m.SetProjectStatuses("CLIP", []JiraStatus{ + {ID: "1", Name: "In Development", StatusCategory: "indeterminate"}, + }) + got, err := m.ListProjectStatuses(context.Background(), "CLIP") + if err != nil { + t.Fatalf("ListProjectStatuses: %v", err) + } + if len(got) != 1 || got[0].Name != "In Development" { + t.Fatalf("unexpected statuses: %+v", got) + } + // Unseeded project returns empty, not nil-panic. + if s, _ := m.ListProjectStatuses(context.Background(), "OTHER"); len(s) != 0 { + t.Fatalf("expected no statuses for unseeded project, got %+v", s) + } +} + +func TestMockClient_SearchTickets_FiltersByStatusName(t *testing.T) { + m := NewMockClient() + m.SetSearchHits([]JiraTicket{ + {Key: "CLIP-1", ProjectKey: "CLIP", StatusName: "In Development"}, + {Key: "CLIP-2", ProjectKey: "CLIP", StatusName: "Ready for review"}, + {Key: "CLIP-3", ProjectKey: "CLIP", StatusName: "In Development"}, + }) + res, err := m.SearchTickets( + context.Background(), + `project in ("CLIP") AND status in ("Ready for review") ORDER BY updated DESC`, + "", + 50, + ) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(res.Tickets) != 1 || res.Tickets[0].Key != "CLIP-2" { + t.Fatalf("expected only CLIP-2, got %+v", res.Tickets) + } +} + +func TestMockClient_SearchTickets_FiltersByProjectKey(t *testing.T) { + m := NewMockClient() + m.SetSearchHits([]JiraTicket{ + {Key: "CLIP-1", ProjectKey: "CLIP", StatusName: "In Development"}, + {Key: "OPS-9", ProjectKey: "OPS", StatusName: "In Development"}, + }) + res, err := m.SearchTickets( + context.Background(), + `project in ("CLIP") AND assignee = currentUser() ORDER BY updated DESC`, + "", + 50, + ) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(res.Tickets) != 1 || res.Tickets[0].Key != "CLIP-1" { + t.Fatalf("expected only CLIP-1, got %+v", res.Tickets) + } +} + +func TestMockClient_SearchTickets_ProjectAndKeyNarrowingCompose(t *testing.T) { + m := NewMockClient() + m.SetSearchHits([]JiraTicket{ + {Key: "CLIP-1", ProjectKey: "CLIP", StatusName: "In Development"}, + {Key: "CLIP-2", ProjectKey: "CLIP", StatusName: "In Development"}, + {Key: "OPS-9", ProjectKey: "OPS", StatusName: "In Development"}, + }) + // A project clause plus a specific ticket key must narrow to that key, not + // short-circuit on the project clause and return the whole project. + res, err := m.SearchTickets( + context.Background(), + `project in ("CLIP") AND key = CLIP-2 ORDER BY updated DESC`, + "", + 50, + ) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(res.Tickets) != 1 || res.Tickets[0].Key != "CLIP-2" { + t.Fatalf("expected only CLIP-2, got %+v", res.Tickets) + } +} + func TestMockClient_ResetClearsState(t *testing.T) { m := NewMockClient() m.AddTicket(&JiraTicket{Key: "X-1"}) + m.SetProjectStatuses("CLIP", []JiraStatus{{ID: "1", Name: "Open"}}) m.SetAuthResult(&TestConnectionResult{OK: false, Error: "boom"}) m.Reset() + if s, _ := m.ListProjectStatuses(context.Background(), "CLIP"); len(s) != 0 { + t.Fatalf("Reset did not clear statuses: %+v", s) + } res, _ := m.TestAuth(context.Background()) if !res.OK { t.Fatalf("Reset did not restore default auth result: %+v", res) diff --git a/apps/backend/internal/jira/mock_controller.go b/apps/backend/internal/jira/mock_controller.go index fc9ee79e88..5e0d5f5bc2 100644 --- a/apps/backend/internal/jira/mock_controller.go +++ b/apps/backend/internal/jira/mock_controller.go @@ -31,6 +31,7 @@ func (c *MockController) RegisterRoutes(router *gin.Engine) { api.PUT("/auth-result", c.setAuthResult) api.PUT("/auth-health", c.setAuthHealth) api.POST("/projects", c.setProjects) + api.POST("/project-statuses", c.setProjectStatuses) api.POST("/tickets", c.addTickets) api.POST("/transitions", c.addTransitions) api.POST("/search-hits", c.setSearchHits) @@ -77,6 +78,23 @@ func (c *MockController) setProjects(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"count": len(req.Projects)}) } +func (c *MockController) setProjectStatuses(ctx *gin.Context) { + var req struct { + ProjectKey string `json:"projectKey"` + Statuses []JiraStatus `json:"statuses"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if req.ProjectKey == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "projectKey required"}) + return + } + c.mock.SetProjectStatuses(req.ProjectKey, req.Statuses) + ctx.JSON(http.StatusOK, gin.H{"count": len(req.Statuses)}) +} + func (c *MockController) addTickets(ctx *gin.Context) { var req struct { Tickets []JiraTicket `json:"tickets"` diff --git a/apps/backend/internal/jira/models.go b/apps/backend/internal/jira/models.go index 2ab3f11bc4..c1312e2549 100644 --- a/apps/backend/internal/jira/models.go +++ b/apps/backend/internal/jira/models.go @@ -126,6 +126,16 @@ type JiraProject struct { ID string `json:"id"` } +// JiraStatus is one workflow status defined for a project. Unlike the coarse +// three-bucket StatusCategory ("new" | "indeterminate" | "done"), Name is the +// project-specific workflow status the user actually sees on a ticket (e.g. +// "In Development", "Ready for review"). The status filter is built from these. +type JiraStatus struct { + ID string `json:"id"` + Name string `json:"name"` + StatusCategory string `json:"statusCategory"` // "new" | "indeterminate" | "done" +} + // SearchResult is a page of tickets from a JQL search. Atlassian's // /rest/api/3/search/jql endpoint is token-paginated and returns no total // count, so the UI relies on IsLast and NextPageToken to walk pages. diff --git a/apps/backend/internal/jira/service.go b/apps/backend/internal/jira/service.go index 5ffe956351..92c4a0ac86 100644 --- a/apps/backend/internal/jira/service.go +++ b/apps/backend/internal/jira/service.go @@ -309,6 +309,17 @@ func (s *Service) ListProjects(ctx context.Context) ([]JiraProject, error) { return client.ListProjects(ctx) } +// ListProjectStatuses returns the workflow statuses defined for a project, +// used by the ticket-list status filter so users can filter by the real +// statuses in the selected project rather than the three coarse categories. +func (s *Service) ListProjectStatuses(ctx context.Context, projectKey string) ([]JiraStatus, error) { + client, err := s.clientFor(ctx) + if err != nil { + return nil, err + } + return client.ListProjectStatuses(ctx, projectKey) +} + // SearchTickets runs a JQL search, returning a page of tickets. pageToken is // the cursor returned in the previous page's NextPageToken; pass "" for the // first page. diff --git a/apps/backend/internal/jira/service_test.go b/apps/backend/internal/jira/service_test.go index f61e59feef..6bf4e7cb88 100644 --- a/apps/backend/internal/jira/service_test.go +++ b/apps/backend/internal/jira/service_test.go @@ -58,6 +58,7 @@ type fakeClient struct { getTicketFn func(key string) (*JiraTicket, error) transitionFn func(key, id string) error listProjects func() ([]JiraProject, error) + listStatuses func(projectKey string) ([]JiraStatus, error) searchFn func(jql string) (*SearchResult, error) transitionLog []string // "key:id" } @@ -90,6 +91,12 @@ func (c *fakeClient) ListProjects(_ context.Context) ([]JiraProject, error) { } return nil, nil } +func (c *fakeClient) ListProjectStatuses(_ context.Context, projectKey string) ([]JiraStatus, error) { + if c.listStatuses != nil { + return c.listStatuses(projectKey) + } + return nil, nil +} func (c *fakeClient) SearchTickets(_ context.Context, jql, _ string, _ int) (*SearchResult, error) { if c.searchFn != nil { return c.searchFn(jql) @@ -424,6 +431,39 @@ func TestService_GetTicket_RevealError_NotConfusedWithUnconfigured(t *testing.T) } } +func TestService_ListProjectStatuses_PassThrough(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + var gotKey string + f.client.listStatuses = func(projectKey string) ([]JiraStatus, error) { + gotKey = projectKey + return []JiraStatus{{ID: "1", Name: "In Development", StatusCategory: "indeterminate"}}, nil + } + _, _ = f.svc.SetConfig(ctx, &SetConfigRequest{ + SiteURL: "https://a.net", Email: "e", + AuthMethod: AuthMethodAPIToken, InstanceType: InstanceTypeCloud, Secret: "t", + }) + waitForAuthProbe(t, f) + statuses, err := f.svc.ListProjectStatuses(ctx, "CLIP") + if err != nil { + t.Fatalf("list statuses: %v", err) + } + if gotKey != "CLIP" { + t.Errorf("project key: got %q", gotKey) + } + if len(statuses) != 1 || statuses[0].Name != "In Development" { + t.Errorf("unexpected statuses: %+v", statuses) + } +} + +func TestService_ListProjectStatuses_Unconfigured(t *testing.T) { + f := newSvcFixture(t) + _, err := f.svc.ListProjectStatuses(context.Background(), "CLIP") + if !errors.Is(err, ErrNotConfigured) { + t.Errorf("expected ErrNotConfigured, got %v", err) + } +} + func TestService_DoTransition_PassThrough(t *testing.T) { f := newSvcFixture(t) ctx := context.Background() diff --git a/apps/web/app/jira/jira-page-client.tsx b/apps/web/app/jira/jira-page-client.tsx index 5db83817f3..bf5e66afc1 100644 --- a/apps/web/app/jira/jira-page-client.tsx +++ b/apps/web/app/jira/jira-page-client.tsx @@ -22,6 +22,10 @@ import { type FilterState, } from "@/components/jira/my-jira/filter-model"; import { DEFAULT_VIEW, useSavedViews } from "@/components/jira/my-jira/use-saved-views"; +import { + reconcileStatuses, + useProjectStatuses, +} from "@/components/jira/my-jira/use-project-statuses"; import { ListToolbar } from "@/components/jira/my-jira/list-toolbar"; import { FilterBar, hasActiveFilters } from "@/components/jira/my-jira/filter-bar"; import { ResultsPagination } from "@/components/jira/my-jira/results-pagination"; @@ -67,6 +71,7 @@ function useJiraPageData(workspaceId?: string) { const [loaded, setLoaded] = useState(false); const [configured, setConfigured] = useState(false); const [projects, setProjects] = useState([]); + const [defaultProjectKey, setDefaultProjectKey] = useState(""); useEffect(() => { let cancelled = false; @@ -80,6 +85,7 @@ function useJiraPageData(workspaceId?: string) { if (cancelled) return; const ok = !!cfg && cfg.hasSecret; setConfigured(ok); + setDefaultProjectKey(cfg?.defaultProjectKey ?? ""); if (ok) { try { const list = await loadUserProjects(); @@ -98,7 +104,16 @@ function useJiraPageData(workspaceId?: string) { }; }, [workspaceId]); - return { loaded, configured, projects }; + return { loaded, configured, projects, defaultProjectKey }; +} + +// initialFilters seeds the ticket list with the workspace's default project +// (issue #1588 follow-up) so opening /jira lands on that project pre-selected. +// An empty defaultProjectKey keeps the historical "no project" default. +function initialFilters(defaultProjectKey: string): FilterState { + const key = defaultProjectKey.trim(); + if (!key) return DEFAULT_VIEW.filters; + return { ...DEFAULT_VIEW.filters, projectKeys: [key] }; } function TicketResults({ @@ -144,6 +159,7 @@ function TicketResults({ type AuthenticatedViewProps = { workspaceId: string | undefined; projects: JiraProject[]; + defaultProjectKey: string; presets: JiraTaskPreset[]; onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void; onOpenTicket: (ticket: JiraTicket) => void; @@ -152,12 +168,31 @@ type AuthenticatedViewProps = { function AuthenticatedView({ workspaceId, projects, + defaultProjectKey, presets, onStartTask, onOpenTicket, }: AuthenticatedViewProps) { - const state = useFilterState(); + const state = useFilterState(defaultProjectKey); const search = useJiraSearch(workspaceId ?? null, state.effectiveJql); + const { options: statusOptions, loaded: statusesLoaded } = useProjectStatuses( + state.filters.projectKeys, + ); + + // When the available status union changes (project selection changed, or + // statuses finished loading), drop any selected statuses that are no longer + // offered so the JQL never references a status absent from the selection. + // Gate on statusesLoaded so a saved view's statuses aren't stripped on the + // first render, before useProjectStatuses has fetched the current project's + // statuses (options is still [] until then). + const { filters, updateFilters } = state; + useEffect(() => { + if (!statusesLoaded) return; + const reconciled = reconcileStatuses(filters.statuses, statusOptions); + if (reconciled !== filters.statuses) { + updateFilters({ ...filters, statuses: reconciled }); + } + }, [statusesLoaded, statusOptions, filters, updateFilters]); return ( <> @@ -190,6 +225,7 @@ function AuthenticatedView({ filters={state.filters} onChange={state.updateFilters} projects={projects} + statusOptions={statusOptions} hasActiveFilters={hasActiveFilters(state.filters)} onClear={() => state.updateFilters(DEFAULT_FILTERS)} /> @@ -216,9 +252,9 @@ function AuthenticatedView({ ); } -function useFilterState() { +function useFilterState(defaultProjectKey: string) { const savedViews = useSavedViews(); - const [filters, setFilters] = useState(DEFAULT_VIEW.filters); + const [filters, setFilters] = useState(() => initialFilters(defaultProjectKey)); const [activeViewId, setActiveViewId] = useState(DEFAULT_VIEW.id); const [customJql, setCustomJql] = useState(null); const [showJqlEditor, setShowJqlEditor] = useState(false); @@ -276,7 +312,7 @@ function useFilterState() { } export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClientProps) { - const { loaded, configured, projects } = useJiraPageData(workspaceId); + const { loaded, configured, projects, defaultProjectKey } = useJiraPageData(workspaceId); const { taskPresets } = useJiraTaskPresets(); const [launchPayload, setLaunchPayload] = useState(null); const [openTicket, setOpenTicket] = useState(null); @@ -300,6 +336,7 @@ export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClient void; projects: JiraProject[]; + statusOptions: JiraStatus[]; hasActiveFilters: boolean; onClear: () => void; }; @@ -18,6 +19,7 @@ export function FilterBar({ filters, onChange, projects, + statusOptions, hasActiveFilters, onClear, }: FilterBarProps) { @@ -29,8 +31,10 @@ export function FilterBar({ onChange={(projectKeys) => onChange({ ...filters, projectKeys })} /> onChange({ ...filters, statusCategories })} + options={statusOptions} + value={filters.statuses} + onChange={(statuses) => onChange({ ...filters, statuses })} + hasProjectSelected={filters.projectKeys.length > 0} /> 0 || - f.statusCategories.length > 0 || + f.statuses.length > 0 || f.assignee !== DEFAULT_FILTERS.assignee || f.searchText.trim().length > 0 ); diff --git a/apps/web/components/jira/my-jira/filter-model.test.ts b/apps/web/components/jira/my-jira/filter-model.test.ts index b98a203e3b..12242fc227 100644 --- a/apps/web/components/jira/my-jira/filter-model.test.ts +++ b/apps/web/components/jira/my-jira/filter-model.test.ts @@ -17,13 +17,27 @@ describe("filtersToJql", () => { ); }); - it("maps status categories to Jira labels", () => { + it("emits status clause for selected status names", () => { const jql = filtersToJql({ ...DEFAULT_FILTERS, - statusCategories: ["new", "indeterminate"], + statuses: ["In Development", "Ready for review"], assignee: "anyone", }); - expect(jql).toBe(`statusCategory in ("To Do", "In Progress") ORDER BY updated DESC`); + expect(jql).toBe(`status in ("In Development", "Ready for review") ORDER BY updated DESC`); + }); + + it("omits status clause when no statuses selected", () => { + const jql = filtersToJql({ ...DEFAULT_FILTERS, statuses: [], assignee: "anyone" }); + expect(jql).toBe("ORDER BY updated DESC"); + }); + + it("escapes quotes in status names", () => { + const jql = filtersToJql({ + ...DEFAULT_FILTERS, + statuses: [`Needs "review"`], + assignee: "anyone", + }); + expect(jql).toBe(`status in ("Needs \\"review\\"") ORDER BY updated DESC`); }); it("detects ticket key in search text", () => { @@ -73,4 +87,8 @@ describe("filtersEqual", () => { it("returns false when arrays differ", () => { expect(filtersEqual(DEFAULT_FILTERS, { ...DEFAULT_FILTERS, projectKeys: ["X"] })).toBe(false); }); + + it("returns false when statuses differ", () => { + expect(filtersEqual(DEFAULT_FILTERS, { ...DEFAULT_FILTERS, statuses: ["Done"] })).toBe(false); + }); }); diff --git a/apps/web/components/jira/my-jira/filter-model.ts b/apps/web/components/jira/my-jira/filter-model.ts index eb8184aa9e..88a97b015d 100644 --- a/apps/web/components/jira/my-jira/filter-model.ts +++ b/apps/web/components/jira/my-jira/filter-model.ts @@ -1,11 +1,11 @@ -import type { JiraStatusCategory } from "@/lib/types/jira"; - export type AssigneeFilter = "me" | "unassigned" | "anyone"; export type SortKey = "updated" | "created" | "priority"; export type FilterState = { projectKeys: string[]; - statusCategories: JiraStatusCategory[]; + // Real project workflow status names (e.g. "In Development"), not the coarse + // three-bucket status categories. Empty = no status restriction. + statuses: string[]; assignee: AssigneeFilter; searchText: string; sort: SortKey; @@ -13,18 +13,12 @@ export type FilterState = { export const DEFAULT_FILTERS: FilterState = { projectKeys: [], - statusCategories: [], + statuses: [], assignee: "me", searchText: "", sort: "updated", }; -export const STATUS_CATEGORY_OPTIONS: { value: JiraStatusCategory; label: string }[] = [ - { value: "new", label: "To Do" }, - { value: "indeterminate", label: "In Progress" }, - { value: "done", label: "Done" }, -]; - function quote(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } @@ -38,14 +32,9 @@ function searchClause(raw: string): string | null { return `text ~ ${quote(trimmed)}`; } -function statusClause(categories: JiraStatusCategory[]): string | null { - if (categories.length === 0) return null; - const labels = categories.map((c) => { - if (c === "new") return quote("To Do"); - if (c === "indeterminate") return quote("In Progress"); - return quote("Done"); - }); - return `statusCategory in (${labels.join(", ")})`; +function statusClause(statuses: string[]): string | null { + if (statuses.length === 0) return null; + return `status in (${statuses.map(quote).join(", ")})`; } function assigneeClause(a: AssigneeFilter): string | null { @@ -68,7 +57,7 @@ function sortClause(s: SortKey): string { export function filtersToJql(f: FilterState): string { const clauses = [ projectClause(f.projectKeys), - statusClause(f.statusCategories), + statusClause(f.statuses), assigneeClause(f.assignee), searchClause(f.searchText), ].filter((c): c is string => c !== null); @@ -84,7 +73,7 @@ export function filtersEqual(a: FilterState, b: FilterState): boolean { a.searchText === b.searchText && a.projectKeys.length === b.projectKeys.length && a.projectKeys.every((k, i) => k === b.projectKeys[i]) && - a.statusCategories.length === b.statusCategories.length && - a.statusCategories.every((c, i) => c === b.statusCategories[i]) + a.statuses.length === b.statuses.length && + a.statuses.every((s, i) => s === b.statuses[i]) ); } diff --git a/apps/web/components/jira/my-jira/filter-pills.tsx b/apps/web/components/jira/my-jira/filter-pills.tsx index 0c42ee59bb..da62a63eee 100644 --- a/apps/web/components/jira/my-jira/filter-pills.tsx +++ b/apps/web/components/jira/my-jira/filter-pills.tsx @@ -5,19 +5,70 @@ import { IconChevronDown, IconX } from "@tabler/icons-react"; import { Checkbox } from "@kandev/ui/checkbox"; import { Input } from "@kandev/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; -import type { JiraProject, JiraStatusCategory } from "@/lib/types/jira"; -import { STATUS_CATEGORY_OPTIONS, type AssigneeFilter } from "./filter-model"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import type { JiraProject, JiraStatus } from "@/lib/types/jira"; +import { type AssigneeFilter } from "./filter-model"; type PillShellProps = { label: string; summary: string | null; active: boolean; onClear?: () => void; + // When disabled the pill renders greyed-out and non-interactive; `disabledHint` + // is surfaced via a Radix tooltip on a focusable wrapper so the user learns + // why (e.g. pick a project first), reachable by keyboard and screen readers. + disabled?: boolean; + disabledHint?: string; children: React.ReactNode; }; -function PillShell({ label, summary, active, onClear, children }: PillShellProps) { +function DisabledPill({ + label, + summary, + disabledHint, +}: Pick) { + // Radix Tooltip on a focusable wrapper (per apps/web/AGENTS.md) so keyboard + // and screen-reader users reach the disabled pill and learn why it's off, + // instead of the reason being mouse-hover-only via a raw title attribute. + const pill = ( +
+ + {label} + {summary && {summary}} + + +
+ ); + if (!disabledHint) return pill; + return ( + + {pill} + {disabledHint} + + ); +} + +function PillShell({ + label, + summary, + active, + onClear, + disabled, + disabledHint, + children, +}: PillShellProps) { const [open, setOpen] = useState(false); + if (disabled) { + return ; + } return (