Skip to content

Commit a75dcd2

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents ea0d97b + fbc9fd8 commit a75dcd2

5 files changed

Lines changed: 239 additions & 25 deletions

File tree

internal/wscli/helpers.go

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package wscli
22

33
import (
44
"fmt"
5+
"strconv"
56
"strings"
67
)
78

@@ -146,17 +147,63 @@ func newFiltersWithWorkspace(client *Client, seed map[string]string) (map[string
146147

147148
// applyStatusFilter resolves and adds a status filter (with optional ~negation)
148149
// to the supplied filters map. It is a no-op when statusVal is empty.
149-
func applyStatusFilter(filters map[string]string, statusVal string, client *Client) {
150+
func applyStatusFilter(filters map[string]string, statusVal string, client *Client) error {
150151
if statusVal == "" {
151-
return
152+
return nil
152153
}
154+
key := "status_id"
153155
if isNegatedFilter(statusVal) {
154-
resolved := cfg.ResolveStatusWithFallback(stripNegation(statusVal), client)
155-
filters["status_id_not"] = resolved
156-
} else {
157-
resolved := cfg.ResolveStatusWithFallback(statusVal, client)
158-
filters["status_id"] = resolved
156+
key, statusVal = "status_id_not", stripNegation(statusVal)
159157
}
158+
resolved := cfg.ResolveStatusWithFallback(statusVal, client)
159+
if validStatusIDs(resolved) {
160+
filters[key] = resolved
161+
return nil
162+
}
163+
workspaceID, err := strconv.Atoi(filters["workspace_id"])
164+
if err != nil || workspaceID <= 0 {
165+
return fmt.Errorf("status names require a workspace: use -w, or supply a numeric status ID")
166+
}
167+
statuses, err := client.GetWorkspaceStatuses(workspaceID)
168+
if err != nil {
169+
return fmt.Errorf("resolve status: %w", err)
170+
}
171+
var ids, names []string
172+
for _, status := range statuses {
173+
names = append(names, status.Name)
174+
if strings.EqualFold(status.Name, resolved) {
175+
ids = append(ids, strconv.Itoa(status.ID))
176+
}
177+
}
178+
if len(ids) == 0 {
179+
return fmt.Errorf("unknown status %q; valid statuses: %s", statusVal, strings.Join(names, ", "))
180+
}
181+
filters[key] = strings.Join(ids, ",")
182+
return nil
183+
}
184+
185+
func validStatusIDs(value string) bool {
186+
for _, part := range strings.Split(value, ",") {
187+
id, err := strconv.Atoi(strings.TrimSpace(part))
188+
if err != nil || id <= 0 {
189+
return false
190+
}
191+
}
192+
return true
193+
}
194+
195+
func resolveParentID(client *Client, value string) (int, error) {
196+
if value == "0" {
197+
return 0, nil
198+
}
199+
id, err := client.ResolveItemID(value)
200+
if err != nil {
201+
return 0, fmt.Errorf("resolve parent: %w", err)
202+
}
203+
if id <= 0 {
204+
return 0, fmt.Errorf("parent must be an item key, positive ID, or 0 to clear")
205+
}
206+
return id, nil
160207
}
161208

162209
// WorkspaceContext holds the commonly fetched workspace configuration data.

internal/wscli/models.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,13 @@ type ItemCreateRequest struct {
164164
// carry status_id — status changes go through TransitionRequest on a
165165
// dedicated endpoint so workflow and condition rules are enforced.
166166
type ItemUpdateRequest struct {
167-
Title *string `json:"title,omitempty"`
168-
Description *string `json:"description,omitempty"`
169-
PriorityID *int `json:"priority_id,omitempty"`
170-
ItemTypeID *int `json:"item_type_id,omitempty"`
171-
AssigneeID *int `json:"assignee_id,omitempty"`
172-
ParentID *int `json:"parent_id,omitempty"`
167+
Title *string `json:"title,omitempty"`
168+
Description *string `json:"description,omitempty"`
169+
PriorityID *int `json:"priority_id,omitempty"`
170+
ItemTypeID *int `json:"item_type_id,omitempty"`
171+
AssigneeID *int `json:"assignee_id,omitempty"`
172+
// nil omits the field; a pointer to nil clears it with JSON null.
173+
ParentID **int `json:"parent_id,omitempty"`
173174
MilestoneIDs *[]int `json:"milestone_ids,omitempty"`
174175
IterationID *int `json:"iteration_id,omitempty"`
175176
ProjectID *int `json:"project_id,omitempty"`

internal/wscli/output.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ func (o *Output) printJSON(data any) {
3737
}
3838

3939
func (o *Output) printTable(data any) {
40+
if items, ok := data.(*PaginatedResponse[Item]); ok {
41+
warnItemPagination(items)
42+
}
4043
w := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0)
4144
defer func() { _ = w.Flush() }() //nolint:errcheck // output to stdout
4245

@@ -144,6 +147,9 @@ func (o *Output) printTable(data any) {
144147
}
145148

146149
func (o *Output) printCSV(data any) {
150+
if items, ok := data.(*PaginatedResponse[Item]); ok {
151+
warnItemPagination(items)
152+
}
147153
w := csv.NewWriter(stdout)
148154
defer w.Flush()
149155

internal/wscli/task.go

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ Examples:
7373
return err
7474
}
7575

76-
applyStatusFilter(filters, statusFilter, client)
76+
if err := applyStatusFilter(filters, statusFilter, client); err != nil {
77+
return err
78+
}
7779

7880
// Add date filters
7981
if err := applyDateFilters(filters, createdFilter, updatedFilter); err != nil {
@@ -130,7 +132,11 @@ var taskListCmd = &cobra.Command{
130132
Long: `List tasks with optional filtering.
131133
132134
Examples:
133-
ws task ls # List all accessible tasks
135+
ws task ls # First page of accessible tasks
136+
ws task ls --all # Load all matching pages
137+
ws task ls --page 2 --limit 25 # Select a page
138+
ws task ls -w PROJ -s "In Progress" # Workspace status name
139+
ws task ls -w PROJ --milestone "Q4" # Exact milestone name or ID
134140
ws task ls -s 1 # Filter by status ID
135141
ws task ls -s ~done # Exclude done status (negation)
136142
ws task ls --assignee 5 # Filter by assignee ID
@@ -148,7 +154,23 @@ Examples:
148154
return err
149155
}
150156

151-
applyStatusFilter(filters, statusFilter, client)
157+
if err := applyStatusFilter(filters, statusFilter, client); err != nil {
158+
return err
159+
}
160+
if taskListMilestone != "" {
161+
workspaceID, err := resolveOptionalWorkspace(client)
162+
if err != nil {
163+
return err
164+
}
165+
id, err := resolveTaskMilestone(client, taskListMilestone, workspaceID)
166+
if err != nil {
167+
return err
168+
}
169+
if id <= 0 {
170+
return fmt.Errorf("milestone ID must be positive")
171+
}
172+
filters["milestone_id"] = strconv.Itoa(id)
173+
}
152174

153175
if assigneeFilter != "" {
154176
filters["assignee_id"] = assigneeFilter
@@ -165,7 +187,7 @@ Examples:
165187
return err
166188
}
167189

168-
items, err := client.ListItems(filters)
190+
items, err := listTaskPage(client, filters, taskListPage, taskListLimit, taskListAll)
169191
if err != nil {
170192
return fmt.Errorf("failed to list items: %w", err)
171193
}
@@ -308,8 +330,14 @@ Examples:
308330
if createAssigneeID > 0 {
309331
req.AssigneeID = &createAssigneeID
310332
}
311-
if createParentID > 0 {
312-
req.ParentID = &createParentID
333+
if cmd.Flags().Changed("parent") {
334+
parentID, err := resolveParentID(client, createParent)
335+
if err != nil {
336+
return err
337+
}
338+
if parentID > 0 {
339+
req.ParentID = &parentID
340+
}
313341
}
314342
if createDueDate != "" {
315343
d, err := parseDateFlag("due-date", createDueDate)
@@ -646,7 +674,9 @@ Examples:
646674
return err
647675
}
648676

649-
applyStatusFilter(filters, childStatusFilter, client)
677+
if err := applyStatusFilter(filters, childStatusFilter, client); err != nil {
678+
return err
679+
}
650680
if childTypeFilter != "" {
651681
filters["item_type_id"] = childTypeFilter
652682
}
@@ -766,7 +796,15 @@ Examples:
766796
hasChanges = true
767797
}
768798
if cmd.Flags().Changed("parent") {
769-
req.ParentID = &editParentID
799+
parentID, err := resolveParentID(client, editParent)
800+
if err != nil {
801+
return err
802+
}
803+
var parent *int
804+
if parentID > 0 {
805+
parent = &parentID
806+
}
807+
req.ParentID = &parent
770808
hasChanges = true
771809
}
772810
if cmd.Flags().Changed("due-date") {
@@ -1054,7 +1092,7 @@ var (
10541092
createPriorityID int
10551093
createStatusID int
10561094
createAssigneeID int
1057-
createParentID int
1095+
createParent string
10581096
createDueDate string
10591097
createStartDate string
10601098
createEndDate string
@@ -1068,7 +1106,7 @@ var (
10681106
editTypeStatusID int
10691107
editPriorityID int
10701108
editAssigneeID int
1071-
editParentID int
1109+
editParent string
10721110
editDueDate string
10731111
editStartDate string
10741112
editEndDate string
@@ -1093,6 +1131,11 @@ func init() {
10931131
taskCmd.AddCommand(taskHistoryCmd)
10941132

10951133
// List filters
1134+
taskListCmd.Flags().IntVar(&taskListPage, "page", 1, "result page (starts at 1)")
1135+
taskListCmd.Flags().IntVar(&taskListLimit, "limit", 50, "results per page (1-100)")
1136+
taskListCmd.Flags().BoolVar(&taskListAll, "all", false, "fetch every result page")
1137+
taskListCmd.Flags().StringVar(&taskListMilestone, "milestone", "", "filter by milestone name or ID")
1138+
taskListCmd.MarkFlagsMutuallyExclusive("all", "page")
10961139
taskMineCmd.Flags().StringVarP(&statusFilter, "status", "s", "", "filter by status (use ~status to exclude)")
10971140
taskMineCmd.Flags().StringVar(&createdFilter, "created", "", "filter by creation date (today, week, month, year, or -Nd)")
10981141
taskMineCmd.Flags().StringVar(&updatedFilter, "updated", "", "filter by update date (today, week, month, year, or -Nd)")
@@ -1128,7 +1171,7 @@ func init() {
11281171
taskEditCmd.Flags().IntVar(&editTypeStatusID, "type-status", 0, "target status ID when changing to a type with a different workflow")
11291172
taskEditCmd.Flags().IntVar(&editPriorityID, "priority", 0, "priority ID")
11301173
taskEditCmd.Flags().IntVar(&editAssigneeID, "assignee", 0, "assignee user ID")
1131-
taskEditCmd.Flags().IntVar(&editParentID, "parent", 0, "parent item ID")
1174+
taskEditCmd.Flags().StringVar(&editParent, "parent", "", "parent item key or ID (0 clears parent)")
11321175
taskEditCmd.Flags().StringVar(&editDueDate, "due-date", "", "due date (YYYY-MM-DD)")
11331176
taskEditCmd.Flags().StringVar(&editStartDate, "start-date", "", "start date (YYYY-MM-DD)")
11341177
taskEditCmd.Flags().StringVar(&editEndDate, "end-date", "", "end date (YYYY-MM-DD)")
@@ -1144,7 +1187,7 @@ func init() {
11441187
taskCreateCmd.Flags().IntVar(&createPriorityID, "priority", 0, "priority ID")
11451188
taskCreateCmd.Flags().IntVar(&createStatusID, "status", 0, "status ID")
11461189
taskCreateCmd.Flags().IntVar(&createAssigneeID, "assignee", 0, "assignee user ID")
1147-
taskCreateCmd.Flags().IntVar(&createParentID, "parent", 0, "parent item ID")
1190+
taskCreateCmd.Flags().StringVar(&createParent, "parent", "", "parent item key or ID")
11481191
taskCreateCmd.Flags().StringVar(&createDueDate, "due-date", "", "due date (YYYY-MM-DD)")
11491192
taskCreateCmd.Flags().StringVar(&createStartDate, "start-date", "", "start date (YYYY-MM-DD)")
11501193
taskCreateCmd.Flags().StringVar(&createEndDate, "end-date", "", "end date (YYYY-MM-DD)")

internal/wscli/task_pagination.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package wscli
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
)
8+
9+
var (
10+
taskListPage int
11+
taskListLimit int
12+
taskListAll bool
13+
taskListMilestone string
14+
)
15+
16+
func warnItemPagination(items *PaginatedResponse[Item]) {
17+
if items == nil {
18+
return
19+
}
20+
total := items.Pagination.TotalItems
21+
if total == 0 {
22+
total = items.Pagination.Total
23+
}
24+
if total == 0 {
25+
total = items.Total
26+
}
27+
if total > len(items.Data) {
28+
_, _ = fmt.Fprintf(stderr, "Showing %d of %d items (page %d/%d); task ls supports --page/--limit and --all (reapply the relevant filters).\n", len(items.Data), total, items.Pagination.Page, items.Pagination.TotalPages)
29+
}
30+
}
31+
32+
func listTaskPage(client *Client, filters map[string]string, page, limit int, all bool) (*PaginatedResponse[Item], error) {
33+
if page < 1 || limit < 1 || limit > 100 {
34+
return nil, fmt.Errorf("--page must be positive and --limit must be between 1 and 100")
35+
}
36+
params := make(map[string]string, len(filters)+2)
37+
for key, value := range filters {
38+
params[key] = value
39+
}
40+
params["page_size"] = strconv.Itoa(limit)
41+
var combined []Item
42+
seen := make(map[int]bool)
43+
for {
44+
params["page"] = strconv.Itoa(page)
45+
response, err := client.ListItems(params)
46+
if err != nil {
47+
return nil, err
48+
}
49+
if response.Pagination.Page != page || response.Pagination.TotalPages < 0 ||
50+
(response.Pagination.TotalPages == 0 && (len(response.Data) > 0 || response.Pagination.TotalItems > 0)) {
51+
return nil, fmt.Errorf("invalid pagination returned for page %d", page)
52+
}
53+
if !all {
54+
return response, nil
55+
}
56+
before := len(combined)
57+
for _, item := range response.Data {
58+
if !seen[item.ID] {
59+
seen[item.ID] = true
60+
combined = append(combined, item)
61+
}
62+
}
63+
if page >= response.Pagination.TotalPages {
64+
if len(combined) != response.Pagination.TotalItems {
65+
return nil, fmt.Errorf("incomplete pagination: received %d of %d items; retry the query", len(combined), response.Pagination.TotalItems)
66+
}
67+
if combined == nil {
68+
combined = []Item{}
69+
}
70+
return &PaginatedResponse[Item]{Data: combined, Pagination: PaginationMeta{Page: 1, PageSize: len(combined), TotalItems: len(combined), TotalPages: 1}}, nil
71+
}
72+
if len(combined) == before {
73+
return nil, fmt.Errorf("pagination made no progress on page %d; retry the query", page)
74+
}
75+
page++
76+
}
77+
}
78+
79+
// A name must identify exactly one milestone in the selected workspace.
80+
// Never turn a misspelled filter into an unfiltered or fuzzy-matched list.
81+
func resolveTaskMilestone(client *Client, value string, workspaceID *int) (int, error) {
82+
if id, err := strconv.Atoi(value); err == nil {
83+
return id, nil
84+
}
85+
if workspaceID == nil {
86+
return 0, fmt.Errorf("milestone names require a workspace: use -w, or supply a numeric milestone ID")
87+
}
88+
match := 0
89+
for page := 1; ; page++ {
90+
response, err := client.ListMilestonesInWorkspace(*workspaceID, map[string]string{"page": strconv.Itoa(page), "page_size": "100"})
91+
if err != nil {
92+
return 0, fmt.Errorf("resolve milestone: %w", err)
93+
}
94+
if response.Pagination.Page != page || response.Pagination.TotalPages < 0 ||
95+
(response.Pagination.TotalPages == 0 && (len(response.Data) > 0 || response.Pagination.TotalItems > 0)) {
96+
return 0, fmt.Errorf("invalid milestone pagination returned for page %d", page)
97+
}
98+
for _, milestone := range response.Data {
99+
if strings.EqualFold(milestone.Name, value) {
100+
if match != 0 && match != milestone.ID {
101+
return 0, fmt.Errorf("milestone name %q is ambiguous; use its numeric ID", value)
102+
}
103+
match = milestone.ID
104+
}
105+
}
106+
if page >= response.Pagination.TotalPages {
107+
break
108+
}
109+
if len(response.Data) == 0 {
110+
return 0, fmt.Errorf("milestone pagination made no progress on page %d", page)
111+
}
112+
}
113+
if match == 0 {
114+
return 0, fmt.Errorf("milestone not found: %s", value)
115+
}
116+
return match, nil
117+
}

0 commit comments

Comments
 (0)