feat: filter Jira tickets by project statuses (#1588) (#1595)

This commit is contained in:
Carlos Florêncio
2026-07-03 19:17:14 +01:00
committed by GitHub
parent d1d56c1a08
commit d451e8f9f8
32 changed files with 1494 additions and 78 deletions
+1
View File
@@ -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)
}
@@ -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
@@ -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/..."
+11
View File
@@ -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")
+125 -6
View File
@@ -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
@@ -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)
@@ -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"`
+10
View File
@@ -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.
+11
View File
@@ -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.
@@ -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()
+42 -5
View File
@@ -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<JiraProject[]>([]);
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<FilterState>(DEFAULT_VIEW.filters);
const [filters, setFilters] = useState<FilterState>(() => initialFilters(defaultProjectKey));
const [activeViewId, setActiveViewId] = useState<string | null>(DEFAULT_VIEW.id);
const [customJql, setCustomJql] = useState<string | null>(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<JiraLaunchPayload | null>(null);
const [openTicket, setOpenTicket] = useState<JiraTicket | null>(null);
@@ -300,6 +336,7 @@ export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClient
<AuthenticatedView
workspaceId={workspaceId}
projects={projects}
defaultProjectKey={defaultProjectKey}
presets={taskPresets}
onStartTask={onStartTask}
onOpenTicket={onOpenTicket}
@@ -1,7 +1,7 @@
"use client";
import { Button } from "@kandev/ui/button";
import type { JiraProject } from "@/lib/types/jira";
import type { JiraProject, JiraStatus } from "@/lib/types/jira";
import { AssigneePill, ProjectPill, StatusPill } from "./filter-pills";
import type { FilterState } from "./filter-model";
import { DEFAULT_FILTERS } from "./filter-model";
@@ -10,6 +10,7 @@ type FilterBarProps = {
filters: FilterState;
onChange: (filters: FilterState) => 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 })}
/>
<StatusPill
value={filters.statusCategories}
onChange={(statusCategories) => onChange({ ...filters, statusCategories })}
options={statusOptions}
value={filters.statuses}
onChange={(statuses) => onChange({ ...filters, statuses })}
hasProjectSelected={filters.projectKeys.length > 0}
/>
<AssigneePill
value={filters.assignee}
@@ -53,7 +57,7 @@ export function FilterBar({
export function hasActiveFilters(f: FilterState): boolean {
return (
f.projectKeys.length > 0 ||
f.statusCategories.length > 0 ||
f.statuses.length > 0 ||
f.assignee !== DEFAULT_FILTERS.assignee ||
f.searchText.trim().length > 0
);
@@ -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);
});
});
@@ -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])
);
}
+100 -21
View File
@@ -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<PillShellProps, "label" | "summary" | "disabledHint">) {
// 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 = (
<div
data-testid={`jira-filter-pill-${label.toLowerCase()}`}
data-disabled="true"
tabIndex={0}
role="button"
aria-disabled="true"
aria-label={disabledHint ?? `${label} filter disabled`}
className="inline-flex items-stretch rounded-md border text-xs overflow-hidden bg-background opacity-50"
>
<span className="px-2.5 py-1.5 flex items-center gap-1.5 cursor-not-allowed">
<span className="text-muted-foreground">{label}</span>
{summary && <span className="font-medium">{summary}</span>}
<IconChevronDown className="h-3 w-3 text-muted-foreground" />
</span>
</div>
);
if (!disabledHint) return pill;
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent>{disabledHint}</TooltipContent>
</Tooltip>
);
}
function PillShell({
label,
summary,
active,
onClear,
disabled,
disabledHint,
children,
}: PillShellProps) {
const [open, setOpen] = useState(false);
if (disabled) {
return <DisabledPill label={label} summary={summary} disabledHint={disabledHint} />;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<div
@@ -28,6 +79,7 @@ function PillShell({ label, summary, active, onClear, children }: PillShellProps
<PopoverTrigger asChild>
<button
type="button"
data-testid={`jira-filter-pill-${label.toLowerCase()}`}
className="cursor-pointer px-2.5 py-1.5 flex items-center gap-1.5 hover:bg-muted/50 transition-colors"
>
<span className="text-muted-foreground">{label}</span>
@@ -102,6 +154,7 @@ export function ProjectPill({ projects, value, onChange }: ProjectPillProps) {
{filtered.map((p) => (
<label
key={p.key}
data-testid={`jira-project-option-${p.key}`}
className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-muted/50"
>
<Checkbox checked={selected.has(p.key)} onCheckedChange={() => toggle(p.key)} />
@@ -115,23 +168,35 @@ export function ProjectPill({ projects, value, onChange }: ProjectPillProps) {
}
type StatusPillProps = {
value: JiraStatusCategory[];
onChange: (cats: JiraStatusCategory[]) => void;
// Available statuses for the selected project(s). Empty means either no
// project is selected or the selected project(s) expose no statuses; the pill
// is disabled either way, with a hint chosen from hasProjectSelected.
options: JiraStatus[];
value: string[];
onChange: (statuses: string[]) => void;
// Whether at least one project is selected. Distinguishes "pick a project
// first" from "this project has no statuses" so the disabled hint isn't
// misleading once a project is chosen.
hasProjectSelected: boolean;
};
export function StatusPill({ value, onChange }: StatusPillProps) {
const NO_PROJECT_HINT = "Select a project to filter by status";
const NO_STATUSES_HINT = "No statuses available for the selected project";
export function StatusPill({ options, value, onChange, hasProjectSelected }: StatusPillProps) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.name.toLowerCase().includes(q));
}, [options, query]);
const selected = new Set(value);
const toggle = (cat: JiraStatusCategory) => {
if (selected.has(cat)) onChange(value.filter((c) => c !== cat));
else onChange([...value, cat]);
const toggle = (name: string) => {
if (selected.has(name)) onChange(value.filter((n) => n !== name));
else onChange([...value, name]);
};
const summary =
value.length > 0
? joinSummary(
value.map((c) => STATUS_CATEGORY_OPTIONS.find((o) => o.value === c)?.label ?? c),
2,
)
: null;
const summary = value.length > 0 ? joinSummary(value, 2) : null;
return (
<PillShell
@@ -139,15 +204,29 @@ export function StatusPill({ value, onChange }: StatusPillProps) {
summary={summary}
active={value.length > 0}
onClear={() => onChange([])}
disabled={options.length === 0}
disabledHint={hasProjectSelected ? NO_STATUSES_HINT : NO_PROJECT_HINT}
>
<div className="py-1">
{STATUS_CATEGORY_OPTIONS.map((o) => (
<div className="p-2 border-b">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search statuses…"
className="h-7 text-xs"
/>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filtered.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">No statuses match.</div>
)}
{filtered.map((o) => (
<label
key={o.value}
key={o.id}
data-testid={`jira-status-option-${o.name}`}
className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-muted/50"
>
<Checkbox checked={selected.has(o.value)} onCheckedChange={() => toggle(o.value)} />
<span className="text-sm">{o.label}</span>
<Checkbox checked={selected.has(o.name)} onCheckedChange={() => toggle(o.name)} />
<span className="text-sm truncate">{o.name}</span>
</label>
))}
</div>
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import type { JiraStatus } from "@/lib/types/jira";
const listJiraProjectStatusesMock = vi.fn<[string], Promise<{ statuses: JiraStatus[] }>>();
vi.mock("@/lib/api/domains/jira-api", () => ({
listJiraProjectStatuses: (key: string) => listJiraProjectStatusesMock(key),
}));
import { reconcileStatuses, useProjectStatuses } from "./use-project-statuses";
afterEach(() => {
cleanup();
listJiraProjectStatusesMock.mockReset();
});
function status(id: string, name: string): JiraStatus {
return { id, name, statusCategory: "indeterminate" };
}
const IN_DEV = "In Development";
describe("reconcileStatuses", () => {
it("returns the same reference when nothing is selected", () => {
const selected: string[] = [];
expect(reconcileStatuses(selected, [status("1", "Open")])).toBe(selected);
});
it("keeps selected statuses that are still available", () => {
const selected = [IN_DEV, "Done"];
const available = [status("1", IN_DEV), status("2", "Done"), status("3", "To Do")];
expect(reconcileStatuses(selected, available)).toEqual([IN_DEV, "Done"]);
});
it("drops selected statuses no longer present in the union", () => {
const selected = [IN_DEV, "Ready for review"];
const available = [status("1", IN_DEV)];
expect(reconcileStatuses(selected, available)).toEqual([IN_DEV]);
});
it("drops all when none remain (e.g. project deselected)", () => {
expect(reconcileStatuses([IN_DEV], [])).toEqual([]);
});
it("returns the same reference when every selection is still valid", () => {
const selected = ["Open"];
const result = reconcileStatuses(selected, [status("1", "Open")]);
expect(result).toBe(selected);
});
});
describe("useProjectStatuses", () => {
it("reports loaded=true with empty options when no project is selected", async () => {
const { result } = renderHook(() => useProjectStatuses([]));
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.options).toEqual([]);
expect(listJiraProjectStatusesMock).not.toHaveBeenCalled();
});
it("stays unloaded until the fetch resolves, then exposes the options", async () => {
let resolve: ((v: { statuses: JiraStatus[] }) => void) | undefined;
listJiraProjectStatusesMock.mockReturnValue(
new Promise((r) => {
resolve = r;
}),
);
const { result } = renderHook(() => useProjectStatuses(["CLIP"]));
// Before the fetch resolves the hook must not claim to be loaded, otherwise
// callers would reconcile a saved status selection against empty options.
expect(result.current.loaded).toBe(false);
expect(result.current.options).toEqual([]);
resolve?.({ statuses: [status("1", IN_DEV)] });
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.options).toEqual([status("1", IN_DEV)]);
});
});
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { listJiraProjectStatuses } from "@/lib/api/domains/jira-api";
import type { JiraStatus } from "@/lib/types/jira";
// reconcileStatuses drops any selected status names that are no longer present
// in the available union (e.g. after the project selection changed). Kept pure
// so it is trivially unit-testable and reusable from the page hook.
export function reconcileStatuses(selected: string[], available: JiraStatus[]): string[] {
if (selected.length === 0) return selected;
const names = new Set(available.map((s) => s.name));
const next = selected.filter((name) => names.has(name));
// Preserve referential equality when nothing changed so callers can skip
// redundant state updates.
return next.length === selected.length ? selected : next;
}
// unionByName merges status lists from several projects, de-duping by name.
// Two projects may expose a status with the same name but different ids; the
// filter targets status names in JQL, so name is the identity that matters.
function unionByName(lists: JiraStatus[][]): JiraStatus[] {
const seen = new Set<string>();
const out: JiraStatus[] = [];
for (const list of lists) {
for (const s of list) {
if (seen.has(s.name)) continue;
seen.add(s.name);
out.push(s);
}
}
return out;
}
// ProjectStatuses is the result of useProjectStatuses. `loaded` is false while
// the fetch for the current project-key set is still pending and flips to true
// only once every selected key has resolved (from cache or network). Callers
// that reconcile a saved status selection against `options` must wait for
// `loaded`, otherwise the first render (options still []) would strip the
// selection before the statuses arrive.
export type ProjectStatuses = {
options: JiraStatus[];
loaded: boolean;
};
// useProjectStatuses fetches the workflow statuses for the selected project
// keys, unions and de-dupes them by name, and caches per project key for the
// lifetime of the component so re-selecting a project never refetches. A fetch
// failure for one project is non-fatal: that project contributes no options
// and the rest still load.
export function useProjectStatuses(projectKeys: string[]): ProjectStatuses {
const [options, setOptions] = useState<JiraStatus[]>([]);
const [loaded, setLoaded] = useState(false);
const cacheRef = useRef<Map<string, JiraStatus[]>>(new Map());
const cacheKey = [...projectKeys].sort().join(",");
useEffect(() => {
let cancelled = false;
// Re-fetching for a new key set: options for the previous set are stale and
// the current set has not resolved yet, so mark unloaded until it does.
setLoaded(false);
async function load() {
if (projectKeys.length === 0) {
setOptions([]);
setLoaded(true);
return;
}
const cache = cacheRef.current;
await Promise.all(
projectKeys
.filter((key) => !cache.has(key))
.map(async (key) => {
try {
const { statuses } = await listJiraProjectStatuses(key);
cache.set(key, statuses ?? []);
} catch {
// Non-fatal: cache an empty list so we don't refetch on every render.
cache.set(key, []);
}
}),
);
if (cancelled) return;
setOptions(unionByName(projectKeys.map((key) => cache.get(key) ?? [])));
setLoaded(true);
}
void load();
return () => {
cancelled = true;
};
// cacheKey encodes the sorted key set; projectKeys identity is unstable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheKey]);
return { options, loaded };
}
@@ -33,11 +33,13 @@ const view: SavedView = {
name: "Mine",
filters: {
projectKeys: [],
statusCategories: [],
statuses: [],
assignee: "me",
searchText: "",
sort: "updated",
},
customJql: null,
builtin: false,
};
describe("useSavedViews", () => {
@@ -63,6 +65,33 @@ describe("useSavedViews", () => {
});
});
it("hydrates a legacy statusCategories view to statuses: [] without throwing", async () => {
// Views persisted before the status-name migration carry `statusCategories`
// and no `statuses`. They must load, dropping the old category filter.
const legacy = {
id: "custom:legacy",
name: "Legacy",
filters: {
projectKeys: ["CLIP"],
statusCategories: ["indeterminate"],
assignee: "me",
searchText: "",
sort: "updated",
},
};
localStorageMock.setItem(STORAGE_KEY, JSON.stringify([legacy]));
const { result } = renderHook(() => useSavedViews());
await waitFor(() => {
const loaded = result.current.custom.find((v) => v.id === "custom:legacy");
expect(loaded).toBeDefined();
expect(loaded?.filters.statuses).toEqual([]);
expect(loaded?.filters.projectKeys).toEqual(["CLIP"]);
expect("statusCategories" in loaded!.filters).toBe(false);
});
});
it("retries empty local views after a failed backend sync", async () => {
localStorageMock.setItem(STORAGE_KEY, JSON.stringify([]));
localStorageMock.setItem(SYNC_FAILED_KEY, "1");
@@ -28,12 +28,11 @@ const BUILTIN_VIEWS: SavedView[] = [
builtin: true,
filters: { ...DEFAULT_FILTERS, assignee: "me" },
},
{
id: "builtin:in-progress",
name: "In progress",
builtin: true,
filters: { ...DEFAULT_FILTERS, assignee: "me", statusCategories: ["indeterminate"] },
},
// The former "In progress" builtin filtered by the "indeterminate" status
// category. Status names are now project-specific, so a global builtin can't
// hard-code an in-progress status without a selected project; dropping the
// status filter would have left it indistinguishable from "Assigned to me".
// Users get an in-progress view by selecting a project and its statuses.
{
id: "builtin:unassigned",
name: "Unassigned",
@@ -49,32 +48,61 @@ function readStorage(): SavedView[] {
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(isSavedView);
return normalizeSavedViews(parsed);
} catch {
return [];
}
}
function isFilterState(f: unknown): f is FilterState {
// isFilterStateShape recognizes both the current `statuses: string[]` shape and
// legacy views persisted with `statusCategories`. It only validates the fields
// unrelated to statuses; status normalization happens in normalizeFilterState.
function isFilterStateShape(f: unknown): f is Record<string, unknown> {
if (!f || typeof f !== "object") return false;
const rec = f as Record<string, unknown>;
return (
Array.isArray(rec.projectKeys) &&
rec.projectKeys.every((k) => typeof k === "string") &&
Array.isArray(rec.statusCategories) &&
rec.statusCategories.every(
(c) => c === "" || c === "new" || c === "indeterminate" || c === "done",
) &&
(rec.assignee === "me" || rec.assignee === "unassigned" || rec.assignee === "anyone") &&
typeof rec.searchText === "string" &&
(rec.sort === "updated" || rec.sort === "created" || rec.sort === "priority")
);
}
function isSavedView(v: unknown): v is SavedView {
if (!v || typeof v !== "object") return false;
// normalizeFilterState coerces a persisted filter (current or legacy) into a
// valid FilterState. Legacy views carrying `statusCategories` (and no
// `statuses`) hydrate to `statuses: []` — the old category filter is dropped
// rather than throwing, since categories no longer map to a specific status.
function normalizeFilterState(rec: Record<string, unknown>): FilterState {
const statuses =
Array.isArray(rec.statuses) && rec.statuses.every((s) => typeof s === "string")
? (rec.statuses as string[])
: [];
return {
projectKeys: rec.projectKeys as string[],
statuses,
assignee: rec.assignee as FilterState["assignee"],
searchText: rec.searchText as string,
sort: rec.sort as FilterState["sort"],
};
}
function normalizeSavedView(v: unknown): SavedView | null {
if (!v || typeof v !== "object") return null;
const rec = v as Record<string, unknown>;
return typeof rec.id === "string" && typeof rec.name === "string" && isFilterState(rec.filters);
if (typeof rec.id !== "string" || typeof rec.name !== "string") return null;
if (!isFilterStateShape(rec.filters)) return null;
return {
id: rec.id,
name: rec.name,
filters: normalizeFilterState(rec.filters),
customJql: typeof rec.customJql === "string" ? rec.customJql : null,
builtin: rec.builtin === true,
};
}
function normalizeSavedViews(values: unknown[]): SavedView[] {
return values.map(normalizeSavedView).filter((v): v is SavedView => v !== null);
}
function writeStorage(views: SavedView[]): void {
@@ -88,7 +116,7 @@ function writeStorage(views: SavedView[]): void {
function readServerViews(value: unknown): SavedView[] | null {
if (!Array.isArray(value)) return null;
return value.filter(isSavedView);
return normalizeSavedViews(value);
}
const syncServer = createQueuedUserSettingsSync<SavedView[]>(SYNC_FAILED_KEY, (views) => ({
@@ -120,7 +148,7 @@ function latestSavedViews(fallback: SavedView[]): SavedView[] {
if (raw === null) return fallback;
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return fallback;
return parsed.filter(isSavedView);
return normalizeSavedViews(parsed);
} catch {
return fallback;
}
+9
View File
@@ -1486,6 +1486,13 @@ export class ApiClient {
await this.request("POST", "/api/v1/jira/mock/projects", { projects });
}
async mockJiraSetProjectStatuses(projectKey: string, statuses: MockJiraStatus[]): Promise<void> {
await this.request("POST", "/api/v1/jira/mock/project-statuses", {
projectKey,
statuses,
});
}
async mockJiraAddTickets(tickets: MockJiraTicket[]): Promise<void> {
await this.request("POST", "/api/v1/jira/mock/tickets", { tickets });
}
@@ -1882,6 +1889,8 @@ export class ApiClient {
export type MockJiraProject = { id: string; key: string; name: string };
export type MockJiraStatus = { id: string; name: string; statusCategory?: string };
export type MockJiraTransition = {
id: string;
name: string;
@@ -0,0 +1,104 @@
import { test, expect } from "../../fixtures/test-base";
// Covers the "default project" preference (issue #1588 follow-up): when the
// workspace Jira config carries a defaultProjectKey, opening /jira must land on
// that project pre-selected in the Project filter (which in turn enables the
// Status filter), instead of the historical "no project" default.
test.describe("Jira default project", () => {
const PROJECT_KEY = "CLIP";
const OTHER_KEY = "OPS";
async function seedProjectsAndTickets(apiClient: {
mockJiraSetProjects: (p: { id: string; key: string; name: string }[]) => Promise<void>;
mockJiraSetProjectStatuses: (
key: string,
s: { id: string; name: string; statusCategory: string }[],
) => Promise<void>;
mockJiraSetSearchHits: (
t: {
key: string;
summary: string;
projectKey: string;
statusName: string;
statusCategory: string;
url: string;
}[],
) => Promise<void>;
}): Promise<void> {
await apiClient.mockJiraSetProjects([
{ id: "1", key: PROJECT_KEY, name: "Clip" },
{ id: "2", key: OTHER_KEY, name: "Ops" },
]);
await apiClient.mockJiraSetProjectStatuses(PROJECT_KEY, [
{ id: "10001", name: "In Development", statusCategory: "indeterminate" },
]);
await apiClient.mockJiraSetSearchHits([
{
key: "CLIP-1",
summary: "Dev ticket",
projectKey: PROJECT_KEY,
statusName: "In Development",
statusCategory: "indeterminate",
url: "https://acme.atlassian.net/browse/CLIP-1",
},
{
key: "OPS-9",
summary: "Ops ticket",
projectKey: OTHER_KEY,
statusName: "In Development",
statusCategory: "indeterminate",
url: "https://acme.atlassian.net/browse/OPS-9",
},
]);
}
test("no default project keeps the project filter unselected", async ({
apiClient,
testPage,
}) => {
await apiClient.mockJiraReset();
await apiClient.setJiraConfig({
siteUrl: "https://acme.atlassian.net",
email: "alice@example.com",
secret: "api-token-value",
});
await apiClient.waitForIntegrationAuthHealthy("jira");
await seedProjectsAndTickets(apiClient);
await testPage.goto("/jira");
// Project pill shows no summary, and the status pill stays disabled.
await expect(testPage.getByTestId("jira-filter-pill-project")).not.toContainText(PROJECT_KEY);
await expect(testPage.getByTestId("jira-filter-pill-status")).toHaveAttribute(
"data-disabled",
"true",
);
});
test("default project is pre-selected on load and narrows the list", async ({
apiClient,
testPage,
}) => {
await apiClient.mockJiraReset();
await apiClient.setJiraConfig({
siteUrl: "https://acme.atlassian.net",
email: "alice@example.com",
defaultProjectKey: PROJECT_KEY,
secret: "api-token-value",
});
await apiClient.waitForIntegrationAuthHealthy("jira");
await seedProjectsAndTickets(apiClient);
await testPage.goto("/jira");
// The Project pill reflects the default selection…
await expect(testPage.getByTestId("jira-filter-pill-project")).toContainText(PROJECT_KEY);
// …which enables the Status pill (statuses come from the selected project).
await expect(testPage.getByTestId("jira-filter-pill-status")).not.toHaveAttribute(
"data-disabled",
"true",
);
// …and the list is narrowed to the default project's ticket only.
await expect(testPage.getByText("CLIP-1")).toBeVisible();
await expect(testPage.getByText("OPS-9")).toHaveCount(0);
});
});
@@ -0,0 +1,80 @@
import { test, expect } from "../../fixtures/test-base";
// Covers the ticket-list Status filter (issue #1588): the filter must offer the
// selected project's real workflow statuses, not the three coarse status
// categories, and must be disabled until a project is selected.
test.describe("Jira status filter", () => {
const PROJECT_KEY = "CLIP";
test.beforeEach(async ({ apiClient }) => {
await apiClient.mockJiraReset();
await apiClient.setJiraConfig({
siteUrl: "https://acme.atlassian.net",
email: "alice@example.com",
secret: "api-token-value",
});
await apiClient.waitForIntegrationAuthHealthy("jira");
await apiClient.mockJiraSetProjects([{ id: "1", key: PROJECT_KEY, name: "Clip" }]);
await apiClient.mockJiraSetProjectStatuses(PROJECT_KEY, [
{ id: "10001", name: "In Development", statusCategory: "indeterminate" },
{ id: "10002", name: "Ready for review", statusCategory: "indeterminate" },
]);
// The project pill lists only projects the user has tickets in, derived from
// the assignee/reporter search hits — so the seeded tickets double as both
// that discovery source and the list the status filter narrows.
await apiClient.mockJiraSetSearchHits([
{
key: "CLIP-1",
summary: "Dev ticket",
projectKey: PROJECT_KEY,
statusName: "In Development",
statusCategory: "indeterminate",
url: "https://acme.atlassian.net/browse/CLIP-1",
},
{
key: "CLIP-2",
summary: "Review ticket",
projectKey: PROJECT_KEY,
statusName: "Ready for review",
statusCategory: "indeterminate",
url: "https://acme.atlassian.net/browse/CLIP-2",
},
]);
});
test("status filter is disabled until a project is selected", async ({ testPage }) => {
await testPage.goto("/jira");
const statusPill = testPage.getByTestId("jira-filter-pill-status");
await expect(statusPill).toBeVisible();
await expect(statusPill).toHaveAttribute("data-disabled", "true");
});
test("selecting a project lists its real statuses and narrows the list", async ({ testPage }) => {
await testPage.goto("/jira");
// Pick the seeded project.
await testPage.getByTestId("jira-filter-pill-project").click();
await testPage.getByTestId(`jira-project-option-${PROJECT_KEY}`).click();
await testPage.keyboard.press("Escape");
// Status pill is now enabled and offers the project's real statuses.
const statusPill = testPage.getByTestId("jira-filter-pill-status");
await expect(statusPill).not.toHaveAttribute("data-disabled", "true");
await statusPill.click();
await expect(testPage.getByTestId("jira-status-option-In Development")).toBeVisible();
await expect(testPage.getByTestId("jira-status-option-Ready for review")).toBeVisible();
// The old coarse categories must NOT appear.
await expect(testPage.getByTestId("jira-status-option-To Do")).toHaveCount(0);
await expect(testPage.getByTestId("jira-status-option-In Progress")).toHaveCount(0);
// Both tickets show before narrowing.
await expect(testPage.getByText("CLIP-1")).toBeVisible();
await expect(testPage.getByText("CLIP-2")).toBeVisible();
// Checking "Ready for review" narrows to the matching ticket only.
await testPage.getByTestId("jira-status-option-Ready for review").click();
await expect(testPage.getByText("CLIP-2")).toBeVisible();
await expect(testPage.getByText("CLIP-1")).toHaveCount(0);
});
});
+8
View File
@@ -5,6 +5,7 @@ import type {
JiraIssueWatch,
JiraProject,
JiraSearchResult,
JiraStatus,
JiraTicket,
SetJiraConfigRequest,
TestJiraConnectionResult,
@@ -46,6 +47,13 @@ export async function listJiraProjects(options?: ApiRequestOptions) {
return fetchJson<{ projects: JiraProject[] }>(`/api/v1/jira/projects`, options);
}
export async function listJiraProjectStatuses(projectKey: string, options?: ApiRequestOptions) {
return fetchJson<{ statuses: JiraStatus[] }>(
`/api/v1/jira/projects/${encodeURIComponent(projectKey)}/statuses`,
options,
);
}
export async function getJiraTicket(ticketKey: string, options?: ApiRequestOptions) {
return fetchJson<JiraTicket>(`/api/v1/jira/tickets/${encodeURIComponent(ticketKey)}`, options);
}
+12
View File
@@ -86,6 +86,18 @@ export interface JiraProject {
id: string;
}
/**
* A workflow status defined for a project. Unlike the coarse three-bucket
* `JiraStatusCategory`, `name` is the project-specific status the user sees on
* a ticket (e.g. "In Development", "Ready for review"). The ticket-list status
* filter is populated from these.
*/
export interface JiraStatus {
id: string;
name: string;
statusCategory: JiraStatusCategory;
}
export interface JiraSearchResult {
tickets: JiraTicket[];
maxResults: number;
+118
View File
@@ -0,0 +1,118 @@
---
spec: docs/specs/jira-status-filter/spec.md
created: 2026-07-03
status: draft
---
# Implementation Plan: Jira Ticket Status Filter
## Overview
Replace the Jira tickets page status-*category* filter with a real-status
multi-select sourced from the selected project(s). Backend gains one read
endpoint that lists a project's statuses; the frontend swaps the filter model
from `statusCategories` to `statuses`, fetches per-project statuses on demand,
and emits `status in (…)` JQL. Order: backend contract first, then frontend
types/API, filter model, UI wiring, then E2E.
---
## Backend
### JiraStatus model (`apps/backend/internal/jira/models.go`)
Add `JiraStatus{ ID, Name, StatusCategory string }` with JSON tags
`id`/`name`/`statusCategory`.
### Client method (`apps/backend/internal/jira/client.go`, `cloud_client.go`)
Add `ListProjectStatuses(ctx, projectKey string) ([]JiraStatus, error)` to the
`Client` interface. `CloudClient` implementation calls
`GET {apiBase}/project/{projectKey}/statuses` via the existing `do()` helper.
The upstream response is `[]{ id, name, statuses: []{ id, name, statusCategory:{ key } } }`
(per issue type). Flatten and de-duplicate by status id; map
`statusCategory.key` → `StatusCategory`.
### Mock client (`apps/backend/internal/jira/mock_client.go`)
Add `statuses map[string][]JiraStatus`, a `SetProjectStatuses(key, []JiraStatus)`
setter, and `ListProjectStatuses` returning the seeded slice. Wire a mock
control route `POST /api/v1/jira/mock/projects/:key/statuses` in
`mock_controller.go` following the existing mock route pattern.
### Service (`apps/backend/internal/jira/service.go`)
Add `ListProjectStatuses(ctx, projectKey)` using `clientFor()`.
### Handler + route (`apps/backend/internal/jira/handlers.go`)
Add `httpListProjectStatuses` returning `{ "statuses": [...] }`, using
`writeClientError`. Register `GET /projects/:key/statuses` in
`RegisterHTTPRoutes`.
---
## Frontend
### Types (`apps/web/lib/types/jira.ts`)
Add `interface JiraStatus { id: string; name: string; statusCategory: JiraStatusCategory }`.
### API client (`apps/web/lib/api/domains/jira-api.ts`)
Add `listJiraProjectStatuses(key, options?)` →
`fetchJson<{ statuses: JiraStatus[] }>('/api/v1/jira/projects/{key}/statuses')`.
### Filter model (`apps/web/components/jira/my-jira/filter-model.ts`)
- `FilterState.statusCategories: JiraStatusCategory[]` → `statuses: string[]`.
- Remove `STATUS_CATEGORY_OPTIONS`.
- `statusClause` emits `status in ("A", "B")` (reuse `quote`).
- Update `DEFAULT_FILTERS` and `filtersEqual`.
### Saved views back-compat (`apps/web/components/jira/my-jira/use-saved-views.ts`)
When hydrating persisted views, coerce any legacy `statusCategories` field to
`statuses: []` so old localStorage entries load without error.
### Status pill (`apps/web/components/jira/my-jira/filter-pills.tsx`)
Rewrite `StatusPill` to accept `options: JiraStatus[]`, `value: string[]`,
`disabled`, and a hint. Disabled + hint when no options (no project selected).
Multi-select by status name.
### Filter bar + page wiring (`filter-bar.tsx`, `app/jira/jira-page-client.tsx`)
Add a hook that fetches statuses for the selected `projectKeys` (union,
de-duped by name), caches per project key for the session, and drops selected
statuses no longer present when the project set changes. Pass options/disabled
into `StatusPill` via `FilterBar`.
---
## Tests
- **Client flatten/de-dupe** — `cloud_client_test.go`: mock `GET /rest/api/3/project/{key}/statuses` returning two issue types with overlapping statuses; assert de-dup by id and category mapping. (spec: union scenario, data model)
- **Handler shape/errors** — `handlers_test.go`: `200 {statuses:[...]}`, and `503 JIRA_NOT_CONFIGURED` when not configured. (spec: API surface, failure modes)
- **Service passthrough** — `service_test.go`: fake client returns statuses.
- **filtersToJql status clause** — `filter-model.test.ts`: `statuses: ["Ready for review"]` → `status in ("Ready for review")`; empty → no clause. (spec: JQL contract)
- **Saved-view back-compat** — `use-saved-views` test: legacy `statusCategories` view hydrates to `statuses: []` without throwing. (spec: saved views scenario)
- **Stale-status drop** — unit test for the project→status reconciliation helper. (spec: deselect-project scenario)
---
## E2E Tests
- **Status populates from project** — `apps/web/e2e/jira/status-filter.spec.ts`: seed a mock project with statuses `In Development`, `Ready for review`; select the project; open Status; assert real status names appear (not To Do/In Progress/Done). (spec scenario 2)
- **Filter narrows list** — check `Ready for review`; assert only matching seeded tickets remain. (spec scenario 3)
- **Disabled without project** — with no project selected, Status pill is disabled with hint. (spec scenario 1)
---
## Implementation Waves
```
Wave 1:
- [x] [task-01-backend-status-endpoint](task-01-backend-status-endpoint.md)
Wave 2:
- [x] [task-02-frontend-types-api](task-02-frontend-types-api.md)
- [x] [task-03-frontend-filter-model](task-03-frontend-filter-model.md)
Wave 3:
- [x] [task-04-frontend-ui-wiring](task-04-frontend-ui-wiring.md)
Wave 4:
- [x] [task-05-e2e](task-05-e2e.md)
```
Frontend tasks are largely sequential (shared build/type surfaces); task-04
depends on 02 and 03. E2E runs last against the mock Jira client.
@@ -0,0 +1,43 @@
---
id: "01-backend-status-endpoint"
title: "Backend: list project statuses endpoint"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/jira-status-filter/spec.md"
---
# Task 01: Backend list-project-statuses endpoint
Add the read path that returns a project's real Jira statuses.
## Acceptance
- New `JiraStatus{ID,Name,StatusCategory}` type and a `Client.ListProjectStatuses(ctx, key)` method implemented on `CloudClient` (calls `GET {apiBase}/project/{key}/statuses`, flattens per-issue-type statuses, de-dupes by id, maps `statusCategory.key`).
- `MockClient` supports seeding statuses and a mock control route seeds them; `Service.ListProjectStatuses` and handler `GET /api/v1/jira/projects/:key/statuses` return `{ "statuses": [...] }`.
- Not-configured returns `503` `JIRA_NOT_CONFIGURED`; upstream 401/403/404 pass through via `writeClientError`.
## Verification
- `cd apps/backend && go test ./internal/jira/...`
- `make -C apps/backend lint`
## Files likely touched
- `apps/backend/internal/jira/models.go`
- `apps/backend/internal/jira/client.go`
- `apps/backend/internal/jira/cloud_client.go`
- `apps/backend/internal/jira/mock_client.go`
- `apps/backend/internal/jira/mock_controller.go`
- `apps/backend/internal/jira/service.go`
- `apps/backend/internal/jira/handlers.go`
- Tests: `cloud_client_test.go`, `handlers_test.go`, `service_test.go`
## Inputs
- Spec: Data model, API surface, Failure modes.
- Plan: Backend section.
- Patterns: mirror `ListProjects`/`httpListProjects` and the `do()` helper; mock route pattern in `mock_controller.go`.
## Dependencies
None.
## Output contract
Report: summary, files changed, tests run + results, blockers, risks, and set `status: done` + tick the plan checkbox.
@@ -0,0 +1,36 @@
---
id: "02-frontend-types-api"
title: "Frontend: JiraStatus type + status API client"
status: done
wave: 2
depends_on: ["01-backend-status-endpoint"]
plan: "plan.md"
spec: "../../specs/jira-status-filter/spec.md"
---
# Task 02: Frontend JiraStatus type + API client
Expose the new backend endpoint to the SPA.
## Acceptance
- `apps/web/lib/types/jira.ts` exports `interface JiraStatus { id: string; name: string; statusCategory: JiraStatusCategory }`.
- `apps/web/lib/api/domains/jira-api.ts` exports `listJiraProjectStatuses(projectKey, options?)` returning `{ statuses: JiraStatus[] }`, URL-encoding the key, mirroring `listJiraProjects`.
## Verification
- `cd apps/web && pnpm run typecheck`
- `cd apps && pnpm --filter @kandev/web lint`
## Files likely touched
- `apps/web/lib/types/jira.ts`
- `apps/web/lib/api/domains/jira-api.ts`
## Inputs
- Spec: Data model, API surface.
- Plan: Frontend > Types, API client.
- Pattern: existing `listJiraProjects` / `getJiraTicket` in the same file.
## Dependencies
Task 01 (endpoint contract). Type work can start in parallel but verify against the real endpoint shape.
## Output contract
Report: summary, files changed, checks run, blockers, risks; set `status: done` + tick plan checkbox.
@@ -0,0 +1,40 @@
---
id: "03-frontend-filter-model"
title: "Frontend: filter model status -> status names + saved-view back-compat"
status: done
wave: 2
depends_on: []
plan: "plan.md"
spec: "../../specs/jira-status-filter/spec.md"
---
# Task 03: Filter model + saved-view back-compat
Switch the filter model from status categories to real status names.
## Acceptance
- `FilterState.statusCategories: JiraStatusCategory[]` becomes `statuses: string[]`; `DEFAULT_FILTERS` and `filtersEqual` updated; `STATUS_CATEGORY_OPTIONS` removed.
- `statusClause` emits `status in ("A", "B")` using the existing `quote`; empty selection emits no clause.
- Legacy saved views persisted with `statusCategories` hydrate to `statuses: []` without throwing (`use-saved-views.ts`).
- Unit tests cover the new JQL clause and the legacy-view hydration.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- components/jira/my-jira/filter-model.test.ts`
- `cd apps && pnpm --filter @kandev/web test -- components/jira/my-jira/use-saved-views`
- `cd apps/web && pnpm run typecheck`
## Files likely touched
- `apps/web/components/jira/my-jira/filter-model.ts`
- `apps/web/components/jira/my-jira/filter-model.test.ts`
- `apps/web/components/jira/my-jira/use-saved-views.ts` (+ test)
## Inputs
- Spec: What, Data model (FilterState change + saved-view rule), Scenarios (JQL, saved views).
- Plan: Frontend > Filter model, Saved views back-compat; Tests.
- Note: this changes a shared type; expect TypeScript errors in `filter-pills.tsx`/`filter-bar.tsx`/`jira-page-client.tsx` resolved by task 04.
## Dependencies
None (type-only; consumers fixed in task 04).
## Output contract
Report: summary, files changed, tests run + results, blockers, risks; set `status: done` + tick plan checkbox.
@@ -0,0 +1,42 @@
---
id: "04-frontend-ui-wiring"
title: "Frontend: StatusPill options + per-project status fetch/cache wiring"
status: done
wave: 3
depends_on: ["02-frontend-types-api", "03-frontend-filter-model"]
plan: "plan.md"
spec: "../../specs/jira-status-filter/spec.md"
---
# Task 04: Status pill + page wiring
Populate the status filter from the selected project(s) and reconcile selection.
## Acceptance
- `StatusPill` accepts `options: JiraStatus[]`, `value: string[]`, `disabled`, and a hint; multi-selects by status name; when `options` is empty (no project selected) it renders disabled with a hint to pick a project.
- Page hook fetches statuses for each selected project key via `listJiraProjectStatuses`, unions + de-dupes by name, and caches per project key for the session (no refetch on re-select). Status fetch failure is non-fatal (empty options, list still loads).
- Changing the project selection drops any selected statuses no longer present in the union (reconciliation helper is unit-testable).
- `FilterBar` passes options/disabled through to `StatusPill`.
## Verification
- `cd apps/web && pnpm run typecheck`
- `cd apps && pnpm --filter @kandev/web lint`
- `cd apps && pnpm --filter @kandev/web test -- components/jira/my-jira` (reconciliation helper test)
## Files likely touched
- `apps/web/components/jira/my-jira/filter-pills.tsx`
- `apps/web/components/jira/my-jira/filter-bar.tsx`
- `apps/web/app/jira/jira-page-client.tsx`
- new small hook/helper module under `apps/web/components/jira/my-jira/` (+ test)
## Inputs
- Spec: What (union/dedupe, disabled-when-no-project, cache, drop stale), Failure modes, Decisions, Scenarios 1/4/5/7.
- Plan: Frontend > Status pill, Filter bar + page wiring.
- Patterns: existing `StatusPill`/`ProjectPill` in `filter-pills.tsx`; `useJiraPageData`/`loadUserProjects` in `jira-page-client.tsx`.
- Follow `/mobile-parity`: keep the pill responsive.
## Dependencies
Tasks 02 and 03.
## Output contract
Report: summary, files changed, tests run + results, blockers, risks; set `status: done` + tick plan checkbox.
@@ -0,0 +1,37 @@
---
id: "05-e2e"
title: "E2E: Jira status filter"
status: done
wave: 4
depends_on: ["01-backend-status-endpoint", "04-frontend-ui-wiring"]
plan: "plan.md"
spec: "../../specs/jira-status-filter/spec.md"
---
# Task 05: E2E coverage for the status filter
Verify the user-visible flow against the mock Jira client.
## Acceptance
- Spec scenario 1: with no project selected, the Status pill is disabled and shows the pick-a-project hint.
- Spec scenario 2: after selecting a seeded project, the Status pill lists real status names (`In Development`, `Ready for review`) and not the old categories.
- Spec scenario 3: checking `Ready for review` narrows the ticket list to matching seeded tickets.
- Uses the mock control routes to seed projects, project statuses, and tickets.
## Verification
- `cd apps/web && pnpm e2e tests/integrations/jira-status-filter.spec.ts`
## Files likely touched
- `apps/web/e2e/tests/integrations/jira-status-filter.spec.ts`
- possibly a shared Jira e2e fixture/helper under `apps/web/e2e/`
## Inputs
- Spec: Scenarios 1-3.
- Plan: E2E Tests.
- Patterns: existing Jira e2e specs and mock seeding via `/api/v1/jira/mock/...` (see `apps/web/e2e/README.md`).
## Dependencies
Tasks 01 and 04.
## Output contract
Report: summary, files changed, e2e run result, blockers, risks; set `status: done` + tick plan checkbox.
+1
View File
@@ -69,6 +69,7 @@ Per-workspace credentials and triage triggers for external services.
| [slack](integrations/slack.md) | shipped |
| [external-mcp](integrations/external-mcp.md) | draft |
| [gitlab-integration](gitlab-integration/spec.md) | shipped |
| [jira-status-filter](jira-status-filter/spec.md) | shipped |
## workspaces/ — workspace lifecycle
+123
View File
@@ -0,0 +1,123 @@
---
status: shipped
created: 2026-07-03
owner: cfl12
---
# Jira Ticket Status Filter
## Why
On the Jira tickets page, the "Status" filter only offers Jira's three built-in
status *categories* (To Do / In Progress / Done), not the real workflow statuses
that appear on tickets (e.g. `In Development`, `Ready for review`). Users read
those real status names on every ticket row, look for them in the filter, and
can't find them — so the filter looks broken and can't express "show me only
tickets that are Ready for review". Reported in issue #1588.
## What
- The filter bar SHALL offer a status filter whose options are the **real Jira
status names** of the selected project(s), not the three status categories.
- The status filter SHALL be **multi-select**; selecting statuses narrows the
ticket list to tickets whose status name is one of the selected values.
- The status options SHALL be sourced from the project(s) currently selected in
the Project filter. When more than one project is selected, the options are the
**union** of those projects' statuses, de-duplicated by status name.
- When no project is selected, the status filter SHALL be disabled and show a
hint directing the user to pick a project first (matching the issue's target
workflow: choose project → statuses populate).
- Selected statuses SHALL be reflected in the composed JQL as
`status in ("Name", …)` (replacing the previous `statusCategory in (…)`).
- The previous status-category filter is **removed**, per the acceptance criteria
("current status filter is renamed or removed").
- Changing the Project selection SHALL drop any selected statuses that are no
longer available in the new project set (no stale/unselectable statuses persist).
- Fetched statuses per project SHALL be cached for the page session so switching
project selections back and forth does not refetch.
## Data model
No persistent backend state. One new transport type is introduced.
Backend Go type (in `apps/backend/internal/jira/models.go`), mirrored on the
frontend in `apps/web/lib/types/jira.ts`:
```
JiraStatus
id string Jira status id
name string display name, e.g. "Ready for review"
statusCategory string "new" | "indeterminate" | "done" (for badge colour only)
```
`FilterState` (frontend, `apps/web/components/jira/my-jira/filter-model.ts`)
changes its status field from `statusCategories: JiraStatusCategory[]` to
`statuses: string[]` (status names). Saved views persisted in localStorage that
still carry the old `statusCategories` shape MUST load without error — the old
field is ignored and treated as "no status filter".
## API surface
New backend endpoint, following the existing `GET /api/v1/jira/projects` pattern:
- `GET /api/v1/jira/projects/:key/statuses`
- Response `200`: `{ "statuses": JiraStatus[] }` — flattened + de-duplicated by
status id across the project's issue types.
- Errors follow the shared Jira handler mapping: `503`
`{ "code": "JIRA_NOT_CONFIGURED" }` when not configured; upstream `401/403/404`
passed through; other failures `500`.
New client method on the `Client` interface / `CloudClient`
(`apps/backend/internal/jira/client.go`, `cloud_client.go`):
- `ListProjectStatuses(ctx, projectKey string) ([]JiraStatus, error)` — calls
Jira `GET {apiBase}/project/{projectKey}/statuses` (`apiBase` is `/rest/api/3`
for Cloud, `/rest/api/2` for Server/DC), flattening the per-issue-type status
arrays and de-duplicating by id. `MockClient` gains a matching seed setter for
E2E.
New frontend API function (`apps/web/lib/api/domains/jira-api.ts`):
- `listJiraProjectStatuses(projectKey)` → `{ statuses: JiraStatus[] }`.
JQL contract (`filter-model.ts`): the status clause becomes
`status in ("A", "B")` with names quoted/escaped exactly as project keys are today.
Empty selection emits no clause.
## Failure modes
- **Status fetch fails / project has no statuses:** the status filter shows no
selectable options (disabled with an unobtrusive hint); the ticket list is
unaffected and no status clause is added to the JQL. Failure is non-fatal and
logged, consistent with how project loading already degrades on this page.
- **Not configured:** the page already gates on Jira config; the endpoint returns
`503 JIRA_NOT_CONFIGURED` and the filter simply has no options.
## Scenarios
- **GIVEN** Jira is configured and no project is selected, **WHEN** the user opens
the Status filter, **THEN** it is disabled and shows a hint to select a project.
- **GIVEN** a project `CLIP` whose workflow includes `In Development` and
`Ready for review`, **WHEN** the user selects project `CLIP`, **THEN** the Status
filter lists the real `CLIP` status names including `In Development` and
`Ready for review` (not To Do / In Progress / Done).
- **GIVEN** project `CLIP` selected, **WHEN** the user checks `Ready for review`,
**THEN** the composed JQL contains `status in ("Ready for review")` and the
ticket list shows only tickets in that status.
- **GIVEN** two projects selected with overlapping status names, **WHEN** the user
opens the Status filter, **THEN** each status name appears once (union,
de-duplicated).
- **GIVEN** `Ready for review` is selected under project `CLIP`, **WHEN** the user
deselects `CLIP` and selects a project without a `Ready for review` status,
**THEN** `Ready for review` is dropped from the active filter and the JQL no
longer references it.
- **GIVEN** a saved view persisted with the old `statusCategories` field, **WHEN**
the page loads that view, **THEN** it loads without error and applies no status
filter.
- **GIVEN** the status endpoint returns an error for the selected project, **WHEN**
the user opens the Status filter, **THEN** it shows no options and the ticket
list is still returned by the other filters.
## Out of scope
- Filtering by status *category* (the old behavior) — removed, not preserved.
- Persisting fetched statuses beyond the page session (no backend cache/table).
- Editing or transitioning statuses from the filter (transitions already exist
elsewhere).
- Assignee/priority/type filter changes.
## Decisions
- When no project is selected, the status filter is disabled and shows a hint to
pick a project first (confirmed with owner, matches the issue's target
workflow). It does not fall back to the old category filter.