Skip to content

Commit 7c3de97

Browse files
committed
Fix CLI status filtering and add status exclusion support
Status aliases now store numeric IDs instead of names, fixing silent filter failures where strconv.Atoi("Done") would fail on the server. Add status_id_not query param for negation filtering (~done), a completed-statuses endpoint for fallback resolution when aliases are stale, and a ws config refresh command to regenerate aliases.
1 parent d896214 commit 7c3de97

File tree

11 files changed

+180
-23
lines changed

11 files changed

+180
-23
lines changed

RELEASE_NOTES.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Windshift v0.4.5
1+
# Windshift v0.4.6
22

33
---
44

@@ -12,25 +12,27 @@
1212

1313
## Highlights
1414

15-
### Improved Onboarding for Non-Admin Users
16-
The onboarding flow now handles non-admin users who already have access to a workspace. Previously, these users could get stuck in the onboarding process; they are now guided directly into their available workspace.
15+
### Fixed CLI Status Filtering
16+
Status filtering in the CLI (`ws task list -s done`, `ws task mine -s ~done`) was silently broken because aliases stored status names instead of numeric IDs. The server's `status_id` parameter requires an integer, so name-based values were quietly ignored. Aliases now store numeric status IDs, and a fallback mechanism queries the server's completed-statuses endpoint when aliases are stale or missing.
1717

18-
### Searchable Multi-Select for Iteration Filters
19-
The CQL iteration `IN` / `NOT IN` filter UI has been upgraded from a plain checkbox list to a searchable multi-select dropdown, making it much easier to work with long lists of iterations.
18+
### Status Exclusion Filter (`~done`)
19+
The CLI's negation syntax (`-s ~done`) now works end-to-end. A new `status_id_not` query parameter has been added to the items API, enabling server-side exclusion of a specific status.
2020

21-
### Resizable Collections Sidebar
22-
The collections sidebar can now be resized by dragging a handle on its edge, giving you control over how much screen space it occupies.
21+
### Completed Statuses Endpoint
22+
A new `GET /rest/api/v1/workspaces/{id}/statuses/completed` endpoint returns only statuses where the category is marked as completed. This powers the CLI's fallback resolution and is available for any integration that needs to identify "done" statuses programmatically.
2323

24-
### Workspace Key Cache
25-
Workspace key resolution no longer requires a database lookup on every request. Keys are now cached in memory, improving response times for all workspace-scoped API calls.
24+
### `ws config refresh` Command
25+
A new `ws config refresh` subcommand re-fetches workspace statuses from the server and regenerates status aliases with numeric IDs in `ws.toml`. Use this after renaming statuses on the server to keep your local aliases in sync.
2626

2727
---
2828

2929
## Bug Fixes
3030

31-
- **CQL Iteration Filter:** Fixed iteration `IN` / `NOT IN` queries and corrected user group name resolution in CQL
32-
- **PostgreSQL Notification Settings:** Fixed notification settings queries and the config set admin page failing on PostgreSQL
31+
- **Status filter silently ignored:** `ws task list -s done` now correctly sends numeric status IDs to the server instead of status names that fail `strconv.Atoi` silently
32+
- **Negation filter no-op:** `ws task list -s ~done` now excludes the specified status via the new `status_id_not` server-side filter
33+
- **Stale alias resilience:** If a status is renamed after `ws init`, the CLI falls back to the completed-statuses endpoint to resolve "done" dynamically
3334

34-
## Other Changes
35+
## API Changes
3536

36-
- Split large handler files into smaller, focused modules (planning, Jira importer, SCM, portal, AI)
37+
- Added `status_id_not` query parameter to `GET /rest/api/v1/items` for excluding items by status
38+
- Added `GET /rest/api/v1/workspaces/{id}/statuses/completed` endpoint

cmd/ws/client.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,15 @@ func (c *Client) GetWorkspaceStatuses(workspaceID int) ([]Status, error) {
227227
return statuses, nil
228228
}
229229

230+
// GetCompletedStatuses gets statuses where is_completed = true for a workspace
231+
func (c *Client) GetCompletedStatuses(workspaceID int) ([]Status, error) {
232+
var statuses []Status
233+
if err := c.GET(fmt.Sprintf("/rest/api/v1/workspaces/%d/statuses/completed", workspaceID), &statuses); err != nil {
234+
return nil, err
235+
}
236+
return statuses, nil
237+
}
238+
230239
// ListStatuses lists all statuses
231240
func (c *Client) ListStatuses() ([]Status, error) {
232241
var statuses []Status

cmd/ws/config.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,38 @@ func (c *Config) ResolveStatus(input string) string {
144144
return input
145145
}
146146

147+
// ResolveStatusWithFallback resolves a status input, falling back to the completed-statuses
148+
// endpoint when the alias is non-numeric (stale) or when "done" has no alias.
149+
// Returns comma-separated IDs for completed statuses, or the resolved value.
150+
func (c *Config) ResolveStatusWithFallback(input string, client *Client) string {
151+
resolved := c.ResolveStatus(input)
152+
153+
// If already numeric, use it directly
154+
if _, err := fmt.Sscanf(resolved, "%d", new(int)); err == nil {
155+
return resolved
156+
}
157+
158+
// Non-numeric resolution — try completed-statuses endpoint for "done" alias
159+
if input == "done" || resolved == "done" {
160+
wsKey := c.GetEffectiveWorkspace()
161+
if wsKey == "" {
162+
return resolved
163+
}
164+
wsID, err := client.ResolveWorkspaceID(wsKey)
165+
if err != nil {
166+
return resolved
167+
}
168+
statuses, err := client.GetCompletedStatuses(wsID)
169+
if err != nil || len(statuses) == 0 {
170+
return resolved
171+
}
172+
// Return first completed status ID
173+
return fmt.Sprintf("%d", statuses[0].ID)
174+
}
175+
176+
return resolved
177+
}
178+
147179
// GetEffectiveWorkspace returns the workspace key to use for queries
148180
func (c *Config) GetEffectiveWorkspace() string {
149181
return c.Defaults.WorkspaceKey

cmd/ws/config_cmd.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,66 @@ This shows the merged configuration from all sources:
223223
},
224224
}
225225

226+
var configRefreshCmd = &cobra.Command{
227+
Use: "refresh",
228+
Short: "Refresh status aliases from workspace",
229+
Long: `Re-fetch workspace statuses and regenerate status aliases with numeric IDs.
230+
231+
This is useful when statuses have been renamed on the server or when aliases
232+
contain stale name-based values instead of numeric IDs.
233+
234+
Examples:
235+
ws config refresh # Refresh aliases in ./ws.toml`,
236+
RunE: func(cmd *cobra.Command, args []string) error {
237+
client, err := NewClient()
238+
if err != nil {
239+
return err
240+
}
241+
242+
wsKey := cfg.GetEffectiveWorkspace()
243+
if wsKey == "" {
244+
return fmt.Errorf("workspace is required: use -w flag or set defaults.workspace_key in config")
245+
}
246+
247+
wsID, err := client.ResolveWorkspaceID(wsKey)
248+
if err != nil {
249+
return fmt.Errorf("failed to resolve workspace: %w", err)
250+
}
251+
252+
statuses, err := client.GetWorkspaceStatuses(wsID)
253+
if err != nil {
254+
return fmt.Errorf("failed to get statuses: %w", err)
255+
}
256+
257+
// Regenerate aliases with numeric IDs
258+
cfg.StatusAliases = generateDefaultAliases(statuses)
259+
260+
// Save back to project config
261+
projectConfig := Config{
262+
Server: cfg.Server,
263+
Defaults: cfg.Defaults,
264+
Cache: cfg.Cache,
265+
StatusAliases: cfg.StatusAliases,
266+
}
267+
if err := saveProjectConfig(projectConfig, "./ws.toml"); err != nil {
268+
return fmt.Errorf("failed to save ws.toml: %w", err)
269+
}
270+
271+
fmt.Println("Refreshed status aliases in ws.toml:")
272+
for alias, id := range cfg.StatusAliases {
273+
fmt.Printf(" %s -> %s\n", alias, id)
274+
}
275+
return nil
276+
},
277+
}
278+
226279
var configInitGlobal bool
227280

228281
func init() {
229282
rootCmd.AddCommand(configCmd)
230283
configCmd.AddCommand(configInitCmd)
231284
configCmd.AddCommand(configShowCmd)
285+
configCmd.AddCommand(configRefreshCmd)
232286

233287
configInitCmd.Flags().BoolVar(&configInitGlobal, "global", false, "create global config instead of project config")
234288
}

cmd/ws/init.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -250,38 +250,40 @@ func generateDefaultAliases(statuses []Status) map[string]string {
250250
for _, s := range statuses {
251251
nameLower := strings.ToLower(s.Name)
252252

253+
idStr := fmt.Sprintf("%d", s.ID)
254+
253255
// Map "done" alias
254256
if strings.Contains(nameLower, "done") || strings.Contains(nameLower, "complete") {
255257
if _, exists := aliases["done"]; !exists {
256-
aliases["done"] = s.Name
258+
aliases["done"] = idStr
257259
}
258260
}
259261

260262
// Map "progress" alias
261263
if strings.Contains(nameLower, "progress") || strings.Contains(nameLower, "working") {
262264
if _, exists := aliases["progress"]; !exists {
263-
aliases["progress"] = s.Name
265+
aliases["progress"] = idStr
264266
}
265267
}
266268

267269
// Map "blocked" alias
268270
if strings.Contains(nameLower, "block") || strings.Contains(nameLower, "hold") {
269271
if _, exists := aliases["blocked"]; !exists {
270-
aliases["blocked"] = s.Name
272+
aliases["blocked"] = idStr
271273
}
272274
}
273275

274276
// Map "review" alias
275277
if strings.Contains(nameLower, "review") {
276278
if _, exists := aliases["review"]; !exists {
277-
aliases["review"] = s.Name
279+
aliases["review"] = idStr
278280
}
279281
}
280282

281283
// Map "todo" alias
282284
if strings.Contains(nameLower, "open") || strings.Contains(nameLower, "new") || strings.Contains(nameLower, "todo") {
283285
if _, exists := aliases["todo"]; !exists {
284-
aliases["todo"] = s.Name
286+
aliases["todo"] = idStr
285287
}
286288
}
287289
}

cmd/ws/task.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ Examples:
5353
// Add optional filters from flags
5454
if statusFilter != "" {
5555
if isNegatedFilter(statusFilter) {
56-
resolved := cfg.ResolveStatus(stripNegation(statusFilter))
56+
resolved := cfg.ResolveStatusWithFallback(stripNegation(statusFilter), client)
5757
filters["status_id_not"] = resolved
5858
} else {
59-
resolved := cfg.ResolveStatus(statusFilter)
59+
resolved := cfg.ResolveStatusWithFallback(statusFilter, client)
6060
filters["status_id"] = resolved
6161
}
6262
}
@@ -174,10 +174,10 @@ Examples:
174174
if statusFilter != "" {
175175
if isNegatedFilter(statusFilter) {
176176
// Negation: exclude this status
177-
resolved := cfg.ResolveStatus(stripNegation(statusFilter))
177+
resolved := cfg.ResolveStatusWithFallback(stripNegation(statusFilter), client)
178178
filters["status_id_not"] = resolved
179179
} else {
180-
resolved := cfg.ResolveStatus(statusFilter)
180+
resolved := cfg.ResolveStatusWithFallback(statusFilter, client)
181181
filters["status_id"] = resolved
182182
}
183183
}

frontend/src/version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"code": "v0.4.5", "name": "Early Outfitting"}
1+
{"code": "v0.4.6", "name": "Early Outfitting"}

internal/repository/item_list.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type ItemListParams struct {
2323
type ItemFilters struct {
2424
WorkspaceID *int
2525
StatusID *int
26+
StatusIDNot *int
2627
PriorityID *int
2728
AssigneeID *int
2829
CreatorID *int
@@ -193,6 +194,11 @@ func (r *ItemRepository) buildWhereClause(params ItemListParams) (whereClause st
193194
args = append(args, *params.Filters.StatusID)
194195
}
195196

197+
if params.Filters.StatusIDNot != nil {
198+
whereClause += " AND i.status_id != ?"
199+
args = append(args, *params.Filters.StatusIDNot)
200+
}
201+
196202
if params.Filters.PriorityID != nil {
197203
whereClause += " AND i.priority_id = ?"
198204
args = append(args, *params.Filters.PriorityID)

internal/restapi/v1/handlers/items.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ func (h *ItemHandler) List(w http.ResponseWriter, r *http.Request) {
118118
filters.StatusID = &id
119119
}
120120
}
121+
if statusIDNot := r.URL.Query().Get("status_id_not"); statusIDNot != "" {
122+
if id, parseErr := strconv.Atoi(statusIDNot); parseErr == nil {
123+
filters.StatusIDNot = &id
124+
}
125+
}
121126
if priorityID := r.URL.Query().Get("priority_id"); priorityID != "" {
122127
if id, parseErr := strconv.Atoi(priorityID); parseErr == nil {
123128
filters.PriorityID = &id

internal/restapi/v1/handlers/workspaces.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,52 @@ func (h *WorkspaceHandler) GetStatuses(w http.ResponseWriter, r *http.Request) {
390390
restapi.RespondOK(w, result)
391391
}
392392

393+
// ListCompletedStatuses handles GET /rest/api/v1/workspaces/{id}/statuses/completed
394+
func (h *WorkspaceHandler) ListCompletedStatuses(w http.ResponseWriter, r *http.Request) {
395+
user, ok := requireAuth(w, r)
396+
if !ok {
397+
return
398+
}
399+
400+
wsID, ok := parsePathID(w, r, "id", "workspace ID")
401+
if !ok {
402+
return
403+
}
404+
405+
canView, _ := h.perms.CanViewWorkspace(user.ID, wsID)
406+
if !canView {
407+
restapi.RespondError(w, r, restapi.ErrWorkspaceNotFound)
408+
return
409+
}
410+
411+
statuses, err := h.workspaceService.GetStatuses(wsID)
412+
if err != nil {
413+
restapi.RespondError(w, r, restapi.ErrInternalError)
414+
return
415+
}
416+
417+
// Filter for completed statuses only
418+
var result []dto.StatusSummary
419+
for _, s := range statuses {
420+
if s.IsCompleted {
421+
result = append(result, dto.StatusSummary{
422+
ID: s.ID,
423+
Name: s.Name,
424+
CategoryID: s.CategoryID,
425+
CategoryName: s.CategoryName,
426+
CategoryColor: s.CategoryColor,
427+
IsCompleted: s.IsCompleted,
428+
})
429+
}
430+
}
431+
432+
if result == nil {
433+
result = []dto.StatusSummary{}
434+
}
435+
436+
restapi.RespondOK(w, result)
437+
}
438+
393439
// GetItemTypes handles GET /rest/api/v1/workspaces/{id}/item-types
394440
func (h *WorkspaceHandler) GetItemTypes(w http.ResponseWriter, r *http.Request) {
395441
user, ok := requireAuth(w, r)

0 commit comments

Comments
 (0)