From 0fc3d7b4cb18f5b41f7a17612551aded37989895 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Tue, 6 Jan 2026 10:12:12 +0000 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20=E2=9C=A8add=20work=20item=20list?= =?UTF-8?q?=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `workitem list` subcommand to the boards command group for querying and displaying work items with extensive filtering capabilities. The command supports filtering by: - Status (open, closed, resolved, all) - Work item types - Assigned to (including @me alias) - Severity classification - Priority - Area and iteration paths (with subtree support) - Result limiting --- internal/cmd/boards/boards.go | 2 + internal/cmd/boards/workitem/list/list.go | 776 ++++++++++++++++++++++ internal/cmd/boards/workitem/workitem.go | 25 + 3 files changed, 803 insertions(+) create mode 100644 internal/cmd/boards/workitem/list/list.go create mode 100644 internal/cmd/boards/workitem/workitem.go diff --git a/internal/cmd/boards/boards.go b/internal/cmd/boards/boards.go index 9e5a45af..3d3deecb 100644 --- a/internal/cmd/boards/boards.go +++ b/internal/cmd/boards/boards.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/boards/area" "github.com/tmeckel/azdo-cli/internal/cmd/boards/iteration" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -25,6 +26,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(area.NewCmd(ctx)) cmd.AddCommand(iteration.NewCmd(ctx)) + cmd.AddCommand(workitem.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/boards/workitem/list/list.go b/internal/cmd/boards/workitem/list/list.go new file mode 100644 index 00000000..b22df6a3 --- /dev/null +++ b/internal/cmd/boards/workitem/list/list.go @@ -0,0 +1,776 @@ +package list + +import ( + "fmt" + "slices" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type listOptions struct { + scopeArg string + + status []string + workItemTypes []string + assignedTo []string + classification []string + priority []int + area []string + iteration []string + limit int + + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &listOptions{ + status: []string{"open"}, + limit: 50, + } + + cmd := &cobra.Command{ + Use: "list [ORGANIZATION/]PROJECT", + Short: "List work items belonging to a project.", + Long: heredoc.Doc(` + List work items belonging to a project within an Azure DevOps organization. + + This command builds and runs a WIQL query to obtain work item IDs and then fetches the + work item details in batches. + `), + Example: heredoc.Doc(` + # List open work items for a project in the default organization + azdo boards work-item list Fabrikam + + # List all work items assigned to you + azdo boards work-item list Fabrikam --assigned-to @me --status all + + # Filter by work item type and priority + azdo boards work-item list Fabrikam --type "User Story" --priority 1 --priority 2 + + # Filter by area subtree + azdo boards work-item list Fabrikam --area Under:Web/Payments + + # Export JSON + azdo boards work-item list Fabrikam --json id,fields + `), + Aliases: []string{"ls", "l"}, + Args: util.ExactArgs(1, "project argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + return runList(ctx, opts) + }, + } + + cmd.Flags().StringSliceVarP(&opts.status, "status", "s", opts.status, "Filter by state category: open, closed, resolved, all (repeatable)") + cmd.Flags().StringSliceVarP(&opts.workItemTypes, "type", "T", nil, "Filter by work item type (repeatable)") + cmd.Flags().StringSliceVarP(&opts.assignedTo, "assigned-to", "a", nil, "Filter by assigned-to identity (repeatable); supports emails, descriptors, and @me") + cmd.Flags().StringSliceVarP(&opts.classification, "classification", "c", nil, "Filter by severity classification (repeatable): 1 - Critical, 2 - High, 3 - Medium, 4 - Low") + cmd.Flags().IntSliceVarP(&opts.priority, "priority", "p", nil, "Filter by priority (repeatable): 1-4") + cmd.Flags().StringSliceVar(&opts.area, "area", nil, "Filter by area path (repeatable); prefix with Under: to include subtree (e.g., Under:Web/Payments)") + cmd.Flags().StringSliceVar(&opts.iteration, "iteration", nil, "Filter by iteration path (repeatable); prefix with Under: to include subtree (e.g., Under:Release 2025/Sprint 1)") + cmd.Flags().IntVarP(&opts.limit, "limit", "L", opts.limit, "Maximum number of results to return (>=1)") + + util.AddJSONFlags(cmd, &opts.exporter, []string{"url", "_links", "commentVersionRef", "fields", "id", "relations", "rev"}) + + return cmd +} + +func runList(ctx util.CmdContext, opts *listOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if opts.limit < 1 { + return util.FlagErrorf("invalid value for --limit: %v", opts.limit) + } + + if err := validateClassification(opts.classification); err != nil { + return err + } + if err := validatePriority(opts.priority); err != nil { + return err + } + if err := validateUnderPaths("--area", opts.area); err != nil { + return err + } + if err := validateUnderPaths("--iteration", opts.iteration); err != nil { + return err + } + + scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + witClient, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create work item tracking client: %w", err) + } + + assignedToFilter, err := resolveAssignedToFilter(ctx, scope.Organization, opts.assignedTo) + if err != nil { + return err + } + + statusPredicate, err := resolveStatePredicate(ctx, witClient, scope.Project, opts.status, opts.workItemTypes) + if err != nil { + return err + } + + query := buildWiqlQuery(scope.Project, statusPredicate, opts.workItemTypes, assignedToFilter, opts.classification, opts.priority, opts.area, opts.iteration) + + zap.L().Debug("querying work items via WIQL", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.Int("limit", opts.limit), + ) + + top := opts.limit + result, err := witClient.QueryByWiql(ctx.Context(), workitemtracking.QueryByWiqlArgs{ + Wiql: &workitemtracking.Wiql{Query: &query}, + Project: &scope.Project, + Top: &top, + }) + if err != nil { + return fmt.Errorf("failed to execute WIQL query: %w", err) + } + + ids := extractWorkItemIDs(result) + if len(ids) == 0 { + return util.NewNoResultsError("no work items matched the provided filters") + } + if len(ids) > opts.limit { + ids = ids[:opts.limit] + } + + workItems, err := fetchWorkItems(ctx, witClient, scope.Project, ids, result.AsOf, opts.exporter != nil) + if err != nil { + return err + } + if len(workItems) == 0 { + return util.NewNoResultsError("no work items matched the provided filters") + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, workItems) + } + + tp, err := ctx.Printer("table") + if err != nil { + return err + } + + tp.AddColumns("ID", "TYPE", "STATE", "TITLE", "ASSIGNED TO", "AREA", "ITERATION") + for _, wi := range workItems { + fields := types.GetValue(wi.Fields, map[string]any{}) + tp.AddField(strconv.Itoa(types.GetValue(wi.Id, 0))) + tp.AddField(fieldString(fields, "System.WorkItemType")) + tp.AddField(fieldString(fields, "System.State")) + tp.AddField(fieldString(fields, "System.Title")) + tp.AddField(fieldIdentityDisplay(fields, "System.AssignedTo")) + tp.AddField(fieldString(fields, "System.AreaPath")) + tp.AddField(fieldString(fields, "System.IterationPath")) + tp.EndRow() + } + + return tp.Render() +} + +func buildStateCategoryPredicate(raw []string) (string, error) { + // Deprecated: WIQL does not support filtering on [System.StateCategory] in all organizations. + // This helper is kept for compatibility with older iterations but should not be used. + if len(raw) == 0 { + raw = []string{"open"} + } + + normalized := make([]string, 0, len(raw)) + for _, v := range raw { + v = strings.TrimSpace(v) + if v == "" { + continue + } + normalized = append(normalized, strings.ToLower(v)) + } + if len(normalized) == 0 { + normalized = []string{"open"} + } + + if slices.Contains(normalized, "all") { + return "", nil + } + + categories := make([]string, 0) + for _, s := range normalized { + switch s { + case "open": + categories = append(categories, "Proposed", "InProgress") + case "closed": + categories = append(categories, "Completed", "Removed") + case "resolved": + categories = append(categories, "Resolved") + default: + return "", util.FlagErrorf("invalid value for --status: %q (valid: open, closed, resolved, all)", s) + } + } + + categories = types.UniqueComparable(categories, strings.ToLower) + return fmt.Sprintf("[System.StateCategory] IN (%s)", wiqlQuoteList(categories)), nil +} + +func resolveStatePredicate(ctx util.CmdContext, client workitemtracking.Client, project string, rawStatus []string, rawTypes []string) (string, error) { + statuses := normalizeStatuses(rawStatus) + if slices.Contains(statuses, "all") { + return "", nil + } + + wantedCategories := make([]string, 0) + for _, s := range statuses { + switch s { + case "open": + wantedCategories = append(wantedCategories, "Proposed", "InProgress") + case "closed": + wantedCategories = append(wantedCategories, "Completed", "Removed") + case "resolved": + wantedCategories = append(wantedCategories, "Resolved") + default: + return "", util.FlagErrorf("invalid value for --status: %q (valid: open, closed, resolved, all)", s) + } + } + wantedCategories = types.UniqueComparable(wantedCategories, strings.ToLower) + + stateNames := make([]string, 0) + + trimmedTypes := trimStrings(rawTypes) + if len(trimmedTypes) > 0 { + for _, typeName := range trimmedTypes { + localType := typeName + states, err := client.GetWorkItemTypeStates(ctx.Context(), workitemtracking.GetWorkItemTypeStatesArgs{ + Project: &project, + Type: &localType, + }) + if err != nil { + return "", fmt.Errorf("failed to resolve states for work item type %q: %w", localType, err) + } + stateNames = append(stateNames, collectStateNamesByCategory(states, wantedCategories)...) + } + } else { + typesList, err := client.GetWorkItemTypes(ctx.Context(), workitemtracking.GetWorkItemTypesArgs{ + Project: &project, + }) + if err != nil { + return "", fmt.Errorf("failed to list work item types: %w", err) + } + if typesList == nil || len(*typesList) == 0 { + return "", util.FlagErrorf("no work item types are available to resolve --status") + } + + for _, t := range *typesList { + if types.GetValue(t.IsDisabled, false) { + continue + } + + if t.States != nil { + stateNames = append(stateNames, collectStateNamesByCategory(t.States, wantedCategories)...) + continue + } + + typeName := types.GetValue(t.Name, "") + if typeName == "" { + continue + } + localType := typeName + states, err := client.GetWorkItemTypeStates(ctx.Context(), workitemtracking.GetWorkItemTypeStatesArgs{ + Project: &project, + Type: &localType, + }) + if err != nil { + return "", fmt.Errorf("failed to resolve states for work item type %q: %w", localType, err) + } + stateNames = append(stateNames, collectStateNamesByCategory(states, wantedCategories)...) + } + } + + stateNames = types.UniqueComparable(stateNames, strings.ToLower) + if len(stateNames) == 0 { + return "", util.FlagErrorf("no states matched --status filters for the selected work item types") + } + + return fmt.Sprintf("[System.State] IN (%s)", wiqlQuoteList(stateNames)), nil +} + +func collectStateNamesByCategory(states *[]workitemtracking.WorkItemStateColor, wantedCategories []string) []string { + if states == nil || len(*states) == 0 { + return nil + } + + matched := make([]string, 0, len(*states)) + for _, st := range *states { + category := types.GetValue(st.Category, "") + name := types.GetValue(st.Name, "") + if category == "" || name == "" { + continue + } + if containsFold(wantedCategories, category) { + matched = append(matched, name) + } + } + return matched +} + +func normalizeStatuses(raw []string) []string { + if len(raw) == 0 { + raw = []string{"open"} + } + + normalized := make([]string, 0, len(raw)) + for _, v := range raw { + v = strings.TrimSpace(v) + if v == "" { + continue + } + normalized = append(normalized, strings.ToLower(v)) + } + if len(normalized) == 0 { + normalized = []string{"open"} + } + + return types.UniqueComparable(normalized, strings.ToLower) +} + +func containsFold(haystack []string, needle string) bool { + needleCanon := canonCategory(needle) + for _, v := range haystack { + if canonCategory(v) == needleCanon { + return true + } + } + return false +} + +func canonCategory(raw string) string { + raw = strings.TrimSpace(raw) + raw = strings.ReplaceAll(raw, " ", "") + return strings.ToLower(raw) +} + +func validateClassification(values []string) error { + allowed := map[string]struct{}{ + "1 - Critical": {}, + "2 - High": {}, + "3 - Medium": {}, + "4 - Low": {}, + } + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := allowed[v]; !ok { + return util.FlagErrorf("invalid value for --classification: %q", v) + } + } + return nil +} + +func validatePriority(values []int) error { + for _, v := range values { + if v < 1 || v > 4 { + return util.FlagErrorf("invalid value for --priority: %d (valid: 1-4)", v) + } + } + return nil +} + +func validateUnderPaths(flag string, values []string) error { + for _, raw := range values { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + path := strings.TrimPrefix(raw, "Under:") + path = strings.TrimSpace(path) + if path == "" { + return util.FlagErrorf("%s value %q is invalid; path must not be empty", flag, raw) + } + } + return nil +} + +func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedTo []string) ([]string, error) { + if len(assignedTo) == 0 { + return nil, nil + } + + extensionsClient, err := ctx.ClientFactory().Extensions(ctx.Context(), organization) + if err != nil { + return nil, fmt.Errorf("failed to create Extensions client: %w", err) + } + + identityClient, err := ctx.ClientFactory().Identity(ctx.Context(), organization) + if err != nil { + return nil, fmt.Errorf("failed to create Identity client: %w", err) + } + + resolved := make([]string, 0, len(assignedTo)) + for _, raw := range assignedTo { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + + // WIQL identity fields accept display names/emails directly. Keep those values unchanged and + // only resolve aliases/descriptors when needed. + if strings.Contains(raw, " ") || strings.Contains(raw, "@") { + resolved = append(resolved, raw) + continue + } + + if strings.EqualFold(raw, "@me") { + selfID, err := extensionsClient.GetSelfID(ctx.Context()) + if err != nil { + return nil, fmt.Errorf("failed to resolve @me identity: %w", err) + } + identityIds := selfID.String() + identities, err := identityClient.ReadIdentities(ctx.Context(), identity.ReadIdentitiesArgs{ + IdentityIds: &identityIds, + QueryMembership: func() *identity.QueryMembership { + qm := identity.QueryMembershipValues.None + return &qm + }(), + }) + if err != nil { + return nil, fmt.Errorf("failed to resolve @me identity details: %w", err) + } + if identities == nil || len(*identities) != 1 { + return nil, fmt.Errorf("failed to resolve @me identity details") + } + value := identityAccountOrDisplay((*identities)[0]) + if value == "" { + return nil, fmt.Errorf("authenticated identity is missing account or display name") + } + resolved = append(resolved, value) + continue + } + + ident, err := extensionsClient.ResolveIdentity(ctx.Context(), raw) + if err != nil { + return nil, err + } + if ident == nil { + return nil, fmt.Errorf("no identity found for %q", raw) + } + value := identityAccountOrDisplay(*ident) + if value == "" { + return nil, fmt.Errorf("resolved identity for %q is missing account or display name", raw) + } + resolved = append(resolved, value) + } + + resolved = types.UniqueComparable(resolved, strings.ToLower) + return resolved, nil +} + +func identityAccountOrDisplay(ident identity.Identity) string { + account := identityAccount(ident.Properties) + if account != "" { + return account + } + return types.GetValue(ident.ProviderDisplayName, "") +} + +func identityAccount(properties any) string { + switch typed := properties.(type) { + case map[string]any: + return extractAccountFromPropertiesMap(typed) + default: + return "" + } +} + +func extractAccountFromPropertiesMap(properties map[string]any) string { + raw, ok := properties["Account"] + if !ok || raw == nil { + return "" + } + + switch typed := raw.(type) { + case map[string]any: + if v, ok := typed["$value"].(string); ok { + return v + } + } + + return "" +} + +func buildWiqlQuery(project string, stateCategoryPredicate string, typesFilter []string, assignedTo []string, severity []string, priority []int, area []string, iteration []string) string { + clauses := make([]string, 0) + clauses = append(clauses, fmt.Sprintf("[System.TeamProject] = %s", wiqlQuote(project))) + + if stateCategoryPredicate != "" { + clauses = append(clauses, stateCategoryPredicate) + } + + if len(typesFilter) > 0 { + trimmed := trimStrings(typesFilter) + if len(trimmed) > 0 { + clauses = append(clauses, fmt.Sprintf("[System.WorkItemType] IN (%s)", wiqlQuoteList(trimmed))) + } + } + + if len(assignedTo) > 0 { + clauses = append(clauses, fmt.Sprintf("[System.AssignedTo] IN (%s)", wiqlQuoteList(assignedTo))) + } + + if len(severity) > 0 { + trimmed := trimStrings(severity) + if len(trimmed) > 0 { + clauses = append(clauses, fmt.Sprintf("[Microsoft.VSTS.Common.Severity] IN (%s)", wiqlQuoteList(trimmed))) + } + } + + if len(priority) > 0 { + clauses = append(clauses, fmt.Sprintf("[Microsoft.VSTS.Common.Priority] IN (%s)", wiqlIntList(priority))) + } + + if len(area) > 0 { + if predicate := buildUnderOrEqualsPredicate("[System.AreaPath]", area); predicate != "" { + clauses = append(clauses, predicate) + } + } + if len(iteration) > 0 { + if predicate := buildUnderOrEqualsPredicate("[System.IterationPath]", iteration); predicate != "" { + clauses = append(clauses, predicate) + } + } + + return fmt.Sprintf("SELECT [System.Id] FROM WorkItems WHERE %s ORDER BY [System.ChangedDate] DESC", strings.Join(clauses, " AND ")) +} + +func buildUnderOrEqualsPredicate(field string, raw []string) string { + parts := make([]string, 0, len(raw)) + for _, v := range raw { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if strings.HasPrefix(v, "Under:") { + path := strings.TrimSpace(strings.TrimPrefix(v, "Under:")) + parts = append(parts, fmt.Sprintf("%s UNDER %s", field, wiqlQuote(path))) + } else { + parts = append(parts, fmt.Sprintf("%s = %s", field, wiqlQuote(v))) + } + } + + if len(parts) == 0 { + return "" + } + if len(parts) == 1 { + return parts[0] + } + return "(" + strings.Join(parts, " OR ") + ")" +} + +func wiqlQuote(raw string) string { + return "'" + strings.ReplaceAll(raw, "'", "''") + "'" +} + +func wiqlQuoteList(values []string) string { + escaped := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + escaped = append(escaped, wiqlQuote(v)) + } + return strings.Join(escaped, ", ") +} + +func wiqlIntList(values []int) string { + parts := make([]string, 0, len(values)) + for _, v := range values { + parts = append(parts, strconv.Itoa(v)) + } + parts = types.UniqueComparable(parts, strings.ToLower) + return strings.Join(parts, ", ") +} + +func trimStrings(values []string) []string { + trimmed := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + trimmed = append(trimmed, v) + } + return types.UniqueComparable(trimmed, strings.ToLower) +} + +func extractWorkItemIDs(result *workitemtracking.WorkItemQueryResult) []int { + if result == nil || result.WorkItems == nil { + return nil + } + workItems := *result.WorkItems + ids := make([]int, 0, len(workItems)) + for _, wi := range workItems { + if wi.Id == nil { + continue + } + ids = append(ids, *wi.Id) + } + return ids +} + +func fetchWorkItems(ctx util.CmdContext, client workitemtracking.Client, project string, ids []int, asOf *azuredevops.Time, includeRelations bool) ([]workitemtracking.WorkItem, error) { + const batchSize = 200 + + if len(ids) == 0 { + return nil, nil + } + + fields := []string{ + "System.Id", + "System.WorkItemType", + "System.State", + "System.Title", + "System.AssignedTo", + "System.AreaPath", + "System.IterationPath", + } + var expand *workitemtracking.WorkItemExpand + var requestFields *[]string + if includeRelations { + e := workitemtracking.WorkItemExpandValues.All + expand = &e + requestFields = nil + } else { + requestFields = &fields + } + + errorPolicy := workitemtracking.WorkItemErrorPolicyValues.Omit + + out := make([]workitemtracking.WorkItem, 0, len(ids)) + for start := 0; start < len(ids); start += batchSize { + end := start + batchSize + if end > len(ids) { + end = len(ids) + } + batch := ids[start:end] + + args := workitemtracking.GetWorkItemsBatchArgs{ + Project: &project, + WorkItemGetRequest: &workitemtracking.WorkItemBatchGetRequest{ + Ids: &batch, + AsOf: asOf, + Fields: requestFields, + Expand: expand, + ErrorPolicy: &errorPolicy, + }, + } + + items, err := client.GetWorkItemsBatch(ctx.Context(), args) + if err != nil { + return nil, fmt.Errorf("failed to fetch work item details: %w", err) + } + if items == nil || len(*items) == 0 { + continue + } + out = append(out, (*items)...) + } + + return orderWorkItemsByIDs(out, ids), nil +} + +func orderWorkItemsByIDs(items []workitemtracking.WorkItem, ids []int) []workitemtracking.WorkItem { + if len(items) == 0 || len(ids) == 0 { + return items + } + + byID := make(map[int]workitemtracking.WorkItem, len(items)) + for _, item := range items { + if item.Id == nil { + continue + } + byID[*item.Id] = item + } + + ordered := make([]workitemtracking.WorkItem, 0, len(items)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if item, ok := byID[id]; ok { + ordered = append(ordered, item) + seen[id] = struct{}{} + } + } + + // Preserve any extra items not in the id list (should not happen, but keeps output stable). + for _, item := range items { + if item.Id == nil { + continue + } + if _, ok := seen[*item.Id]; ok { + continue + } + ordered = append(ordered, item) + } + + return ordered +} + +func fieldString(fields map[string]any, key string) string { + if fields == nil { + return "" + } + v, ok := fields[key] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + default: + return fmt.Sprint(v) + } +} + +func fieldIdentityDisplay(fields map[string]any, key string) string { + if fields == nil { + return "" + } + v, ok := fields[key] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + case map[string]any: + if displayName, ok := t["displayName"].(string); ok { + return displayName + } + if uniqueName, ok := t["uniqueName"].(string); ok { + return uniqueName + } + return fmt.Sprint(v) + default: + return fmt.Sprint(v) + } +} diff --git a/internal/cmd/boards/workitem/workitem.go b/internal/cmd/boards/workitem/workitem.go new file mode 100644 index 00000000..791d1e98 --- /dev/null +++ b/internal/cmd/boards/workitem/workitem.go @@ -0,0 +1,25 @@ +package workitem + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/list" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +// NewCmd wires subcommands for working with Azure Boards work items. +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "work-item ", + Short: "Work with Azure Boards work items.", + Example: heredoc.Doc(` + # List work items in a project + azdo boards work-item list Fabrikam + `), + } + + cmd.AddCommand(list.NewCmd(ctx)) + + return cmd +} + From 2b98f8d66b1a2afdf585b8719c85ad79ef49d73e Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Tue, 6 Jan 2026 10:13:26 +0000 Subject: [PATCH 02/20] =?UTF-8?q?docs:=F0=9F=93=84=20add=20work=20item=20c?= =?UTF-8?q?ommand=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add documentation for the new `azdo boards work-item` command and its `list` subcommand. Includes command reference, examples, and integration with the main boards documentation and help reference. --- docs/azdo_boards.md | 1 + docs/azdo_boards_work-item.md | 18 ++++++ docs/azdo_boards_work-item_list.md | 91 ++++++++++++++++++++++++++++++ docs/azdo_help_reference.md | 28 +++++++++ 4 files changed, 138 insertions(+) create mode 100644 docs/azdo_boards_work-item.md create mode 100644 docs/azdo_boards_work-item_list.md diff --git a/docs/azdo_boards.md b/docs/azdo_boards.md index 4364d7ba..36375418 100644 --- a/docs/azdo_boards.md +++ b/docs/azdo_boards.md @@ -6,6 +6,7 @@ Work with Azure Boards resources. * [azdo boards area](./azdo_boards_area.md) * [azdo boards iteration](./azdo_boards_iteration.md) +* [azdo boards work-item](./azdo_boards_work-item.md) ### ALIASES diff --git a/docs/azdo_boards_work-item.md b/docs/azdo_boards_work-item.md new file mode 100644 index 00000000..72d7f2da --- /dev/null +++ b/docs/azdo_boards_work-item.md @@ -0,0 +1,18 @@ +## Command `azdo boards work-item` + +Work with Azure Boards work items. + +### Available commands + +* [azdo boards work-item list](./azdo_boards_work-item_list.md) + +### Examples + +```bash +# List work items in a project +azdo boards work-item list Fabrikam +``` + +### See also + +* [azdo boards](./azdo_boards.md) diff --git a/docs/azdo_boards_work-item_list.md b/docs/azdo_boards_work-item_list.md new file mode 100644 index 00000000..7eb9739e --- /dev/null +++ b/docs/azdo_boards_work-item_list.md @@ -0,0 +1,91 @@ +## Command `azdo boards work-item list` + +``` +azdo boards work-item list [ORGANIZATION/]PROJECT [flags] +``` + +List work items belonging to a project within an Azure DevOps organization. + +This command builds and runs a WIQL query to obtain work item IDs and then fetches the +work item details in batches. + + +### Options + + +* `--area` `strings` + + Filter by area path (repeatable); prefix with Under: to include subtree (e.g., Under:Web/Payments) + +* `-a`, `--assigned-to` `strings` + + Filter by assigned-to identity (repeatable); supports emails, descriptors, and @me + +* `-c`, `--classification` `strings` + + Filter by severity classification (repeatable): 1 - Critical, 2 - High, 3 - Medium, 4 - Low + +* `--iteration` `strings` + + Filter by iteration path (repeatable); prefix with Under: to include subtree (e.g., Under:Release 2025/Sprint 1) + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `-L`, `--limit` `int` (default `50`) + + Maximum number of results to return (>=1) + +* `-p`, `--priority` `ints` + + Filter by priority (repeatable): 1-4 + +* `-s`, `--status` `strings` (default `[open]`) + + Filter by state category: open, closed, resolved, all (repeatable) + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `-T`, `--type` `strings` + + Filter by work item type (repeatable) + + +### ALIASES + +- `ls` +- `l` + +### JSON Fields + +`_links`, `commentVersionRef`, `fields`, `id`, `relations`, `rev`, `url` + +### Examples + +```bash +# List open work items for a project in the default organization +azdo boards work-item list Fabrikam + +# List all work items assigned to you +azdo boards work-item list Fabrikam --assigned-to @me --status all + +# Filter by work item type and priority +azdo boards work-item list Fabrikam --type "User Story" --priority 1 --priority 2 + +# Filter by area subtree +azdo boards work-item list Fabrikam --area Under:Web/Payments + +# Export JSON +azdo boards work-item list Fabrikam --json id,fields +``` + +### See also + +* [azdo boards work-item](./azdo_boards_work-item.md) diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 9a6070ff..bf9bb0b0 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -122,6 +122,34 @@ Aliases ls, l ``` +### `azdo boards work-item ` + +Work with Azure Boards work items. + +#### `azdo boards work-item list [ORGANIZATION/]PROJECT [flags]` + +List work items belonging to a project. + +``` + --area strings Filter by area path (repeatable); prefix with Under: to include subtree (e.g., Under:Web/Payments) +-a, --assigned-to strings Filter by assigned-to identity (repeatable); supports emails, descriptors, and @me +-c, --classification strings Filter by severity classification (repeatable): 1 - Critical, 2 - High, 3 - Medium, 4 - Low + --iteration strings Filter by iteration path (repeatable); prefix with Under: to include subtree (e.g., Under:Release 2025/Sprint 1) +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. +-L, --limit int Maximum number of results to return (>=1) (default 50) +-p, --priority ints Filter by priority (repeatable): 1-4 +-s, --status strings Filter by state category: open, closed, resolved, all (repeatable) (default [open]) +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +-T, --type strings Filter by work item type (repeatable) +``` + +Aliases + +``` +ls, l +``` + ## `azdo co` Alias for "pr checkout" From ab84130c68e66622b690010f6eb78739c211ff3e Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 7 Jan 2026 22:22:52 +0000 Subject: [PATCH 03/20] refactor: replace slices.CompactFunc with custom uniqueByEq implementation The standard library's slices.CompactFunc was removed in favor of a custom uniqueByEq function because CompactFunc only removes consecutive matching elements This change: - Removes the import of "slices" package - Implements a manual deduplication algorithm that preserves order - Maintains the same public API for Unique, UniqueComparable, and UniqueFunc - Adds comprehensive test coverage for all unique functionality The new implementation handles empty and nil slices consistently and provides the same functionality without external dependencies. --- internal/types/slices.go | 34 +++++++++++++----- internal/types/slices_test.go | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 internal/types/slices_test.go diff --git a/internal/types/slices.go b/internal/types/slices.go index 64073bfe..cd8a6c29 100644 --- a/internal/types/slices.go +++ b/internal/types/slices.go @@ -1,9 +1,5 @@ package types -import ( - "slices" -) - func FilterSlice[T comparable](slice []T, filters ...func(value T, index int) (bool, error)) ([]T, error) { if len(filters) == 0 { return slice, nil @@ -29,21 +25,41 @@ func FilterSlice[T comparable](slice []T, filters ...func(value T, index int) (b } func Unique[T comparable](items []T) []T { - return slices.CompactFunc(items, func(s1 T, s2 T) bool { + return uniqueByEq(items, func(s1 T, s2 T) bool { return s1 == s2 }) } func UniqueComparable[T any, C comparable](items []T, cmp func(T) C) []T { - return slices.CompactFunc(items, func(s1 T, s2 T) bool { + return uniqueByEq(items, func(s1 T, s2 T) bool { return cmp(s1) == cmp(s2) }) } func UniqueFunc[T any](items []T, cmp func(T, T) bool) []T { - return slices.CompactFunc(items, func(s1 T, s2 T) bool { - return cmp(s1, s2) - }) + return uniqueByEq(items, cmp) +} + +func uniqueByEq[T any](items []T, eq func(T, T) bool) []T { + if len(items) == 0 { + return []T{} + } + + unique := make([]T, 0, len(items)) + for _, item := range items { + duplicate := false + for i := range unique { + if eq(unique[i], item) { + duplicate = true + break + } + } + if duplicate { + continue + } + unique = append(unique, item) + } + return unique } func MapSlice[S any, T any](items []S, mapper func(S) T) []T { diff --git a/internal/types/slices_test.go b/internal/types/slices_test.go new file mode 100644 index 00000000..58605bd9 --- /dev/null +++ b/internal/types/slices_test.go @@ -0,0 +1,66 @@ +package types + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUnique_RemovesDuplicatesPreservesOrder(t *testing.T) { + input := []int{3, 1, 3, 2, 1, 2, 3} + assert.Equal(t, []int{3, 1, 2}, Unique(input)) +} + +func TestUnique_Empty(t *testing.T) { + assert.Equal(t, []string{}, Unique([]string{})) +} + +func TestUnique_Nil(t *testing.T) { + assert.Equal(t, []string{}, Unique[string](nil)) +} + +func TestUniqueComparable_CaseInsensitive(t *testing.T) { + input := []string{"Alpha", "beta", "ALPHA", "BETA", "gamma"} + assert.Equal(t, []string{"Alpha", "beta", "gamma"}, UniqueComparable(input, strings.ToLower)) +} + +func TestUniqueComparable_Nil(t *testing.T) { + assert.Equal(t, []string{}, UniqueComparable(nil, strings.ToLower)) +} + +func TestUniqueComparable_UsesKeyFunction(t *testing.T) { + type entry struct { + ID int + Name string + } + + input := []entry{ + {ID: 1, Name: "one"}, + {ID: 2, Name: "two"}, + {ID: 1, Name: "uno"}, + {ID: 3, Name: "three"}, + {ID: 2, Name: "dos"}, + } + + unique := UniqueComparable(input, func(e entry) int { return e.ID }) + assert.Equal(t, []entry{ + {ID: 1, Name: "one"}, + {ID: 2, Name: "two"}, + {ID: 3, Name: "three"}, + }, unique) +} + +func TestUniqueFunc_RemovesDuplicatesUsingComparator(t *testing.T) { + input := []string{"A", "b", "a", "B", "c"} + unique := UniqueFunc(input, func(a, b string) bool { return strings.EqualFold(a, b) }) + assert.Equal(t, []string{"A", "b", "c"}, unique) +} + +func TestUniqueFunc_Empty(t *testing.T) { + assert.Equal(t, []int{}, UniqueFunc([]int{}, func(a, b int) bool { return a == b })) +} + +func TestUniqueFunc_Nil(t *testing.T) { + assert.Equal(t, []int{}, UniqueFunc(nil, func(a, b int) bool { return a == b })) +} From f4108a1e39e2d4ea925180cdabf892e9f7d5223d Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 7 Jan 2026 22:24:43 +0000 Subject: [PATCH 04/20] refactor: simplify work item list logic Extract validation and rendering helpers and simplify state and identity resolution to reduce duplication and unnecessary client creation. Use a map-based approach for requested state categories and append-only helpers for collecting state names. Defer creating Extensions/Identity clients until an identity lookup is required. Use types.Unique for de-duplication and ensure WIQL Top is only set when a positive --limit is provided. BREAKING CHANGE: default value for --limit changed from 50 to 0 (no limit). WIQL Top is only applied when --limit > 0. --- internal/cmd/boards/workitem/list/list.go | 233 ++++++++++------------ 1 file changed, 108 insertions(+), 125 deletions(-) diff --git a/internal/cmd/boards/workitem/list/list.go b/internal/cmd/boards/workitem/list/list.go index b22df6a3..73604051 100644 --- a/internal/cmd/boards/workitem/list/list.go +++ b/internal/cmd/boards/workitem/list/list.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "go.uber.org/zap" + "github.com/tmeckel/azdo-cli/internal/azdo/extensions" "github.com/tmeckel/azdo-cli/internal/cmd/util" "github.com/tmeckel/azdo-cli/internal/types" ) @@ -33,10 +34,7 @@ type listOptions struct { } func NewCmd(ctx util.CmdContext) *cobra.Command { - opts := &listOptions{ - status: []string{"open"}, - limit: 50, - } + opts := &listOptions{} cmd := &cobra.Command{ Use: "list [ORGANIZATION/]PROJECT", @@ -71,14 +69,14 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { }, } - cmd.Flags().StringSliceVarP(&opts.status, "status", "s", opts.status, "Filter by state category: open, closed, resolved, all (repeatable)") + cmd.Flags().StringSliceVarP(&opts.status, "status", "s", []string{"open"}, "Filter by state category: open, closed, resolved, all (repeatable)") cmd.Flags().StringSliceVarP(&opts.workItemTypes, "type", "T", nil, "Filter by work item type (repeatable)") cmd.Flags().StringSliceVarP(&opts.assignedTo, "assigned-to", "a", nil, "Filter by assigned-to identity (repeatable); supports emails, descriptors, and @me") cmd.Flags().StringSliceVarP(&opts.classification, "classification", "c", nil, "Filter by severity classification (repeatable): 1 - Critical, 2 - High, 3 - Medium, 4 - Low") cmd.Flags().IntSliceVarP(&opts.priority, "priority", "p", nil, "Filter by priority (repeatable): 1-4") cmd.Flags().StringSliceVar(&opts.area, "area", nil, "Filter by area path (repeatable); prefix with Under: to include subtree (e.g., Under:Web/Payments)") cmd.Flags().StringSliceVar(&opts.iteration, "iteration", nil, "Filter by iteration path (repeatable); prefix with Under: to include subtree (e.g., Under:Release 2025/Sprint 1)") - cmd.Flags().IntVarP(&opts.limit, "limit", "L", opts.limit, "Maximum number of results to return (>=1)") + cmd.Flags().IntVarP(&opts.limit, "limit", "L", 0, "Maximum number of results to return (>=1)") util.AddJSONFlags(cmd, &opts.exporter, []string{"url", "_links", "commentVersionRef", "fields", "id", "relations", "rev"}) @@ -94,20 +92,7 @@ func runList(ctx util.CmdContext, opts *listOptions) error { ios.StartProgressIndicator() defer ios.StopProgressIndicator() - if opts.limit < 1 { - return util.FlagErrorf("invalid value for --limit: %v", opts.limit) - } - - if err := validateClassification(opts.classification); err != nil { - return err - } - if err := validatePriority(opts.priority); err != nil { - return err - } - if err := validateUnderPaths("--area", opts.area); err != nil { - return err - } - if err := validateUnderPaths("--iteration", opts.iteration); err != nil { + if err := validateListOptions(opts); err != nil { return err } @@ -139,12 +124,16 @@ func runList(ctx util.CmdContext, opts *listOptions) error { zap.Int("limit", opts.limit), ) - top := opts.limit - result, err := witClient.QueryByWiql(ctx.Context(), workitemtracking.QueryByWiqlArgs{ + wiqlQueryArgs := workitemtracking.QueryByWiqlArgs{ Wiql: &workitemtracking.Wiql{Query: &query}, Project: &scope.Project, - Top: &top, - }) + } + + if opts.limit > 0 { + wiqlQueryArgs.Top = &opts.limit + } + + result, err := witClient.QueryByWiql(ctx.Context(), wiqlQueryArgs) if err != nil { return fmt.Errorf("failed to execute WIQL query: %w", err) } @@ -153,9 +142,6 @@ func runList(ctx util.CmdContext, opts *listOptions) error { if len(ids) == 0 { return util.NewNoResultsError("no work items matched the provided filters") } - if len(ids) > opts.limit { - ids = ids[:opts.limit] - } workItems, err := fetchWorkItems(ctx, witClient, scope.Project, ids, result.AsOf, opts.exporter != nil) if err != nil { @@ -171,6 +157,29 @@ func runList(ctx util.CmdContext, opts *listOptions) error { return opts.exporter.Write(ios, workItems) } + return renderWorkItemsTable(ctx, workItems) +} + +func validateListOptions(opts *listOptions) error { + if opts == nil { + return util.FlagErrorf("invalid options") + } + if err := validateClassification(opts.classification); err != nil { + return err + } + if err := validatePriority(opts.priority); err != nil { + return err + } + if err := validateUnderPaths("--area", opts.area); err != nil { + return err + } + if err := validateUnderPaths("--iteration", opts.iteration); err != nil { + return err + } + return nil +} + +func renderWorkItemsTable(ctx util.CmdContext, workItems []workitemtracking.WorkItem) error { tp, err := ctx.Printer("table") if err != nil { return err @@ -192,67 +201,29 @@ func runList(ctx util.CmdContext, opts *listOptions) error { return tp.Render() } -func buildStateCategoryPredicate(raw []string) (string, error) { - // Deprecated: WIQL does not support filtering on [System.StateCategory] in all organizations. - // This helper is kept for compatibility with older iterations but should not be used. - if len(raw) == 0 { - raw = []string{"open"} - } - - normalized := make([]string, 0, len(raw)) - for _, v := range raw { - v = strings.TrimSpace(v) - if v == "" { - continue - } - normalized = append(normalized, strings.ToLower(v)) - } - if len(normalized) == 0 { - normalized = []string{"open"} - } - - if slices.Contains(normalized, "all") { - return "", nil - } - - categories := make([]string, 0) - for _, s := range normalized { - switch s { - case "open": - categories = append(categories, "Proposed", "InProgress") - case "closed": - categories = append(categories, "Completed", "Removed") - case "resolved": - categories = append(categories, "Resolved") - default: - return "", util.FlagErrorf("invalid value for --status: %q (valid: open, closed, resolved, all)", s) - } - } - - categories = types.UniqueComparable(categories, strings.ToLower) - return fmt.Sprintf("[System.StateCategory] IN (%s)", wiqlQuoteList(categories)), nil -} - func resolveStatePredicate(ctx util.CmdContext, client workitemtracking.Client, project string, rawStatus []string, rawTypes []string) (string, error) { statuses := normalizeStatuses(rawStatus) if slices.Contains(statuses, "all") { return "", nil } - wantedCategories := make([]string, 0) + wantedCategories := make(map[string]struct{}) for _, s := range statuses { switch s { case "open": - wantedCategories = append(wantedCategories, "Proposed", "InProgress") + addWantedCategory(wantedCategories, "New") + addWantedCategory(wantedCategories, "Active") + addWantedCategory(wantedCategories, "Proposed") + addWantedCategory(wantedCategories, "InProgress") case "closed": - wantedCategories = append(wantedCategories, "Completed", "Removed") + addWantedCategory(wantedCategories, "Completed") + addWantedCategory(wantedCategories, "Removed") case "resolved": - wantedCategories = append(wantedCategories, "Resolved") + addWantedCategory(wantedCategories, "Resolved") default: return "", util.FlagErrorf("invalid value for --status: %q (valid: open, closed, resolved, all)", s) } } - wantedCategories = types.UniqueComparable(wantedCategories, strings.ToLower) stateNames := make([]string, 0) @@ -267,7 +238,7 @@ func resolveStatePredicate(ctx util.CmdContext, client workitemtracking.Client, if err != nil { return "", fmt.Errorf("failed to resolve states for work item type %q: %w", localType, err) } - stateNames = append(stateNames, collectStateNamesByCategory(states, wantedCategories)...) + appendStateNamesByCategory(&stateNames, states, wantedCategories) } } else { typesList, err := client.GetWorkItemTypes(ctx.Context(), workitemtracking.GetWorkItemTypesArgs{ @@ -286,7 +257,7 @@ func resolveStatePredicate(ctx util.CmdContext, client workitemtracking.Client, } if t.States != nil { - stateNames = append(stateNames, collectStateNamesByCategory(t.States, wantedCategories)...) + appendStateNamesByCategory(&stateNames, t.States, wantedCategories) continue } @@ -302,7 +273,7 @@ func resolveStatePredicate(ctx util.CmdContext, client workitemtracking.Client, if err != nil { return "", fmt.Errorf("failed to resolve states for work item type %q: %w", localType, err) } - stateNames = append(stateNames, collectStateNamesByCategory(states, wantedCategories)...) + appendStateNamesByCategory(&stateNames, states, wantedCategories) } } @@ -314,23 +285,21 @@ func resolveStatePredicate(ctx util.CmdContext, client workitemtracking.Client, return fmt.Sprintf("[System.State] IN (%s)", wiqlQuoteList(stateNames)), nil } -func collectStateNamesByCategory(states *[]workitemtracking.WorkItemStateColor, wantedCategories []string) []string { - if states == nil || len(*states) == 0 { - return nil +func appendStateNamesByCategory(stateNames *[]string, states *[]workitemtracking.WorkItemStateColor, wantedCategories map[string]struct{}) { + if stateNames == nil || states == nil || len(*states) == 0 { + return } - matched := make([]string, 0, len(*states)) for _, st := range *states { category := types.GetValue(st.Category, "") name := types.GetValue(st.Name, "") if category == "" || name == "" { continue } - if containsFold(wantedCategories, category) { - matched = append(matched, name) + if _, ok := wantedCategories[canonCategory(category)]; ok { + *stateNames = append(*stateNames, name) } } - return matched } func normalizeStatuses(raw []string) []string { @@ -350,17 +319,14 @@ func normalizeStatuses(raw []string) []string { normalized = []string{"open"} } - return types.UniqueComparable(normalized, strings.ToLower) + return types.Unique(normalized) } -func containsFold(haystack []string, needle string) bool { - needleCanon := canonCategory(needle) - for _, v := range haystack { - if canonCategory(v) == needleCanon { - return true - } +func addWantedCategory(categories map[string]struct{}, category string) { + if categories == nil { + return } - return false + categories[canonCategory(category)] = struct{}{} } func canonCategory(raw string) string { @@ -417,14 +383,36 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT return nil, nil } - extensionsClient, err := ctx.ClientFactory().Extensions(ctx.Context(), organization) - if err != nil { - return nil, fmt.Errorf("failed to create Extensions client: %w", err) + needsLookup := false + for _, raw := range assignedTo { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if strings.EqualFold(raw, "@me") { + needsLookup = true + break + } + if shouldResolveIdentity(raw) { + needsLookup = true + break + } } - identityClient, err := ctx.ClientFactory().Identity(ctx.Context(), organization) - if err != nil { - return nil, fmt.Errorf("failed to create Identity client: %w", err) + var extensionsClient extensions.Client + var identityClient identity.Client + if needsLookup { + ext, err := ctx.ClientFactory().Extensions(ctx.Context(), organization) + if err != nil { + return nil, fmt.Errorf("failed to create Extensions client: %w", err) + } + extensionsClient = ext + + idc, err := ctx.ClientFactory().Identity(ctx.Context(), organization) + if err != nil { + return nil, fmt.Errorf("failed to create Identity client: %w", err) + } + identityClient = idc } resolved := make([]string, 0, len(assignedTo)) @@ -434,13 +422,6 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT continue } - // WIQL identity fields accept display names/emails directly. Keep those values unchanged and - // only resolve aliases/descriptors when needed. - if strings.Contains(raw, " ") || strings.Contains(raw, "@") { - resolved = append(resolved, raw) - continue - } - if strings.EqualFold(raw, "@me") { selfID, err := extensionsClient.GetSelfID(ctx.Context()) if err != nil { @@ -449,10 +430,6 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT identityIds := selfID.String() identities, err := identityClient.ReadIdentities(ctx.Context(), identity.ReadIdentitiesArgs{ IdentityIds: &identityIds, - QueryMembership: func() *identity.QueryMembership { - qm := identity.QueryMembershipValues.None - return &qm - }(), }) if err != nil { return nil, fmt.Errorf("failed to resolve @me identity details: %w", err) @@ -468,6 +445,13 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT continue } + // WIQL identity fields accept display names/emails directly. Keep those values unchanged and + // only resolve aliases/descriptors when needed. + if !shouldResolveIdentity(raw) { + resolved = append(resolved, raw) + continue + } + ident, err := extensionsClient.ResolveIdentity(ctx.Context(), raw) if err != nil { return nil, err @@ -486,36 +470,35 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT return resolved, nil } +func shouldResolveIdentity(raw string) bool { + // Keep values that already look like display names/emails unchanged. + return !(strings.Contains(raw, " ") || strings.Contains(raw, "@")) +} + func identityAccountOrDisplay(ident identity.Identity) string { - account := identityAccount(ident.Properties) - if account != "" { + if account := identityAccount(ident.Properties); account != "" { return account } return types.GetValue(ident.ProviderDisplayName, "") } func identityAccount(properties any) string { - switch typed := properties.(type) { - case map[string]any: - return extractAccountFromPropertiesMap(typed) - default: + props, ok := properties.(map[string]any) + if !ok { return "" } -} - -func extractAccountFromPropertiesMap(properties map[string]any) string { - raw, ok := properties["Account"] + raw, ok := props["Account"] if !ok || raw == nil { return "" } - switch typed := raw.(type) { - case map[string]any: - if v, ok := typed["$value"].(string); ok { - return v - } + account, ok := raw.(map[string]any) + if !ok { + return "" + } + if value, ok := account["$value"].(string); ok { + return value } - return "" } @@ -604,11 +587,11 @@ func wiqlQuoteList(values []string) string { } func wiqlIntList(values []int) string { + values = types.Unique(values) parts := make([]string, 0, len(values)) for _, v := range values { parts = append(parts, strconv.Itoa(v)) } - parts = types.UniqueComparable(parts, strings.ToLower) return strings.Join(parts, ", ") } From c7ac3890c5ccc30f4ea95491f6df06293ab08de9 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 08:58:47 +0000 Subject: [PATCH 05/20] =?UTF-8?q?fix(auth):=20=F0=9F=90=9B=20add=20debug?= =?UTF-8?q?=20logging=20and=20username=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive debug logging to the git-credential helper using zap to aid troubleshooting of authentication flows. Generate an artificial username when the parsed input does not provide one, and output it instead of the previously hardcoded 'azdo' value. --- .../cmd/auth/gitcredential/gitcredential.go | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/internal/cmd/auth/gitcredential/gitcredential.go b/internal/cmd/auth/gitcredential/gitcredential.go index 3685430b..8952509a 100644 --- a/internal/cmd/auth/gitcredential/gitcredential.go +++ b/internal/cmd/auth/gitcredential/gitcredential.go @@ -7,14 +7,17 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/tmeckel/azdo-cli/internal/cmd/util" + cmdutil "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/util" + + "go.uber.org/zap" ) type credentialOptions struct { operation string } -func NewCmdGitCredential(ctx util.CmdContext) *cobra.Command { +func NewCmdGitCredential(ctx cmdutil.CmdContext) *cobra.Command { opts := &credentialOptions{} cmd := &cobra.Command{ @@ -31,46 +34,62 @@ func NewCmdGitCredential(ctx util.CmdContext) *cobra.Command { return cmd } -func helperRun(ctx util.CmdContext, opts *credentialOptions) (err error) { +func helperRun(ctx cmdutil.CmdContext, opts *credentialOptions) (err error) { + zap.L().Debug("helper started", zap.String("operation", opts.operation)) + if opts.operation == "store" { + zap.L().Debug("store operation - no action needed") // We pretend to implement the "store" operation, but do nothing since we already have a cached token. return nil } if opts.operation == "erase" { + zap.L().Debug("erase operation - no action needed") // We pretend to implement the "erase" operation, but do nothing since we don't want git to cause user to be logged out. return nil } if opts.operation != "get" { + zap.L().Debug("unsupported operation", zap.String("operation", opts.operation)) return fmt.Errorf("azdo auth git-credential: %q operation not supported", opts.operation) } cfg, err := ctx.Config() if err != nil { - return util.FlagErrorf("error getting configuration: %w", err) + zap.L().Debug("config error", zap.Error(err)) + return cmdutil.FlagErrorf("error getting configuration: %w", err) } iostrms, err := ctx.IOStreams() if err != nil { - return util.FlagErrorf("error getting io streams: %w", err) + zap.L().Debug("io streams error", zap.Error(err)) + return cmdutil.FlagErrorf("error getting io streams: %w", err) } wants := map[string]string{} + zap.L().Debug("parsing input") s := bufio.NewScanner(iostrms.In) + lineNum := 0 for s.Scan() { + lineNum++ line := s.Text() + zap.L().Debug("read line", zap.Int("line", lineNum), zap.String("raw", line)) if line == "" { + zap.L().Debug("empty line detected, stopping input parsing") break } parts := strings.SplitN(line, "=", 2) if len(parts) < 2 { + zap.L().Debug("skipping malformed line", zap.Int("line", lineNum), zap.String("raw", line)) continue } key, value := parts[0], parts[1] + zap.L().Debug("parsed key-value", zap.Int("line", lineNum), zap.String("key", key), zap.String("value", value)) if key == "url" { + zap.L().Debug("parsing url", zap.String("url", value)) u, err := url.Parse(value) if err != nil { + zap.L().Debug("url parse error", zap.Error(err)) return err } wants["protocol"] = u.Scheme @@ -78,45 +97,71 @@ func helperRun(ctx util.CmdContext, opts *credentialOptions) (err error) { wants["path"] = u.Path wants["username"] = u.User.Username() wants["password"], _ = u.User.Password() + zap.L().Debug("url components extracted", + zap.String("protocol", u.Scheme), + zap.String("host", u.Host), + zap.String("path", u.Path), + zap.String("username", u.User.Username())) } else { wants[key] = value } } if err := s.Err(); err != nil { + zap.L().Debug("scanner error", zap.Error(err)) return err } + if u, ok := wants["username"]; !ok || len(strings.TrimSpace(u)) == 0 { + wants["username"] = util.StringBuilderString.MustGenerate(10) + zap.L().Debug("generated artificial username", zap.String("username", wants["username"])) + } + + zap.L().Debug("parsed input", zap.Any("wants", wants), zap.Int("total_keys", len(wants))) + if wants["protocol"] != "https" { + protocol := wants["protocol"] + if protocol == "" { + protocol = "(empty)" + } + zap.L().Debug("protocol not https", zap.String("protocol", protocol)) return fmt.Errorf("protocol %s != https", wants["protocol"]) } var organizationName string lookupHost := strings.ToLower(wants["host"]) + zap.L().Debug("detecting organization", zap.String("host", lookupHost), zap.String("path", wants["path"])) if strings.Contains(lookupHost, ".visualstudio.com") { //nolint:golint,gocritic organizationName = strings.Split(lookupHost, ".")[0] + zap.L().Debug("organization from visualstudio.com", zap.String("organization", organizationName)) } else if lookupHost == "dev.azure.com" { if path, ok := wants["path"]; !ok { + zap.L().Debug("dev.azure.com host requires path") return fmt.Errorf("authenticating via dev.azure.com host requires path parameter") } else { //nolint:golint,revive organizationName = strings.Split(path, "/")[0] + zap.L().Debug("organization from dev.azure.com", zap.String("organization", organizationName)) } } else { + zap.L().Debug("not an Azure DevOps host", zap.String("host", lookupHost)) return fmt.Errorf("not an Azure DevOps host %s", lookupHost) } if organizationName == "" { + zap.L().Debug("unable to extract organization", zap.String("host", wants["host"]), zap.String("path", wants["path"])) return fmt.Errorf("unable to get token from host %s or path %s", wants["host"], wants["path"]) } auth := cfg.Authentication() gotToken, err := auth.GetToken(organizationName) if err != nil || gotToken == "" { + zap.L().Debug("token retrieval failed", zap.String("organization", organizationName), zap.Error(err)) return fmt.Errorf("unable to get token for organization %s", organizationName) } + zap.L().Debug("outputting credentials", zap.String("host", wants["host"])) fmt.Fprint(iostrms.Out, "protocol=https\n") fmt.Fprintf(iostrms.Out, "host=%s\n", wants["host"]) - fmt.Fprintf(iostrms.Out, "username=azdo\n") + fmt.Fprintf(iostrms.Out, "username=%s\n", wants["username"]) fmt.Fprintf(iostrms.Out, "password=%s\n", gotToken) return nil From d622d8b8767a197a403bd262938eed56ef8a910f Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 08:59:54 +0000 Subject: [PATCH 06/20] feat(util): add preconfigured string builders and MustGenerate * Add pre-configured StringBuilder variables for common generation patterns: StringBuilderString, StringBuilderPassword, and StringBuilderNumberedString. * Introduce MustGenerate as a convenience wrapper that panics on error for cases where generation failure indicates a programming error. --- internal/util/stringBuilder.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/util/stringBuilder.go b/internal/util/stringBuilder.go index fa12ddd1..d20afd56 100644 --- a/internal/util/stringBuilder.go +++ b/internal/util/stringBuilder.go @@ -36,6 +36,24 @@ func NewStringBuilder() *StringBuilder { } } +// StringBuilderString is a pre-configured StringBuilder for generating random strings with uppercase and lowercase letters. +var StringBuilderString = NewStringBuilder(). + WithUpperLetters(). + WithLowerLetters() + +// StringBuilderPassword is a pre-configured StringBuilder for generating random passwords with uppercase letters, lowercase letters, numbers, and special characters. +var StringBuilderPassword = NewStringBuilder(). + WithUpperLetters(). + WithLowerLetters(). + WithNumbers(). + WithSpecialCharacters() + +// StringBuilderNumberedString is a pre-configured StringBuilder for generating random strings with uppercase letters, lowercase letters, and numbers. +var StringBuilderNumberedString = NewStringBuilder(). + WithUpperLetters(). + WithLowerLetters(). + WithNumbers() + // WithUpperLetters adds uppercase letters (A-Z) to the character set. func (sb *StringBuilder) WithUpperLetters() *StringBuilder { sb.includeUpper = true @@ -121,3 +139,14 @@ func (sb *StringBuilder) Generate(length int) (string, error) { return s, nil } } + +// MustGenerate creates a random string of the specified length using the configured character set. +// It panics if generation fails (e.g., length <= 0 or no character sets selected). +// This is a convenience method for cases where generation failure is considered a programming error. +func (sb *StringBuilder) MustGenerate(length int) string { + s, err := sb.Generate(length) + if err != nil { + panic(fmt.Sprintf("StringBuilder.MustGenerate failed: %v", err)) + } + return s +} From d9aec1b05cf0fbd3317c9ea8fe63dbfbaca9dad4 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 09:37:34 +0000 Subject: [PATCH 07/20] refactor: simplify identity filter condition --- internal/cmd/boards/workitem/list/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/boards/workitem/list/list.go b/internal/cmd/boards/workitem/list/list.go index 73604051..2ac36cd1 100644 --- a/internal/cmd/boards/workitem/list/list.go +++ b/internal/cmd/boards/workitem/list/list.go @@ -472,7 +472,7 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT func shouldResolveIdentity(raw string) bool { // Keep values that already look like display names/emails unchanged. - return !(strings.Contains(raw, " ") || strings.Contains(raw, "@")) + return !strings.Contains(raw, " ") && !strings.Contains(raw, "@") } func identityAccountOrDisplay(ident identity.Identity) string { From 7d610d2171089b86bb05afb18318a39abd5ee349 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 09:38:06 +0000 Subject: [PATCH 08/20] =?UTF-8?q?test(workitem):=20=20=F0=9F=A7=AAadd=20te?= =?UTF-8?q?sts=20for=20work=20item=20list=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cmd/boards/workitem/list/list_test.go | 754 ++++++++++++++++++ 1 file changed, 754 insertions(+) create mode 100644 internal/cmd/boards/workitem/list/list_test.go diff --git a/internal/cmd/boards/workitem/list/list_test.go b/internal/cmd/boards/workitem/list/list_test.go new file mode 100644 index 00000000..5babd7eb --- /dev/null +++ b/internal/cmd/boards/workitem/list/list_test.go @@ -0,0 +1,754 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/printer" + "github.com/tmeckel/azdo-cli/internal/types" +) + +func TestBuildWiqlQuery(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + project string + stateCategory string + types []string + assignedTo []string + severity []string + priority []int + area []string + iteration []string + mustContain []string + mustNotContain []string + }{ + { + name: "project only", + project: "Fabrikam", + mustContain: []string{"[System.TeamProject] = 'Fabrikam'", "SELECT [System.Id] FROM WorkItems", "ORDER BY [System.ChangedDate] DESC"}, + mustNotContain: []string{"[System.State]", "[System.WorkItemType]", "[System.AssignedTo]", "[System.AreaPath]", "[System.IterationPath]"}, + }, + { + name: "all flags combined", + project: "Fabrikam", + stateCategory: "[System.State] IN ('Active','New')", + types: []string{"User Story", "Task"}, + assignedTo: []string{"alice@x.com", "Bob"}, + severity: []string{"1 - Critical"}, + priority: []int{1, 2}, + area: []string{"Web/Payments", "Under:Web/Payments/Internal"}, + iteration: []string{"Under:Release 2025/Sprint 1"}, + mustContain: []string{ + "[System.TeamProject] = 'Fabrikam'", + "[System.State] IN ('Active','New')", + "[System.WorkItemType] IN ('User Story', 'Task')", + "[System.AssignedTo] IN ('alice@x.com', 'Bob')", + "[Microsoft.VSTS.Common.Severity] IN ('1 - Critical')", + "[Microsoft.VSTS.Common.Priority] IN (1, 2)", + "[System.AreaPath] = 'Web/Payments'", + "[System.AreaPath] UNDER 'Web/Payments/Internal'", + "[System.IterationPath] UNDER 'Release 2025/Sprint 1'", + }, + }, + { + name: "type and assignedTo list ordering", + project: "P", + types: []string{"Bug"}, + assignedTo: []string{"a@b.com"}, + mustContain: []string{ + "[System.WorkItemType] IN ('Bug')", + "[System.AssignedTo] IN ('a@b.com')", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := buildWiqlQuery(tc.project, tc.stateCategory, tc.types, tc.assignedTo, tc.severity, tc.priority, tc.area, tc.iteration) + for _, want := range tc.mustContain { + assert.Contains(t, got, want) + } + for _, notWant := range tc.mustNotContain { + assert.NotContains(t, got, notWant) + } + assert.Equal(t, 1, strings.Count(got, "SELECT [System.Id] FROM WorkItems WHERE")) + }) + } +} + +func TestBuildUnderOrEqualsPredicate(t *testing.T) { + t.Parallel() + + t.Run("single equals path", func(t *testing.T) { + t.Parallel() + got := buildUnderOrEqualsPredicate("[System.AreaPath]", []string{"Web/Payments"}) + assert.Equal(t, "[System.AreaPath] = 'Web/Payments'", got) + }) + + t.Run("single Under path", func(t *testing.T) { + t.Parallel() + got := buildUnderOrEqualsPredicate("[System.IterationPath]", []string{"Under:Release 2025/Sprint 1"}) + assert.Equal(t, "[System.IterationPath] UNDER 'Release 2025/Sprint 1'", got) + }) + + t.Run("multiple values get OR with parentheses", func(t *testing.T) { + t.Parallel() + got := buildUnderOrEqualsPredicate("[System.AreaPath]", []string{"Web/Payments", "Under:Mobile"}) + assert.Equal(t, "([System.AreaPath] = 'Web/Payments' OR [System.AreaPath] UNDER 'Mobile')", got) + }) + + t.Run("empty input returns empty string", func(t *testing.T) { + t.Parallel() + assert.Empty(t, buildUnderOrEqualsPredicate("[System.AreaPath]", nil)) + assert.Empty(t, buildUnderOrEqualsPredicate("[System.AreaPath]", []string{"", " "})) + }) +} + +func TestWiqlQuote(t *testing.T) { + t.Parallel() + + assert.Equal(t, "'foo'", wiqlQuote("foo")) + assert.Equal(t, "'O''Brien'", wiqlQuote("O'Brien")) +} + +func TestWiqlQuoteListAndIntList(t *testing.T) { + t.Parallel() + + assert.Equal(t, "'a', 'b'", wiqlQuoteList([]string{"a", "b", " "})) + assert.Equal(t, "1, 2, 3", wiqlIntList([]int{1, 2, 2, 3})) +} + +func TestValidateClassification(t *testing.T) { + t.Parallel() + + require.NoError(t, validateClassification([]string{"1 - Critical", "3 - Medium"})) + err := validateClassification([]string{"5 - Disaster"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid value for --classification") +} + +func TestValidatePriority(t *testing.T) { + t.Parallel() + + require.NoError(t, validatePriority([]int{1, 2, 3, 4})) + require.Error(t, validatePriority([]int{0})) + require.Error(t, validatePriority([]int{5})) +} + +func TestValidateUnderPaths(t *testing.T) { + t.Parallel() + + require.NoError(t, validateUnderPaths("--area", []string{"Web/Payments", "Under:Mobile/Auth"})) + require.Error(t, validateUnderPaths("--area", []string{"Under:"})) + require.Error(t, validateUnderPaths("--area", []string{"Under: "})) +} + +func TestNormalizeStatuses(t *testing.T) { + t.Parallel() + + t.Run("nil defaults to open", func(t *testing.T) { + t.Parallel() + assert.Equal(t, []string{"open"}, normalizeStatuses(nil)) + }) + + t.Run("trims, lowercases, dedupes, falls back to open", func(t *testing.T) { + t.Parallel() + assert.Equal(t, []string{"open", "closed"}, normalizeStatuses([]string{" OPEN ", "closed", "CLOSED"})) + assert.Equal(t, []string{"open"}, normalizeStatuses([]string{"", " "})) + }) +} + +func TestCanonCategory(t *testing.T) { + t.Parallel() + + assert.Equal(t, "inprogress", canonCategory("In Progress")) + assert.Equal(t, "completed", canonCategory(" Completed ")) +} + +func TestShouldResolveIdentity(t *testing.T) { + t.Parallel() + + assert.False(t, shouldResolveIdentity("alice@example.com")) + assert.False(t, shouldResolveIdentity("Alice Smith")) + assert.True(t, shouldResolveIdentity("vssgp.Uy0xLjI")) + assert.True(t, shouldResolveIdentity("org/team")) +} + +func TestExtractWorkItemIDs(t *testing.T) { + t.Parallel() + + t.Run("nil result", func(t *testing.T) { + t.Parallel() + assert.Nil(t, extractWorkItemIDs(nil)) + }) + + t.Run("skips nil IDs", func(t *testing.T) { + t.Parallel() + result := &workitemtracking.WorkItemQueryResult{ + WorkItems: &[]workitemtracking.WorkItemReference{ + {Id: types.ToPtr(1)}, + {Id: nil}, + {Id: types.ToPtr(2)}, + }, + } + assert.Equal(t, []int{1, 2}, extractWorkItemIDs(result)) + }) +} + +func TestOrderWorkItemsByIDs(t *testing.T) { + t.Parallel() + + items := []workitemtracking.WorkItem{ + {Id: types.ToPtr(1), Url: types.ToPtr("a")}, + {Id: types.ToPtr(2), Url: types.ToPtr("b")}, + {Id: types.ToPtr(3), Url: types.ToPtr("c")}, + } + ordered := orderWorkItemsByIDs(items, []int{3, 1, 2}) + require.Len(t, ordered, 3) + assert.Equal(t, 3, *ordered[0].Id) + assert.Equal(t, 1, *ordered[1].Id) + assert.Equal(t, 2, *ordered[2].Id) +} + +func TestFieldString(t *testing.T) { + t.Parallel() + + fields := map[string]any{ + "a": "hello", + "b": 42, + } + assert.Equal(t, "hello", fieldString(fields, "a")) + assert.Equal(t, "42", fieldString(fields, "b")) + assert.Equal(t, "", fieldString(fields, "missing")) + assert.Equal(t, "", fieldString(nil, "a")) +} + +func TestFieldIdentityDisplay(t *testing.T) { + t.Parallel() + + fields := map[string]any{ + "a": "Alice", + "b": map[string]any{"displayName": "Bob", "uniqueName": "bob@x.com"}, + "c": map[string]any{"uniqueName": "carol@x.com"}, + } + assert.Equal(t, "Alice", fieldIdentityDisplay(fields, "a")) + assert.Equal(t, "Bob", fieldIdentityDisplay(fields, "b")) + assert.Equal(t, "carol@x.com", fieldIdentityDisplay(fields, "c")) + assert.Equal(t, "", fieldIdentityDisplay(fields, "missing")) + assert.Equal(t, "", fieldIdentityDisplay(nil, "a")) +} + +func TestIdentityAccountOrDisplay(t *testing.T) { + t.Parallel() + + t.Run("Account property wins", func(t *testing.T) { + t.Parallel() + ident := identity.Identity{ + Properties: map[string]any{ + "Account": map[string]any{"$value": "Account.From.Properties"}, + }, + ProviderDisplayName: types.ToPtr("Display Name"), + } + assert.Equal(t, "Account.From.Properties", identityAccountOrDisplay(ident)) + }) + + t.Run("falls back to ProviderDisplayName", func(t *testing.T) { + t.Parallel() + ident := identity.Identity{ProviderDisplayName: types.ToPtr("Display Name")} + assert.Equal(t, "Display Name", identityAccountOrDisplay(ident)) + }) + + t.Run("returns empty when nothing available", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "", identityAccountOrDisplay(identity.Identity{})) + }) +} + +func TestValidateListOptions(t *testing.T) { + t.Parallel() + + t.Run("nil options", func(t *testing.T) { + t.Parallel() + require.Error(t, validateListOptions(nil)) + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateListOptions(&listOptions{ + classification: []string{"1 - Critical"}, + priority: []int{1}, + area: []string{"Web/Payments"}, + iteration: []string{"Under:Release 1/Sprint 1"}, + })) + }) + + t.Run("invalid priority", func(t *testing.T) { + t.Parallel() + err := validateListOptions(&listOptions{priority: []int{0}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--priority") + }) +} + +func TestTrimStrings(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"A", "B"}, trimStrings([]string{" A ", "B", "", " ", "a"})) +} + +func TestAppendStateNamesByCategory(t *testing.T) { + t.Parallel() + + states := []workitemtracking.WorkItemStateColor{ + {Name: types.ToPtr("Active"), Category: types.ToPtr("InProgress")}, + {Name: types.ToPtr("Closed"), Category: types.ToPtr("Completed")}, + {Name: types.ToPtr(""), Category: types.ToPtr("Proposed")}, + {Name: types.ToPtr("New"), Category: types.ToPtr("")}, + } + + out := []string{} + categories := map[string]struct{}{"inprogress": {}, "completed": {}} + appendStateNamesByCategory(&out, &states, categories) + assert.ElementsMatch(t, []string{"Active", "Closed"}, out) + + appendStateNamesByCategory(nil, &states, categories) + appendStateNamesByCategory(&out, nil, categories) + empty := []workitemtracking.WorkItemStateColor{} + appendStateNamesByCategory(&out, &empty, categories) +} + +func TestNewCmd_FlagShortcuts(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := mocks.NewMockCmdContext(ctrl) + cmd := NewCmd(ctx) + + type flagCheck struct { + name string + shorthand string + } + for _, want := range []flagCheck{ + // --type uses -T because --template (registered by util.AddJSONFlags) + // already owns -t across all azdo commands. + {"status", "s"}, + {"type", "T"}, + {"assigned-to", "a"}, + {"classification", "c"}, + {"priority", "p"}, + {"limit", "L"}, + } { + f := cmd.Flags().Lookup(want.name) + require.NotNil(t, f, "flag %q must be registered", want.name) + assert.Equal(t, want.shorthand, f.Shorthand, "flag --%s shorthand mismatch", want.name) + } + + assert.Equal(t, "list [ORGANIZATION/]PROJECT", cmd.Use) + assert.ElementsMatch(t, []string{"ls", "l"}, cmd.Aliases) +} + +// ----- runList integration tests via gomock ----- + +type fakeListDeps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + wit *mocks.MockWorkItemTrackingClient + ext *mocks.MockAzDOExtension + ident *mocks.MockIdentityClient + stdout *bytes.Buffer +} + +func setupFakeDeps(t *testing.T, organization string) *fakeListDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &fakeListDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + wit: mocks.NewMockWorkItemTrackingClient(ctrl), + ext: mocks.NewMockAzDOExtension(ctrl), + ident: mocks.NewMockIdentityClient(ctrl), + stdout: out, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), organization).Return(deps.wit, nil).AnyTimes() + + tp, err := printer.NewTablePrinter(out, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + return deps +} + +func openStateColors() []workitemtracking.WorkItemStateColor { + return []workitemtracking.WorkItemStateColor{ + {Name: types.ToPtr("New"), Category: types.ToPtr("Proposed")}, + {Name: types.ToPtr("Active"), Category: types.ToPtr("InProgress")}, + {Name: types.ToPtr("Resolved"), Category: types.ToPtr("Resolved")}, + {Name: types.ToPtr("Closed"), Category: types.ToPtr("Completed")}, + {Name: types.ToPtr("Removed"), Category: types.ToPtr("Removed")}, + } +} + +func workItemTypesWithStates() []workitemtracking.WorkItemType { + states := openStateColors() + disabled := false + return []workitemtracking.WorkItemType{ + { + Name: types.ToPtr("User Story"), + IsDisabled: &disabled, + States: &states, + }, + } +} + +func sampleWorkItem(id int) workitemtracking.WorkItem { + fields := map[string]any{ + "System.WorkItemType": "User Story", + "System.State": "Active", + "System.Title": fmt.Sprintf("Item %d", id), + "System.AssignedTo": "Alice ", + "System.AreaPath": "Fabrikam\\Web", + "System.IterationPath": "Fabrikam\\Release 1\\Sprint 1", + } + return workitemtracking.WorkItem{Id: types.ToPtr(id), Fields: &fields} +} + +func stubDefaultOpenTypes(deps *fakeListDeps) { + typesList := workItemTypesWithStates() + deps.wit.EXPECT().GetWorkItemTypes(gomock.Any(), gomock.Any()).Return(&typesList, nil) +} + +func stubBatch(t *testing.T, deps *fakeListDeps, expandAll bool) { + t.Helper() + + deps.wit.EXPECT().GetWorkItemsBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetWorkItemsBatchArgs) (*[]workitemtracking.WorkItem, error) { + require.NotNil(t, args.WorkItemGetRequest) + require.NotNil(t, args.WorkItemGetRequest.Ids) + if expandAll { + require.NotNil(t, args.WorkItemGetRequest.Expand) + assert.Equal(t, workitemtracking.WorkItemExpandValues.All, *args.WorkItemGetRequest.Expand) + assert.Nil(t, args.WorkItemGetRequest.Fields) + } else { + require.NotNil(t, args.WorkItemGetRequest.Fields) + assert.Nil(t, args.WorkItemGetRequest.Expand) + } + require.NotNil(t, args.WorkItemGetRequest.ErrorPolicy) + assert.Equal(t, workitemtracking.WorkItemErrorPolicyValues.Omit, *args.WorkItemGetRequest.ErrorPolicy) + + batch := *args.WorkItemGetRequest.Ids + out := make([]workitemtracking.WorkItem, 0, len(batch)) + for _, id := range batch { + out = append(out, sampleWorkItem(id)) + } + return &out, nil + }, + ).AnyTimes() +} + +func TestRunList_DefaultOpenStatus(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + stubDefaultOpenTypes(deps) + stubBatch(t, deps, false) + + captured := "" + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + refs := []workitemtracking.WorkItemReference{{Id: types.ToPtr(1)}, {Id: types.ToPtr(2)}} + return &workitemtracking.WorkItemQueryResult{WorkItems: &refs}, nil + }, + ) + + err := runList(deps.cmd, &listOptions{scopeArg: "org/Fabrikam"}) + require.NoError(t, err) + + assert.Contains(t, captured, "[System.TeamProject] = 'Fabrikam'") + assert.Contains(t, captured, "[System.State] IN (") + assert.Contains(t, deps.stdout.String(), "Item 1") +} + +func TestRunList_StatusAllOmitsStatePredicate(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + captured := "" + + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + refs := []workitemtracking.WorkItemReference{{Id: types.ToPtr(7)}} + return &workitemtracking.WorkItemQueryResult{WorkItems: &refs}, nil + }, + ) + stubBatch(t, deps, false) + + err := runList(deps.cmd, &listOptions{scopeArg: "org/Fabrikam", status: []string{"all"}}) + require.NoError(t, err) + + assert.NotContains(t, captured, "[System.State]") +} + +func TestRunList_LimitWiresTop(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + var top *int + + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + top = args.Top + refs := []workitemtracking.WorkItemReference{{Id: types.ToPtr(1)}} + return &workitemtracking.WorkItemQueryResult{WorkItems: &refs}, nil + }, + ) + stubBatch(t, deps, false) + + err := runList(deps.cmd, &listOptions{scopeArg: "org/Fabrikam", status: []string{"all"}, limit: 25}) + require.NoError(t, err) + require.NotNil(t, top) + assert.Equal(t, 25, *top) +} + +func TestRunList_BatchChunkingAt200(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + + ids := make([]int, 0, 250) + for i := 1; i <= 250; i++ { + ids = append(ids, i) + } + + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + refs := make([]workitemtracking.WorkItemReference, 0, len(ids)) + for _, id := range ids { + refs = append(refs, workitemtracking.WorkItemReference{Id: types.ToPtr(id)}) + } + return &workitemtracking.WorkItemQueryResult{WorkItems: &refs}, nil + }, + ) + + batchSizes := []int{} + deps.wit.EXPECT().GetWorkItemsBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetWorkItemsBatchArgs) (*[]workitemtracking.WorkItem, error) { + batchSizes = append(batchSizes, len(*args.WorkItemGetRequest.Ids)) + batch := *args.WorkItemGetRequest.Ids + out := make([]workitemtracking.WorkItem, 0, len(batch)) + for _, id := range batch { + out = append(out, sampleWorkItem(id)) + } + return &out, nil + }, + ).Times(2) + + err := runList(deps.cmd, &listOptions{scopeArg: "org/Fabrikam", status: []string{"all"}}) + require.NoError(t, err) + assert.Equal(t, []int{200, 50}, batchSizes) +} + +func TestRunList_AssignedToMeResolvesIdentity(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + + selfID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + deps.clientFact.EXPECT().Extensions(gomock.Any(), "org").Return(deps.ext, nil) + deps.clientFact.EXPECT().Identity(gomock.Any(), "org").Return(deps.ident, nil) + deps.ext.EXPECT().GetSelfID(gomock.Any()).Return(selfID, nil) + + idsArg := selfID.String() + deps.ident.EXPECT().ReadIdentities(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args identity.ReadIdentitiesArgs) (*[]identity.Identity, error) { + require.NotNil(t, args.IdentityIds) + assert.Equal(t, idsArg, *args.IdentityIds) + account := "Account.From.Properties" + display := "Self User" + out := []identity.Identity{{ + Properties: map[string]any{"Account": map[string]any{"$value": account}}, + ProviderDisplayName: &display, + }} + return &out, nil + }, + ) + + captured := "" + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + refs := []workitemtracking.WorkItemReference{{Id: types.ToPtr(99)}} + return &workitemtracking.WorkItemQueryResult{WorkItems: &refs}, nil + }, + ) + stubBatch(t, deps, false) + + err := runList(deps.cmd, &listOptions{scopeArg: "org/Fabrikam", status: []string{"all"}, assignedTo: []string{"@me"}}) + require.NoError(t, err) + assert.Contains(t, captured, "[System.AssignedTo] IN ('Account.From.Properties')") +} + +func TestRunList_AssignedToEmailSkipsLookup(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + captured := "" + + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + refs := []workitemtracking.WorkItemReference{{Id: types.ToPtr(5)}} + return &workitemtracking.WorkItemQueryResult{WorkItems: &refs}, nil + }, + ) + stubBatch(t, deps, false) + + err := runList(deps.cmd, &listOptions{scopeArg: "org/Fabrikam", status: []string{"all"}, assignedTo: []string{"alice@x.com"}}) + require.NoError(t, err) + assert.Contains(t, captured, "[System.AssignedTo] IN ('alice@x.com')") +} + +func TestRunList_AreaUnderPrefix(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + captured := "" + + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + refs := []workitemtracking.WorkItemReference{{Id: types.ToPtr(1)}} + return &workitemtracking.WorkItemQueryResult{WorkItems: &refs}, nil + }, + ) + stubBatch(t, deps, false) + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + status: []string{"all"}, + area: []string{"Under:Web/Payments"}, + }) + require.NoError(t, err) + assert.Contains(t, captured, "[System.AreaPath] UNDER 'Web/Payments'") +} + +func TestRunList_NoResultsReturnsNoResultsError(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).Return(&workitemtracking.WorkItemQueryResult{ + WorkItems: &[]workitemtracking.WorkItemReference{}, + }, nil) + + err := runList(deps.cmd, &listOptions{scopeArg: "org/Fabrikam", status: []string{"all"}}) + require.Error(t, err) + var noResults util.NoResultsError + require.True(t, errors.As(err, &noResults), "expected NoResultsError, got %v", err) +} + +func TestRunList_WiqlErrorPropagates(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + err := runList(deps.cmd, &listOptions{scopeArg: "org/Fabrikam", status: []string{"all"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "WIQL") +} + +func TestRunList_JSONOutputUsesExpandAll(t *testing.T) { + t.Parallel() + + deps := setupFakeDeps(t, "org") + + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).Return(&workitemtracking.WorkItemQueryResult{ + WorkItems: &[]workitemtracking.WorkItemReference{{Id: types.ToPtr(42)}}, + }, nil) + + expandSeen := false + deps.wit.EXPECT().GetWorkItemsBatch(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetWorkItemsBatchArgs) (*[]workitemtracking.WorkItem, error) { + if args.WorkItemGetRequest.Expand != nil { + expandSeen = true + assert.Equal(t, workitemtracking.WorkItemExpandValues.All, *args.WorkItemGetRequest.Expand) + } + batch := *args.WorkItemGetRequest.Ids + out := make([]workitemtracking.WorkItem, 0, len(batch)) + for _, id := range batch { + out = append(out, sampleWorkItem(id)) + } + return &out, nil + }, + ) + + opts := &listOptions{ + scopeArg: "org/Fabrikam", + status: []string{"all"}, + exporter: &stubExporter{}, + } + + err := runList(deps.cmd, opts) + require.NoError(t, err) + assert.True(t, expandSeen, "JSON path must request expand=All") +} + +type stubExporter struct{} + +func (s *stubExporter) Fields() []string { return nil } +func (s *stubExporter) Write(ios *iostreams.IOStreams, data any) error { + payload, err := json.Marshal(data) + if err != nil { + return err + } + _, err = ios.Out.Write(payload) + return err +} + +func TestRunList_ValidationErrorBubbles(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := mocks.NewMockCmdContext(ctrl) + ios, _, _, _ := iostreams.Test() + ctx.EXPECT().IOStreams().Return(ios, nil).AnyTimes() + + err := runList(ctx, &listOptions{scopeArg: "org/Fabrikam", classification: []string{"5 - Disaster"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid value for --classification") +} From f5c69ce9005dc9fe93040181edf4a9d6d127b64d Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 09:38:39 +0000 Subject: [PATCH 09/20] =?UTF-8?q?style(workitem):=20=20=F0=9F=92=85?= =?UTF-8?q?=F0=9F=8F=BCremove=20trailing=20blank=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/boards/workitem/workitem.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cmd/boards/workitem/workitem.go b/internal/cmd/boards/workitem/workitem.go index 791d1e98..24fe0422 100644 --- a/internal/cmd/boards/workitem/workitem.go +++ b/internal/cmd/boards/workitem/workitem.go @@ -22,4 +22,3 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { return cmd } - From a6d6ef303d5fd3fae288e2c38e687b8177fb78c2 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 16:25:04 +0000 Subject: [PATCH 10/20] feat(workitem): add filtering and sorting options Extend the work item list command with advanced query capabilities: - Filter by exact workflow state (--state) - Filter by creator identity (--created-by, --authored-by alias) - Filter by tags (--tag) - Filter by changed and created dates (--changed-after, --created-after) - Custom sorting (--sort, --order) Update WIQL query builder to combine state categories with exact states, apply date bounds, tag predicates, and creator filters. --- internal/cmd/boards/workitem/list/list.go | 317 +++++++++++++++++++++- 1 file changed, 311 insertions(+), 6 deletions(-) diff --git a/internal/cmd/boards/workitem/list/list.go b/internal/cmd/boards/workitem/list/list.go index 2ac36cd1..2c320c9c 100644 --- a/internal/cmd/boards/workitem/list/list.go +++ b/internal/cmd/boards/workitem/list/list.go @@ -5,6 +5,7 @@ import ( "slices" "strconv" "strings" + "time" "github.com/MakeNowJust/heredoc" "github.com/microsoft/azure-devops-go-api/azuredevops/v7" @@ -30,6 +31,18 @@ type listOptions struct { iteration []string limit int + changedAfter string + createdAfter string + createdBy []string + authoredByRaw []string // populated by --authored-by, merged into createdBy in PreRunE + + state []string + + tags []string + + sortFields []string + sortOrder string + exporter util.Exporter } @@ -69,6 +82,14 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { }, } + cmd.Flags().StringSliceVar(&opts.state, "state", nil, "Filter by exact workflow state name (repeatable; combines with --status)") + cmd.Flags().StringSliceVar(&opts.createdBy, "created-by", nil, "Filter by creator identity (repeatable); supports email, descriptor, @me") + cmd.Flags().StringSliceVar(&opts.authoredByRaw, "authored-by", nil, "Alias for --created-by") + cmd.Flags().StringSliceVar(&opts.tags, "tag", nil, "Filter by tag (repeatable); items must contain all specified tags") + cmd.Flags().StringVar(&opts.changedAfter, "changed-after", "", "Lower bound on System.ChangedDate (RFC3339, YYYY-MM-DD, or 'today')") + cmd.Flags().StringVar(&opts.createdAfter, "created-after", "", "Lower bound on System.CreatedDate (RFC3339, YYYY-MM-DD, or 'today')") + cmd.Flags().StringVar(&opts.sortOrder, "order", "desc", "Sort direction for all --sort fields: asc or desc") + cmd.Flags().StringSliceVar(&opts.sortFields, "sort", nil, "Sort by field (repeatable): changed, created, id, state, title, assigned-to, type, tags") cmd.Flags().StringSliceVarP(&opts.status, "status", "s", []string{"open"}, "Filter by state category: open, closed, resolved, all (repeatable)") cmd.Flags().StringSliceVarP(&opts.workItemTypes, "type", "T", nil, "Filter by work item type (repeatable)") cmd.Flags().StringSliceVarP(&opts.assignedTo, "assigned-to", "a", nil, "Filter by assigned-to identity (repeatable); supports emails, descriptors, and @me") @@ -80,6 +101,19 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { util.AddJSONFlags(cmd, &opts.exporter, []string{"url", "_links", "commentVersionRef", "fields", "id", "relations", "rev"}) + // Merge --authored-by into --created-by before any other PreRunE runs. + originalPreRunE := cmd.PreRunE + cmd.PreRunE = func(c *cobra.Command, args []string) error { + if len(opts.authoredByRaw) > 0 { + opts.createdBy = append(opts.createdBy, opts.authoredByRaw...) + opts.authoredByRaw = nil + } + if originalPreRunE != nil { + return originalPreRunE(c, args) + } + return nil + } + return cmd } @@ -101,6 +135,27 @@ func runList(ctx util.CmdContext, opts *listOptions) error { return util.FlagErrorWrap(err) } + orderBy, err := resolveSort(opts.sortFields, opts.sortOrder) + if err != nil { + return err + } + + changedAfterBound, err := parseDateBound(opts.changedAfter, "--changed-after") + if err != nil { + return err + } + createdAfterBound, err := parseDateBound(opts.createdAfter, "--created-after") + if err != nil { + return err + } + + tagPredicate := buildTagPredicate(opts.tags) + + createdByFilter, err := resolveCreatedByFilter(ctx, scope.Organization, opts.createdBy) + if err != nil { + return err + } + witClient, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization) if err != nil { return fmt.Errorf("failed to create work item tracking client: %w", err) @@ -116,9 +171,26 @@ func runList(ctx util.CmdContext, opts *listOptions) error { return err } - query := buildWiqlQuery(scope.Project, statusPredicate, opts.workItemTypes, assignedToFilter, opts.classification, opts.priority, opts.area, opts.iteration) + stateClause, err := buildStateClause(opts.state) + if err != nil { + return err + } + + // Intersect --status (categories) and --state (exact names) when both are set. + combinedState := "" + switch { + case statusPredicate != "" && stateClause != "": + combinedState = "(" + statusPredicate + ") AND (" + stateClause + ")" + case statusPredicate != "": + combinedState = statusPredicate + case stateClause != "": + combinedState = stateClause + } - zap.L().Debug("querying work items via WIQL", + query := buildWiqlQuery(scope.Project, combinedState, opts.workItemTypes, assignedToFilter, opts.classification, opts.priority, opts.area, opts.iteration, orderBy, changedAfterBound, createdAfterBound, tagPredicate, createdByFilter) + + zap.L().Debug( + "querying work items via WIQL", zap.String("organization", scope.Organization), zap.String("project", scope.Project), zap.Int("limit", opts.limit), @@ -176,6 +248,15 @@ func validateListOptions(opts *listOptions) error { if err := validateUnderPaths("--iteration", opts.iteration); err != nil { return err } + if err := validateState(opts.state); err != nil { + return err + } + if err := validateTags("--tag", opts.tags); err != nil { + return err + } + if err := validateSort(opts.sortFields, opts.sortOrder); err != nil { + return err + } return nil } @@ -378,6 +459,7 @@ func validateUnderPaths(flag string, values []string) error { return nil } +//nolint:dupl // intentional duplicate of resolveCreatedByFilter; refactor when third identity field is needed. func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedTo []string) ([]string, error) { if len(assignedTo) == 0 { return nil, nil @@ -415,6 +497,7 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT identityClient = idc } + //nolint:dupl // intentional duplicate of resolveCreatedByFilter loop resolved := make([]string, 0, len(assignedTo)) for _, raw := range assignedTo { raw = strings.TrimSpace(raw) @@ -470,6 +553,82 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT return resolved, nil } +//nolint:dupl // intentional duplicate of resolveAssignedToFilter; refactor when third identity field is needed. +func resolveCreatedByFilter(ctx util.CmdContext, organization string, createdBy []string) ([]string, error) { + if len(createdBy) == 0 { + return nil, nil + } + needsLookup := false + for _, raw := range createdBy { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if strings.EqualFold(raw, "@me") || shouldResolveIdentity(raw) { + needsLookup = true + break + } + } + var extensionsClient extensions.Client + var identityClient identity.Client + if needsLookup { + ext, err := ctx.ClientFactory().Extensions(ctx.Context(), organization) + if err != nil { + return nil, fmt.Errorf("failed to create Extensions client: %w", err) + } + extensionsClient = ext + idc, err := ctx.ClientFactory().Identity(ctx.Context(), organization) + if err != nil { + return nil, fmt.Errorf("failed to create Identity client: %w", err) + } + identityClient = idc + } + resolved := make([]string, 0, len(createdBy)) + for _, raw := range createdBy { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if strings.EqualFold(raw, "@me") { + selfID, err := extensionsClient.GetSelfID(ctx.Context()) + if err != nil { + return nil, fmt.Errorf("failed to resolve @me identity: %w", err) + } + identityIds := selfID.String() + identities, err := identityClient.ReadIdentities(ctx.Context(), identity.ReadIdentitiesArgs{IdentityIds: &identityIds}) + if err != nil { + return nil, fmt.Errorf("failed to resolve @me identity details: %w", err) + } + if identities == nil || len(*identities) != 1 { + return nil, fmt.Errorf("failed to resolve @me identity details") + } + value := identityAccountOrDisplay((*identities)[0]) + if value == "" { + return nil, fmt.Errorf("authenticated identity is missing account or display name") + } + resolved = append(resolved, value) + continue + } + if !shouldResolveIdentity(raw) { + resolved = append(resolved, raw) + continue + } + ident, err := extensionsClient.ResolveIdentity(ctx.Context(), raw) + if err != nil { + return nil, err + } + if ident == nil { + return nil, fmt.Errorf("no identity found for %q", raw) + } + value := identityAccountOrDisplay(*ident) + if value == "" { + return nil, fmt.Errorf("resolved identity for %q is missing account or display name", raw) + } + resolved = append(resolved, value) + } + return types.UniqueComparable(resolved, strings.ToLower), nil +} + func shouldResolveIdentity(raw string) bool { // Keep values that already look like display names/emails unchanged. return !strings.Contains(raw, " ") && !strings.Contains(raw, "@") @@ -502,12 +661,140 @@ func identityAccount(properties any) string { return "" } -func buildWiqlQuery(project string, stateCategoryPredicate string, typesFilter []string, assignedTo []string, severity []string, priority []int, area []string, iteration []string) string { +var sortFieldMap = map[string]string{ + "changed": "[System.ChangedDate]", + "created": "[System.CreatedDate]", + "id": "[System.Id]", + "state": "[System.State]", + "title": "[System.Title]", + "assigned-to": "[System.AssignedTo]", + "type": "[System.WorkItemType]", + "tags": "[System.Tags]", + "priority": "[Microsoft.VSTS.Common.Priority]", +} + +func validateSort(fields []string, order string) error { + if _, err := resolveSort(fields, order); err != nil { + return err + } + return nil +} + +func resolveSort(fields []string, order string) (string, error) { + if len(fields) == 0 { + return "", nil + } + dir := strings.ToLower(strings.TrimSpace(order)) + if dir == "" { + // default direction depends on the first field + first := strings.ToLower(strings.TrimSpace(fields[0])) + switch first { + case "changed", "created", "id": + dir = "desc" + default: + dir = "asc" + } + } + if dir != "asc" && dir != "desc" { + return "", util.FlagErrorf("invalid --order %q: must be asc or desc", order) + } + parts := make([]string, 0, len(fields)) + for _, raw := range fields { + key := strings.ToLower(strings.TrimSpace(raw)) + ref, ok := sortFieldMap[key] + if !ok { + return "", util.FlagErrorf("invalid --sort field %q (valid: changed, created, id, state, title, assigned-to, type, tags)", raw) + } + parts = append(parts, ref+" "+strings.ToUpper(dir)) + } + return "ORDER BY " + strings.Join(parts, ", "), nil +} + +func parseDateBound(raw, flagName string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + t, err := parseFlexibleDateLocal(raw) + if err != nil { + return "", util.FlagErrorf("invalid %s %q: %v", flagName, raw, err) + } + return t.UTC().Format("2006-01-02T15:04:05Z"), nil +} + +func parseFlexibleDateLocal(raw string) (time.Time, error) { + trimmed := strings.TrimSpace(raw) + if strings.EqualFold(trimmed, "today") { + now := time.Now().UTC() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC), nil + } + if strings.Contains(trimmed, "T") { + return time.Parse(time.RFC3339, trimmed) + } + return time.Parse("2006-01-02", trimmed) +} + +func validateTags(flag string, values []string) error { + for _, v := range values { + if strings.TrimSpace(v) == "" { + return util.FlagErrorf("%s value cannot be empty", flag) + } + } + return nil +} + +func buildTagPredicate(tags []string) string { + cleaned := make([]string, 0, len(tags)) + for _, t := range tags { + trimmed := strings.TrimSpace(t) + if trimmed == "" { + continue + } + cleaned = append(cleaned, trimmed) + } + cleaned = types.UniqueComparable(cleaned, strings.ToLower) + if len(cleaned) == 0 { + return "" + } + parts := make([]string, 0, len(cleaned)) + for _, t := range cleaned { + parts = append(parts, fmt.Sprintf("[System.Tags] CONTAINS %s", wiqlQuote(t))) + } + return strings.Join(parts, " AND ") +} + +func validateState(values []string) error { + for _, v := range values { + if strings.TrimSpace(v) == "" { + return util.FlagErrorf("--state value cannot be empty") + } + } + return nil +} + +func buildStateClause(states []string) (string, error) { + cleaned := make([]string, 0, len(states)) + for _, s := range states { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return "", util.FlagErrorf("--state value cannot be empty") + } + cleaned = append(cleaned, trimmed) + } + cleaned = types.UniqueComparable(cleaned, strings.ToLower) + if len(cleaned) == 0 { + return "", nil + } + return fmt.Sprintf("[System.State] IN (%s)", wiqlQuoteList(cleaned)), nil +} + +//nolint:gocritic // TDD iteration cap: defer struct refactor to a separate change. +func buildWiqlQuery(project string, statePredicate string, typesFilter []string, assignedTo []string, severity []string, priority []int, area []string, iteration []string, orderBy string, changedAfter string, createdAfter string, tagPredicate string, createdBy []string) string { clauses := make([]string, 0) clauses = append(clauses, fmt.Sprintf("[System.TeamProject] = %s", wiqlQuote(project))) - if stateCategoryPredicate != "" { - clauses = append(clauses, stateCategoryPredicate) + if statePredicate != "" { + clauses = append(clauses, statePredicate) } if len(typesFilter) > 0 { @@ -521,6 +808,10 @@ func buildWiqlQuery(project string, stateCategoryPredicate string, typesFilter [ clauses = append(clauses, fmt.Sprintf("[System.AssignedTo] IN (%s)", wiqlQuoteList(assignedTo))) } + if len(createdBy) > 0 { + clauses = append(clauses, fmt.Sprintf("[System.CreatedBy] IN (%s)", wiqlQuoteList(createdBy))) + } + if len(severity) > 0 { trimmed := trimStrings(severity) if len(trimmed) > 0 { @@ -543,7 +834,21 @@ func buildWiqlQuery(project string, stateCategoryPredicate string, typesFilter [ } } - return fmt.Sprintf("SELECT [System.Id] FROM WorkItems WHERE %s ORDER BY [System.ChangedDate] DESC", strings.Join(clauses, " AND ")) + if changedAfter != "" { + clauses = append(clauses, fmt.Sprintf("[System.ChangedDate] >= %s", wiqlQuote(changedAfter))) + } + if createdAfter != "" { + clauses = append(clauses, fmt.Sprintf("[System.CreatedDate] >= %s", wiqlQuote(createdAfter))) + } + + if tagPredicate != "" { + clauses = append(clauses, tagPredicate) + } + + if orderBy == "" { + orderBy = "ORDER BY [System.ChangedDate] DESC" + } + return fmt.Sprintf("SELECT [System.Id] FROM WorkItems WHERE %s %s", strings.Join(clauses, " AND "), orderBy) } func buildUnderOrEqualsPredicate(field string, raw []string) string { From 38a5720a0fc58a877dc02e9d67cf65083ef8482b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 16:31:39 +0000 Subject: [PATCH 11/20] =?UTF-8?q?test(workitem):=20=F0=9F=A7=AA=20add=20li?= =?UTF-8?q?st=20filter=20and=20sort=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test coverage for work item list filtering and sorting, including sort resolution, date bound parsing, tag and state predicates, created-by identity resolution, and new flag shortcuts. --- .../cmd/boards/workitem/list/list_test.go | 394 +++++++++++++++++- 1 file changed, 393 insertions(+), 1 deletion(-) diff --git a/internal/cmd/boards/workitem/list/list_test.go b/internal/cmd/boards/workitem/list/list_test.go index 5babd7eb..bbda535b 100644 --- a/internal/cmd/boards/workitem/list/list_test.go +++ b/internal/cmd/boards/workitem/list/list_test.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" "testing" + "time" "github.com/google/uuid" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" @@ -23,6 +24,389 @@ import ( "github.com/tmeckel/azdo-cli/internal/types" ) +func ctrlFromT(t *testing.T) *gomock.Controller { + c := gomock.NewController(t) + t.Cleanup(c.Finish) + return c +} + +func TestResolveSort(t *testing.T) { + t.Parallel() + cases := []struct { + name string + fields []string + order string + want string + wantErr string + }{ + {name: "no fields returns empty", fields: nil, want: ""}, + {name: "single field default desc", fields: []string{"changed"}, order: "", want: "ORDER BY [System.ChangedDate] DESC"}, + {name: "single field explicit desc", fields: []string{"changed"}, order: "desc", want: "ORDER BY [System.ChangedDate] DESC"}, + {name: "single field asc", fields: []string{"title"}, order: "asc", want: "ORDER BY [System.Title] ASC"}, + {name: "multiple fields", fields: []string{"priority", "id"}, order: "asc", want: "ORDER BY [Microsoft.VSTS.Common.Priority] ASC, [System.Id] ASC"}, + {name: "invalid field", fields: []string{"banana"}, wantErr: "invalid --sort field"}, + {name: "invalid order", fields: []string{"id"}, order: "sideways", wantErr: "invalid --order"}, + {name: "default direction is desc for changed/created/id", fields: []string{"id"}, order: "", want: "ORDER BY [System.Id] DESC"}, + {name: "default direction is asc for others", fields: []string{"state"}, order: "", want: "ORDER BY [System.State] ASC"}, + {name: "all field mappings", fields: []string{"created", "assigned-to", "type", "tags"}, order: "asc", want: "ORDER BY [System.CreatedDate] ASC, [System.AssignedTo] ASC, [System.WorkItemType] ASC, [System.Tags] ASC"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := resolveSort(tc.fields, tc.order) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestRunList_SortDefaultUnchanged(t *testing.T) { + t.Parallel() + deps := setupFakeDeps(t, "org") + stubDefaultOpenTypes(deps) + stubBatch(t, deps, false) + + var captured string + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + ids := []int{1} + return &workitemtracking.WorkItemQueryResult{WorkItems: &[]workitemtracking.WorkItemReference{{Id: &ids[0]}}}, nil + }, + ) + + err := runList(deps.cmd, &listOptions{scopeArg: "org/Fabrikam"}) + require.NoError(t, err) + assert.Contains(t, captured, "ORDER BY [System.ChangedDate] DESC") +} + +func TestRunList_SortTitleAsc(t *testing.T) { + t.Parallel() + deps := setupFakeDeps(t, "org") + stubDefaultOpenTypes(deps) + stubBatch(t, deps, false) + + var captured string + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + ids := []int{1} + return &workitemtracking.WorkItemQueryResult{WorkItems: &[]workitemtracking.WorkItemReference{{Id: &ids[0]}}}, nil + }, + ) + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + sortFields: []string{"title"}, + sortOrder: "asc", + }) + require.NoError(t, err) + assert.Contains(t, captured, "ORDER BY [System.Title] ASC") +} + +func TestRunList_SortInvalidField(t *testing.T) { + t.Parallel() + ios, _, _, _ := iostreams.Test() + deps := &fakeListDeps{ + cmd: mocks.NewMockCmdContext(ctrlFromT(t)), + stdout: &bytes.Buffer{}, + } + deps.cmd.EXPECT().IOStreams().Return(ios, nil).AnyTimes() + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + sortFields: []string{"banana"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --sort field") +} + +func TestParseDateBound(t *testing.T) { + t.Parallel() + cases := []struct { + name string + raw string + flag string + want string + wantErr string + }{ + {name: "empty returns empty", raw: "", flag: "--changed-after", want: ""}, + {name: "RFC3339", raw: "2025-01-18T12:34:56Z", flag: "--changed-after", want: "2025-01-18T12:34:56Z"}, + {name: "date only", raw: "2025-01-18", flag: "--changed-after", want: "2025-01-18T00:00:00Z"}, + {name: "today UTC midnight", raw: "today", flag: "--created-after", want: time.Now().UTC().Format("2006-01-02") + "T00:00:00Z"}, + {name: "TODAY case insensitive", raw: "TODAY", flag: "--created-after", want: time.Now().UTC().Format("2006-01-02") + "T00:00:00Z"}, + {name: "invalid string", raw: "not-a-date", flag: "--changed-after", wantErr: "invalid --changed-after"}, + {name: "flag name in error", raw: "garbage", flag: "--created-after", wantErr: "--created-after"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := parseDateBound(tc.raw, tc.flag) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestRunList_ChangedAfterRFC3339(t *testing.T) { + t.Parallel() + deps := setupFakeDeps(t, "org") + stubDefaultOpenTypes(deps) + stubBatch(t, deps, false) + + var captured string + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + ids := []int{1} + return &workitemtracking.WorkItemQueryResult{WorkItems: &[]workitemtracking.WorkItemReference{{Id: &ids[0]}}}, nil + }, + ) + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + changedAfter: "2025-01-18T00:00:00Z", + }) + require.NoError(t, err) + assert.Contains(t, captured, "[System.ChangedDate] >= '2025-01-18T00:00:00Z'") +} + +func TestRunList_InvalidDateFlag(t *testing.T) { + t.Parallel() + ios, _, _, _ := iostreams.Test() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + deps := &fakeListDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + stdout: &bytes.Buffer{}, + } + deps.cmd.EXPECT().IOStreams().Return(ios, nil).AnyTimes() + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + changedAfter: "not-a-date", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--changed-after") +} + +func TestBuildTagPredicate(t *testing.T) { + t.Parallel() + cases := []struct { + name string + tags []string + want string + }{ + {name: "empty returns empty", tags: nil, want: ""}, + {name: "single tag", tags: []string{"web"}, want: "[System.Tags] CONTAINS 'web'"}, + {name: "multiple tags AND", tags: []string{"web", "security"}, want: "[System.Tags] CONTAINS 'web' AND [System.Tags] CONTAINS 'security'"}, + {name: "trims whitespace", tags: []string{" web ", " "}, want: "[System.Tags] CONTAINS 'web'"}, + {name: "empty in middle skips", tags: []string{"web", " ", "sec"}, want: "[System.Tags] CONTAINS 'web' AND [System.Tags] CONTAINS 'sec'"}, + {name: "dedupes case-insensitively", tags: []string{"Web", "web"}, want: "[System.Tags] CONTAINS 'Web'"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := buildTagPredicate(tc.tags) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestValidateTags(t *testing.T) { + t.Parallel() + assert.NoError(t, validateTags("--tag", nil)) + assert.NoError(t, validateTags("--tag", []string{"web"})) + assert.Error(t, validateTags("--tag", []string{" "})) +} + +func TestRunList_TagFilter(t *testing.T) { + t.Parallel() + deps := setupFakeDeps(t, "org") + stubDefaultOpenTypes(deps) + stubBatch(t, deps, false) + + var captured string + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + ids := []int{1} + return &workitemtracking.WorkItemQueryResult{WorkItems: &[]workitemtracking.WorkItemReference{{Id: &ids[0]}}}, nil + }, + ) + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + tags: []string{"web", "security"}, + }) + require.NoError(t, err) + assert.Contains(t, captured, "[System.Tags] CONTAINS 'web' AND [System.Tags] CONTAINS 'security'") +} + +func TestRunList_CreatedByMe(t *testing.T) { + t.Parallel() + deps := setupFakeDeps(t, "org") + stubDefaultOpenTypes(deps) + stubBatch(t, deps, false) + + selfID := uuid.New() + deps.clientFact.EXPECT().Extensions(gomock.Any(), "org").Return(deps.ext, nil) + deps.ext.EXPECT().GetSelfID(gomock.Any()).Return(selfID, nil) + deps.clientFact.EXPECT().Identity(gomock.Any(), "org").Return(deps.ident, nil) + deps.ident.EXPECT().ReadIdentities(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args identity.ReadIdentitiesArgs) (*[]identity.Identity, error) { + require.NotNil(t, args.IdentityIds) + assert.Equal(t, selfID.String(), *args.IdentityIds) + id := identity.Identity{ + Properties: map[string]any{ + "Account": map[string]any{"$value": "Alice "}, + }, + } + return &[]identity.Identity{id}, nil + }, + ) + + var captured string + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + ids := []int{1} + return &workitemtracking.WorkItemQueryResult{WorkItems: &[]workitemtracking.WorkItemReference{{Id: &ids[0]}}}, nil + }, + ) + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + createdBy: []string{"@me"}, + }) + require.NoError(t, err) + assert.Contains(t, captured, "[System.CreatedBy] IN ('Alice ')") +} + +func TestRunList_AuthoredByAlias(t *testing.T) { + t.Parallel() + deps := setupFakeDeps(t, "org") + stubDefaultOpenTypes(deps) + stubBatch(t, deps, false) + + var captured string + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + ids := []int{1} + return &workitemtracking.WorkItemQueryResult{WorkItems: &[]workitemtracking.WorkItemReference{{Id: &ids[0]}}}, nil + }, + ) + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + createdBy: []string{"bob@x.com"}, + }) + require.NoError(t, err) + assert.Contains(t, captured, "[System.CreatedBy] IN ('bob@x.com')") +} + +func TestValidateState(t *testing.T) { + t.Parallel() + assert.NoError(t, validateState(nil)) + assert.NoError(t, validateState([]string{"Active"})) + assert.NoError(t, validateState([]string{" Active ", "Resolved"})) + assert.Error(t, validateState([]string{" "})) + assert.Error(t, validateState([]string{"Active", " "})) +} + +func TestBuildStateClause(t *testing.T) { + t.Parallel() + cases := []struct { + name string + states []string + want string + wantErr string + }{ + {name: "empty", states: nil, want: ""}, + {name: "single", states: []string{"Active"}, want: "[System.State] IN ('Active')"}, + {name: "multiple", states: []string{"Active", "Ready for Review"}, want: "[System.State] IN ('Active', 'Ready for Review')"}, + {name: "trims", states: []string{" Active "}, want: "[System.State] IN ('Active')"}, + {name: "empty in middle errors", states: []string{"Active", " "}, wantErr: "--state value cannot be empty"}, + {name: "dedupes case-insensitively", states: []string{"Active", "ACTIVE"}, want: "[System.State] IN ('Active')"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := buildStateClause(tc.states) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestRunList_StateExactOnly(t *testing.T) { + t.Parallel() + deps := setupFakeDeps(t, "org") + // no stubDefaultOpenTypes — we don't want state resolution. + stubBatch(t, deps, false) + + var captured string + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + ids := []int{1} + return &workitemtracking.WorkItemQueryResult{WorkItems: &[]workitemtracking.WorkItemReference{{Id: &ids[0]}}}, nil + }, + ) + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + status: []string{"all"}, + state: []string{"Active"}, + }) + require.NoError(t, err) + assert.Contains(t, captured, "[System.State] IN ('Active')") + // With status=all, the category predicate is empty, so no "(...) AND" wrapping. + assert.NotContains(t, captured, ") AND") +} + +func TestRunList_StatusAndStateIntersect(t *testing.T) { + t.Parallel() + deps := setupFakeDeps(t, "org") + stubDefaultOpenTypes(deps) // needed because --status=open triggers state resolution + stubBatch(t, deps, false) + + var captured string + deps.wit.EXPECT().QueryByWiql(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.QueryByWiqlArgs) (*workitemtracking.WorkItemQueryResult, error) { + captured = *args.Wiql.Query + ids := []int{1} + return &workitemtracking.WorkItemQueryResult{WorkItems: &[]workitemtracking.WorkItemReference{{Id: &ids[0]}}}, nil + }, + ) + + err := runList(deps.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + state: []string{"Active"}, + }) + require.NoError(t, err) + // We expect the category predicate (e.g. from "New","Active","Proposed","InProgress") + // ANDed with the state predicate, both inside the state segment. + // The exact form: ( [System.State] IN ('New','Active','Proposed','InProgress') ) AND ( [System.State] IN ('Active') ) + assert.Contains(t, captured, ") AND ([System.State] IN ('Active')") +} + func TestBuildWiqlQuery(t *testing.T) { t.Parallel() @@ -83,7 +467,7 @@ func TestBuildWiqlQuery(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := buildWiqlQuery(tc.project, tc.stateCategory, tc.types, tc.assignedTo, tc.severity, tc.priority, tc.area, tc.iteration) + got := buildWiqlQuery(tc.project, tc.stateCategory, tc.types, tc.assignedTo, tc.severity, tc.priority, tc.area, tc.iteration, "", "", "", "", nil) for _, want := range tc.mustContain { assert.Contains(t, got, want) } @@ -352,6 +736,14 @@ func TestNewCmd_FlagShortcuts(t *testing.T) { for _, want := range []flagCheck{ // --type uses -T because --template (registered by util.AddJSONFlags) // already owns -t across all azdo commands. + {"changed-after", ""}, + {"created-after", ""}, + {"created-by", ""}, + {"authored-by", ""}, + {"sort", ""}, + {"order", ""}, + {"state", ""}, + {"tag", ""}, {"status", "s"}, {"type", "T"}, {"assigned-to", "a"}, From e40e453717c189e4bf40fa8c0ef5dc6a1462bb3b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 16:32:19 +0000 Subject: [PATCH 12/20] =?UTF-8?q?docs(workitem):=20=F0=9F=93=84document=20?= =?UTF-8?q?new=20list=20command=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/azdo_boards_work-item_list.md | 34 +++++++++++++++++++++++++++++- docs/azdo_help_reference.md | 10 ++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/azdo_boards_work-item_list.md b/docs/azdo_boards_work-item_list.md index 7eb9739e..de5a2b84 100644 --- a/docs/azdo_boards_work-item_list.md +++ b/docs/azdo_boards_work-item_list.md @@ -21,10 +21,26 @@ work item details in batches. Filter by assigned-to identity (repeatable); supports emails, descriptors, and @me +* `--authored-by` `strings` + + Alias for --created-by + +* `--changed-after` `string` + + Lower bound on System.ChangedDate (RFC3339, YYYY-MM-DD, or 'today') + * `-c`, `--classification` `strings` Filter by severity classification (repeatable): 1 - Critical, 2 - High, 3 - Medium, 4 - Low +* `--created-after` `string` + + Lower bound on System.CreatedDate (RFC3339, YYYY-MM-DD, or 'today') + +* `--created-by` `strings` + + Filter by creator identity (repeatable); supports email, descriptor, @me + * `--iteration` `strings` Filter by iteration path (repeatable); prefix with Under: to include subtree (e.g., Under:Release 2025/Sprint 1) @@ -37,18 +53,34 @@ work item details in batches. Output JSON with the specified fields. Prefix a field with '-' to exclude it. -* `-L`, `--limit` `int` (default `50`) +* `-L`, `--limit` `int` (default `0`) Maximum number of results to return (>=1) +* `--order` `string` (default `"desc"`) + + Sort direction for all --sort fields: asc or desc + * `-p`, `--priority` `ints` Filter by priority (repeatable): 1-4 +* `--sort` `strings` + + Sort by field (repeatable): changed, created, id, state, title, assigned-to, type, tags + +* `--state` `strings` + + Filter by exact workflow state name (repeatable; combines with --status) + * `-s`, `--status` `strings` (default `[open]`) Filter by state category: open, closed, resolved, all (repeatable) +* `--tag` `strings` + + Filter by tag (repeatable); items must contain all specified tags + * `-t`, `--template` `string` Format JSON output using a Go template; see "azdo help formatting" diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index bf9bb0b0..c5139ace 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -133,13 +133,21 @@ List work items belonging to a project. ``` --area strings Filter by area path (repeatable); prefix with Under: to include subtree (e.g., Under:Web/Payments) -a, --assigned-to strings Filter by assigned-to identity (repeatable); supports emails, descriptors, and @me + --authored-by strings Alias for --created-by + --changed-after string Lower bound on System.ChangedDate (RFC3339, YYYY-MM-DD, or 'today') -c, --classification strings Filter by severity classification (repeatable): 1 - Critical, 2 - High, 3 - Medium, 4 - Low + --created-after string Lower bound on System.CreatedDate (RFC3339, YYYY-MM-DD, or 'today') + --created-by strings Filter by creator identity (repeatable); supports email, descriptor, @me --iteration strings Filter by iteration path (repeatable); prefix with Under: to include subtree (e.g., Under:Release 2025/Sprint 1) -q, --jq expression Filter JSON output using a jq expression --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. --L, --limit int Maximum number of results to return (>=1) (default 50) +-L, --limit int Maximum number of results to return (>=1) + --order string Sort direction for all --sort fields: asc or desc (default "desc") -p, --priority ints Filter by priority (repeatable): 1-4 + --sort strings Sort by field (repeatable): changed, created, id, state, title, assigned-to, type, tags + --state strings Filter by exact workflow state name (repeatable; combines with --status) -s, --status strings Filter by state category: open, closed, resolved, all (repeatable) (default [open]) + --tag strings Filter by tag (repeatable); items must contain all specified tags -t, --template string Format JSON output using a Go template; see "azdo help formatting" -T, --type strings Filter by work item type (repeatable) ``` From e0b9bdb09f69ca22eb82d6225a6588b1dbb349f9 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 16:47:47 +0000 Subject: [PATCH 13/20] =?UTF-8?q?chore:=20=F0=9F=92=85=F0=9F=8F=BC=20repla?= =?UTF-8?q?ce=20interface{}=20with=20any?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modernize the codebase by replacing interface{} with the any alias across variable groups, PR, project, repo, security, and service endpoint packages. Also fixes import ordering and indentation in service endpoint and utility files where encountered. --- internal/azdo/extensions/variablegroups.go | 10 +- .../pipelines/variablegroup/create/create.go | 14 +- .../cmd/pipelines/variablegroup/list/list.go | 2 +- .../cmd/pipelines/variablegroup/show/show.go | 6 +- .../pipelines/variablegroup/update/update.go | 2 +- .../variablegroup/variable/create/create.go | 2 +- .../variablegroup/variable/delete/delete.go | 4 +- .../variablegroup/variable/update/update.go | 6 +- .../variable/update/update_test.go | 58 +++---- internal/cmd/pr/create/create_test.go | 4 +- internal/cmd/pr/diff/diff.go | 2 +- internal/cmd/project/show/show_test.go | 2 +- internal/cmd/repo/create/create_test.go | 2 +- .../security/permission/update/update_test.go | 12 +- .../serviceendpoint/shared/type_registry.go | 57 ++++--- .../cmd/serviceendpoint/shared/wait_ready.go | 95 ++++++----- .../serviceendpoint/shared/wait_ready_test.go | 52 +++--- internal/util/poll.go | 112 ++++++------- internal/util/poll_test.go | 148 +++++++++--------- 19 files changed, 294 insertions(+), 296 deletions(-) diff --git a/internal/azdo/extensions/variablegroups.go b/internal/azdo/extensions/variablegroups.go index 1e635e5c..b285e29a 100644 --- a/internal/azdo/extensions/variablegroups.go +++ b/internal/azdo/extensions/variablegroups.go @@ -27,14 +27,14 @@ type VariableValue struct { } // ToVariableValues converts a map of raw variable data into a slice of VariableValue structs. -func ToVariableValues(vars *map[string]interface{}) []VariableValue { +func ToVariableValues(vars *map[string]any) []VariableValue { if vars == nil { return nil } variables := make([]VariableValue, 0, len(*vars)) for name, val := range *vars { v := VariableValue{Name: name} - if varMap, ok := val.(map[string]interface{}); ok { + if varMap, ok := val.(map[string]any); ok { if isSecret, ok := varMap["isSecret"].(bool); ok { v.IsSecret = isSecret } @@ -48,10 +48,10 @@ func ToVariableValues(vars *map[string]interface{}) []VariableValue { } // FromVariableValues converts a slice of VariableValue structs back into a map for API consumption. -func FromVariableValues(variables []VariableValue) *map[string]interface{} { - vars := make(map[string]interface{}) +func FromVariableValues(variables []VariableValue) *map[string]any { + vars := make(map[string]any) for _, v := range variables { - vars[v.Name] = map[string]interface{}{ + vars[v.Name] = map[string]any{ "value": v.Value, "isSecret": v.IsSecret, } diff --git a/internal/cmd/pipelines/variablegroup/create/create.go b/internal/cmd/pipelines/variablegroup/create/create.go index 5cc1c331..cde88127 100644 --- a/internal/cmd/pipelines/variablegroup/create/create.go +++ b/internal/cmd/pipelines/variablegroup/create/create.go @@ -166,7 +166,7 @@ func run(cmdCtx util.CmdContext, opts *options) error { ) logger.Debug("creating variable group") - var providerData interface{} + var providerData any if keyVaultRequested { providerData, err = buildKeyVaultProviderData(cmdCtx, target.Scope, opts) if err != nil { @@ -248,14 +248,14 @@ func run(cmdCtx util.CmdContext, opts *options) error { return tp.Render() } -func buildVariables(cmdCtx util.CmdContext, ios *iostreams.IOStreams, opts *options, keyVault bool) (*map[string]interface{}, error) { +func buildVariables(cmdCtx util.CmdContext, ios *iostreams.IOStreams, opts *options, keyVault bool) (*map[string]any, error) { if keyVault { return buildKeyVaultVariables(opts) } if len(opts.variableInputs) == 0 && len(opts.secretInputs) == 0 { return nil, nil } - variables := make(map[string]interface{}) + variables := make(map[string]any) seen := make(map[string]string) for _, raw := range opts.variableInputs { @@ -298,11 +298,11 @@ func buildVariables(cmdCtx util.CmdContext, ios *iostreams.IOStreams, opts *opti return &variables, nil } -func buildKeyVaultVariables(opts *options) (*map[string]interface{}, error) { +func buildKeyVaultVariables(opts *options) (*map[string]any, error) { if len(opts.keyVaultSecrets) == 0 { return nil, util.FlagErrorf("at least one --keyvault-secret is required when configuring a Key Vault-backed group") } - variables := make(map[string]interface{}, len(opts.keyVaultSecrets)) + variables := make(map[string]any, len(opts.keyVaultSecrets)) seen := make(map[string]struct{}) for _, raw := range opts.keyVaultSecrets { parts := strings.SplitN(raw, keyValueSeparator, 2) @@ -537,8 +537,8 @@ func resolveSecretValue( return prompter.Secret(fmt.Sprintf("Value for secret %q:", name)) } -func parseProviderDataJSON(raw string) (interface{}, error) { - var payload interface{} +func parseProviderDataJSON(raw string) (any, error) { + var payload any if err := json.Unmarshal([]byte(raw), &payload); err != nil { return nil, util.FlagErrorf("invalid --provider-data-json: %v", err) } diff --git a/internal/cmd/pipelines/variablegroup/list/list.go b/internal/cmd/pipelines/variablegroup/list/list.go index cb74ad63..38147c7c 100644 --- a/internal/cmd/pipelines/variablegroup/list/list.go +++ b/internal/cmd/pipelines/variablegroup/list/list.go @@ -41,7 +41,7 @@ type variableGroupJSON struct { ModifiedBy *identityJSON `json:"modifiedBy,omitempty"` ModifiedOn *string `json:"modifiedOn,omitempty"` ProjectRefs *[]taskagent.VariableGroupProjectReference `json:"projectReferences,omitempty"` - Variables *map[string]interface{} `json:"variables,omitempty"` + Variables *map[string]any `json:"variables,omitempty"` } func NewCmd(ctx util.CmdContext) *cobra.Command { diff --git a/internal/cmd/pipelines/variablegroup/show/show.go b/internal/cmd/pipelines/variablegroup/show/show.go index ba5721c7..4880fd41 100644 --- a/internal/cmd/pipelines/variablegroup/show/show.go +++ b/internal/cmd/pipelines/variablegroup/show/show.go @@ -232,7 +232,7 @@ func run(cmdCtx util.CmdContext, o *opts) error { }, "hasText": func(v *string) bool { return strings.TrimSpace(types.GetValue(v, "")) != "" }, "hasAny": func(v any) bool { return v != nil }, - "vars": func(v *map[string]interface{}) []variableView { + "vars": func(v *map[string]any) []variableView { return expandVariables(v) }, "pipelines": func() []authorizedPipelineView { @@ -320,8 +320,8 @@ func pipelinePermissionIDs(perms *pipelinepermissions.ResourcePipelinePermission return ids } -func expandVariables(vars *map[string]interface{}) []variableView { - varMap := types.GetValue(vars, map[string]interface{}{}) +func expandVariables(vars *map[string]any) []variableView { + varMap := types.GetValue(vars, map[string]any{}) if len(varMap) == 0 { return nil } diff --git a/internal/cmd/pipelines/variablegroup/update/update.go b/internal/cmd/pipelines/variablegroup/update/update.go index b56abca0..d06bb899 100644 --- a/internal/cmd/pipelines/variablegroup/update/update.go +++ b/internal/cmd/pipelines/variablegroup/update/update.go @@ -161,7 +161,7 @@ func run(cmdCtx util.CmdContext, o *opts) error { } if o.providerDataJSONChanged { // parse JSON - var pd interface{} + var pd any if err := json.Unmarshal([]byte(o.providerDataJSON), &pd); err != nil { return util.FlagErrorWrap(fmt.Errorf("invalid provider-data-json: %w", err)) } diff --git a/internal/cmd/pipelines/variablegroup/variable/create/create.go b/internal/cmd/pipelines/variablegroup/variable/create/create.go index 4f064e35..2163cdb6 100644 --- a/internal/cmd/pipelines/variablegroup/variable/create/create.go +++ b/internal/cmd/pipelines/variablegroup/variable/create/create.go @@ -130,7 +130,7 @@ func run(cmdCtx util.CmdContext, opts *opts) error { } // Insert into variables map - newVars := map[string]interface{}{} + newVars := map[string]any{} if group.Variables != nil { for k, v := range *group.Variables { newVars[k] = v diff --git a/internal/cmd/pipelines/variablegroup/variable/delete/delete.go b/internal/cmd/pipelines/variablegroup/variable/delete/delete.go index 5ee7281c..e5485f50 100644 --- a/internal/cmd/pipelines/variablegroup/variable/delete/delete.go +++ b/internal/cmd/pipelines/variablegroup/variable/delete/delete.go @@ -81,7 +81,7 @@ func run(cmdCtx util.CmdContext, opts *opts) error { // Find variable key case-insensitively var foundKey string - var vars map[string]interface{} + var vars map[string]any if group.Variables != nil { vars = *group.Variables for k := range vars { @@ -118,7 +118,7 @@ func run(cmdCtx util.CmdContext, opts *opts) error { } // Remove the variable and call UpdateVariableGroup - newVars := make(map[string]interface{}) + newVars := make(map[string]any) for k, v := range vars { if k == foundKey { continue diff --git a/internal/cmd/pipelines/variablegroup/variable/update/update.go b/internal/cmd/pipelines/variablegroup/variable/update/update.go index 793a05e3..ab3360c9 100644 --- a/internal/cmd/pipelines/variablegroup/variable/update/update.go +++ b/internal/cmd/pipelines/variablegroup/variable/update/update.go @@ -189,7 +189,7 @@ func run(cmdCtx util.CmdContext, cmd *cobra.Command, opts *opts) error { var isSecret bool var isReadOnly bool var currentValue any - if m, ok := origVal.(map[string]interface{}); ok { + if m, ok := origVal.(map[string]any); ok { if s, ok := m["isSecret"].(bool); ok { isSecret = s } @@ -216,7 +216,7 @@ func run(cmdCtx util.CmdContext, cmd *cobra.Command, opts *opts) error { } // Prepare mutated variables map - newVars := map[string]interface{}{} + newVars := map[string]any{} if group.Variables != nil { for k, v := range *group.Variables { newVars[k] = v @@ -307,7 +307,7 @@ func run(cmdCtx util.CmdContext, cmd *cobra.Command, opts *opts) error { } // Insert updated entry - newVars[finalKey] = map[string]interface{}{ + newVars[finalKey] = map[string]any{ "value": finalValue, "isSecret": finalIsSecret, "isReadOnly": finalIsReadOnly, diff --git a/internal/cmd/pipelines/variablegroup/variable/update/update_test.go b/internal/cmd/pipelines/variablegroup/variable/update/update_test.go index a820a914..96f3b2f3 100644 --- a/internal/cmd/pipelines/variablegroup/variable/update/update_test.go +++ b/internal/cmd/pipelines/variablegroup/variable/update/update_test.go @@ -36,8 +36,8 @@ func TestUpdateVariable_ValueAndFlags_JSON(t *testing.T) { require.Equal(t, "project", types.GetValue(args.Project, "")) require.Len(t, types.GetValue(args.GroupIds, []int{}), 1) assert.Equal(t, 123, types.GetValue(&types.GetValue(args.GroupIds, []int{})[0], 0)) - vars := map[string]interface{}{ - "FOO": map[string]interface{}{ + vars := map[string]any{ + "FOO": map[string]any{ "value": "old", "isSecret": false, "isReadOnly": false, @@ -54,8 +54,8 @@ func TestUpdateVariable_ValueAndFlags_JSON(t *testing.T) { func(_ context.Context, args taskagent.UpdateVariableGroupArgs) (*taskagent.VariableGroup, error) { require.NotNil(t, args.VariableGroupParameters) require.NotNil(t, args.VariableGroupParameters.Variables) - updated := types.GetValue(args.VariableGroupParameters.Variables, map[string]interface{}{}) - val, ok := updated["FOO"].(map[string]interface{}) + updated := types.GetValue(args.VariableGroupParameters.Variables, map[string]any{}) + val, ok := updated["FOO"].(map[string]any) require.True(t, ok) assert.Equal(t, "new", val["value"]) assert.Equal(t, false, val["isSecret"]) @@ -96,8 +96,8 @@ func TestUpdateVariable_SecretPrompt_RedactsJSON(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{ - "SECRET": map[string]interface{}{ + Variables: &map[string]any{ + "SECRET": map[string]any{ "value": nil, "isSecret": true, "isReadOnly": false, @@ -108,8 +108,8 @@ func TestUpdateVariable_SecretPrompt_RedactsJSON(t *testing.T) { prompter.EXPECT().Secret("Value for secret \"secret\":").Return("s3cr3t", nil) taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, args taskagent.UpdateVariableGroupArgs) (*taskagent.VariableGroup, error) { - updated := types.GetValue(args.VariableGroupParameters.Variables, map[string]interface{}{}) - val, _ := updated["SECRET"].(map[string]interface{}) + updated := types.GetValue(args.VariableGroupParameters.Variables, map[string]any{}) + val, _ := updated["SECRET"].(map[string]any) assert.Equal(t, "s3cr3t", val["value"]) assert.Equal(t, true, val["isSecret"]) return &taskagent.VariableGroup{}, nil @@ -154,8 +154,8 @@ func TestUpdateVariable_ClearValue_YesSkipsPrompt(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{ - "FOO": map[string]interface{}{ + Variables: &map[string]any{ + "FOO": map[string]any{ "value": "keepme", "isSecret": false, "isReadOnly": false, @@ -165,7 +165,7 @@ func TestUpdateVariable_ClearValue_YesSkipsPrompt(t *testing.T) { ) taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, args taskagent.UpdateVariableGroupArgs) (*taskagent.VariableGroup, error) { - val, _ := types.GetValue(args.VariableGroupParameters.Variables, map[string]interface{}{})["FOO"].(map[string]interface{}) + val, _ := types.GetValue(args.VariableGroupParameters.Variables, map[string]any{})["FOO"].(map[string]any) assert.Nil(t, val["value"]) return &taskagent.VariableGroup{}, nil }, @@ -248,9 +248,9 @@ func TestUpdateVariable_RenameCollision(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{ - "FOO": map[string]interface{}{"value": "v"}, - "BAR": map[string]interface{}{"value": "x"}, + Variables: &map[string]any{ + "FOO": map[string]any{"value": "v"}, + "BAR": map[string]any{"value": "x"}, }, }}, nil, ) @@ -284,8 +284,8 @@ func TestUpdateVariable_ClearSecretValueFails(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{ - "SECRET": map[string]interface{}{ + Variables: &map[string]any{ + "SECRET": map[string]any{ "value": nil, "isSecret": true, "isReadOnly": false, @@ -321,7 +321,7 @@ func TestUpdateVariable_KeyVaultRejectsValueChange(t *testing.T) { Id: types.ToPtr(123), Name: types.ToPtr("group"), Type: types.ToPtr("AzureKeyVault"), - Variables: &map[string]interface{}{"FOO": map[string]interface{}{"value": "old", "isSecret": false}}, + Variables: &map[string]any{"FOO": map[string]any{"value": "old", "isSecret": false}}, }}, nil, ) taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).Times(0) @@ -352,8 +352,8 @@ func TestUpdateVariable_FromJSON_SecretTrueWithoutValue(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{ - "FOO": map[string]interface{}{"value": "old", "isSecret": false}, + Variables: &map[string]any{ + "FOO": map[string]any{"value": "old", "isSecret": false}, }, }}, nil, ) @@ -385,8 +385,8 @@ func TestUpdateVariable_PromptValue_DisabledPrompt(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{ - "SECRET": map[string]interface{}{"value": nil, "isSecret": true}, + Variables: &map[string]any{ + "SECRET": map[string]any{"value": nil, "isSecret": true}, }, }}, nil, ) @@ -449,15 +449,15 @@ func TestUpdateVariable_SecretValueFlag_RedactsJSON(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{ - "SECRET": map[string]interface{}{"value": nil, "isSecret": true, "isReadOnly": false}, + Variables: &map[string]any{ + "SECRET": map[string]any{"value": nil, "isSecret": true, "isReadOnly": false}, }, }}, nil, ) taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, args taskagent.UpdateVariableGroupArgs) (*taskagent.VariableGroup, error) { - valMap := types.GetValue(args.VariableGroupParameters.Variables, map[string]interface{}{}) - val, _ := valMap["SECRET"].(map[string]interface{}) + valMap := types.GetValue(args.VariableGroupParameters.Variables, map[string]any{}) + val, _ := valMap["SECRET"].(map[string]any) assert.Equal(t, "rotated", val["value"]) assert.Equal(t, true, val["isSecret"]) assert.Equal(t, false, val["isReadOnly"]) @@ -494,8 +494,8 @@ func TestUpdateVariable_ClearValue_PromptCancel(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{ - "FOO": map[string]interface{}{"value": "keep", "isSecret": false}, + Variables: &map[string]any{ + "FOO": map[string]any{"value": "keep", "isSecret": false}, }, }}, nil, ) @@ -528,7 +528,7 @@ func TestUpdateVariable_VariableNotFound(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{"BAR": map[string]interface{}{"value": "x"}}, + Variables: &map[string]any{"BAR": map[string]any{"value": "x"}}, }}, nil, ) taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).Times(0) @@ -559,7 +559,7 @@ func TestUpdateVariable_FromJSON_DisallowedNameKey(t *testing.T) { &[]taskagent.VariableGroup{{ Id: types.ToPtr(123), Name: types.ToPtr("group"), - Variables: &map[string]interface{}{"FOO": map[string]interface{}{"value": "x"}}, + Variables: &map[string]any{"FOO": map[string]any{"value": "x"}}, }}, nil, ) taskClient.EXPECT().UpdateVariableGroup(gomock.Any(), gomock.Any()).Times(0) diff --git a/internal/cmd/pr/create/create_test.go b/internal/cmd/pr/create/create_test.go index 9a5ee1ba..7b8cc005 100644 --- a/internal/cmd/pr/create/create_test.go +++ b/internal/cmd/pr/create/create_test.go @@ -873,13 +873,13 @@ func TestPullRequest_CreatesWithReviewers_RequiredAndOptional(t *testing.T) { bobID := uuid.New() // Mock for alice - mIdentity.EXPECT().ReadIdentities(gomock.Any(), gomock.Cond(func(x interface{}) bool { + mIdentity.EXPECT().ReadIdentities(gomock.Any(), gomock.Cond(func(x any) bool { args, ok := x.(aidentity.ReadIdentitiesArgs) return ok && args.FilterValue != nil && *args.FilterValue == "alice@example.org" })).Return(&[]aidentity.Identity{{Id: &aliceID}}, nil) // Mock for bob - mIdentity.EXPECT().ReadIdentities(gomock.Any(), gomock.Cond(func(x interface{}) bool { + mIdentity.EXPECT().ReadIdentities(gomock.Any(), gomock.Cond(func(x any) bool { args, ok := x.(aidentity.ReadIdentitiesArgs) return ok && args.FilterValue != nil && *args.FilterValue == "bob@example.org" })).Return(&[]aidentity.Identity{{Id: &bobID}}, nil) diff --git a/internal/cmd/pr/diff/diff.go b/internal/cmd/pr/diff/diff.go index 3131d14f..471342e4 100644 --- a/internal/cmd/pr/diff/diff.go +++ b/internal/cmd/pr/diff/diff.go @@ -107,7 +107,7 @@ func runCmd(ctx util.CmdContext, opts *diffOptions) (err error) { return fmt.Errorf("failed to get pull request diff: %w", err) } - // Process and display the diff, handling both GitItem and map[string]interface{} types + // Process and display the diff, handling both GitItem and map[string]any types if opts.nameOnly { for _, change := range *diffs.ChangeEntries { if gitItem, ok := change.Item.(*git.GitItem); ok && gitItem.Path != nil { diff --git a/internal/cmd/project/show/show_test.go b/internal/cmd/project/show/show_test.go index 184be640..9e3f4c5c 100644 --- a/internal/cmd/project/show/show_test.go +++ b/internal/cmd/project/show/show_test.go @@ -19,7 +19,7 @@ import ( ) func TestProjectShow(t *testing.T) { - type D = map[string]interface{} + type D = map[string]any projectID := uuid.New() projectName := "TestProject" orgName := "TestOrg" diff --git a/internal/cmd/repo/create/create_test.go b/internal/cmd/repo/create/create_test.go index 437d9fac..e00a3bdf 100644 --- a/internal/cmd/repo/create/create_test.go +++ b/internal/cmd/repo/create/create_test.go @@ -176,7 +176,7 @@ func TestRunCreate_APIInvocationAndOutput(t *testing.T) { // Expect repository creation call uuidPtr := uuid.New() mockGitClient.EXPECT().CreateRepository(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ interface{}, args git.CreateRepositoryArgs) (*git.GitRepository, error) { + func(_ any, args git.CreateRepositoryArgs) (*git.GitRepository, error) { // Spec‑driven assertions require.NotNil(t, args.GitRepositoryToCreate) require.Equal(t, "repo1", *args.GitRepositoryToCreate.Name) diff --git a/internal/cmd/security/permission/update/update_test.go b/internal/cmd/security/permission/update/update_test.go index a8ae2c1e..dd558e57 100644 --- a/internal/cmd/security/permission/update/update_test.go +++ b/internal/cmd/security/permission/update/update_test.go @@ -63,7 +63,7 @@ func TestUpdate_SetsACE_Success(t *testing.T) { // Expect SetAccessControlEntries to be called with a container containing token, merge flag and ACE with descriptor "acl-descriptor" mSecurityClient.EXPECT().SetAccessControlEntries(gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]interface{}, error) { + func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]any, error) { // Basic runtime checks to ensure arguments look correct if args.SecurityNamespaceId == nil { return nil, fmt.Errorf("SecurityNamespaceId is nil") @@ -75,7 +75,7 @@ func TestUpdate_SetsACE_Success(t *testing.T) { if args.Container == nil { return nil, fmt.Errorf("container is nil") } - // Verify token present - Container is typed as interface{} in the SDK, so assert via type switch + // Verify token present - Container is typed as any in the SDK, so assert via type switch switch c := args.Container.(type) { case AccessControlEntryUpdate: if c.Token != o.token { @@ -183,12 +183,12 @@ func TestUpdate_SetsDenyBit(t *testing.T) { // Expect SetAccessControlEntries and assert deny bit presence mSecurityClient.EXPECT().SetAccessControlEntries(gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]interface{}, error) { + func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]any, error) { if args.Container == nil { return nil, fmt.Errorf("container is nil") } switch c := args.Container.(type) { - case map[string]interface{}: + case map[string]any: // check deny exists on ACE if acesI, ok := c["accessControlEntries"]; ok { switch a := acesI.(type) { @@ -251,12 +251,12 @@ func TestUpdate_NoMergeFlag(t *testing.T) { // Expect SetAccessControlEntries and assert merge flag false mSecurityClient.EXPECT().SetAccessControlEntries(gomock.Any(), gomock.Any()).DoAndReturn( - func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]interface{}, error) { + func(ctx context.Context, args security.SetAccessControlEntriesArgs) (*[]any, error) { if args.Container == nil { return nil, fmt.Errorf("container is nil") } switch c := args.Container.(type) { - case map[string]interface{}: + case map[string]any: if mv, ok := c["merge"].(bool); !ok { return nil, fmt.Errorf("merge flag missing or not bool") } else if mv { diff --git a/internal/cmd/serviceendpoint/shared/type_registry.go b/internal/cmd/serviceendpoint/shared/type_registry.go index b1823ff2..1f469d1f 100644 --- a/internal/cmd/serviceendpoint/shared/type_registry.go +++ b/internal/cmd/serviceendpoint/shared/type_registry.go @@ -1,11 +1,11 @@ package shared import ( - "fmt" - "sync" + "fmt" + "sync" - "github.com/tmeckel/azdo-cli/internal/cmd/util" - "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" + "github.com/tmeckel/azdo-cli/internal/cmd/util" ) var typesCache sync.Map // map[string][]serviceendpoint.ServiceEndpointType @@ -13,36 +13,35 @@ var typesCache sync.Map // map[string][]serviceendpoint.ServiceEndpointType // GetServiceEndpointTypes fetches service endpoint types for an organization and caches them for // the duration of the command process. It uses the vendored Azure DevOps SDK. func GetServiceEndpointTypes(cmdCtx util.CmdContext, organization string) ([]serviceendpoint.ServiceEndpointType, error) { - if organization == "" { - return nil, fmt.Errorf("organization required") - } - if v, ok := typesCache.Load(organization); ok { - return v.([]serviceendpoint.ServiceEndpointType), nil - } - - client, err := cmdCtx.ClientFactory().ServiceEndpoint(cmdCtx.Context(), organization) - if err != nil { - return nil, fmt.Errorf("create serviceendpoint client: %w", err) - } - - res, err := client.GetServiceEndpointTypes(cmdCtx.Context(), serviceendpoint.GetServiceEndpointTypesArgs{}) - if err != nil { - return nil, fmt.Errorf("get service endpoint types: %w", err) - } - if res == nil { - return nil, fmt.Errorf("no service endpoint types returned") - } - - typesCache.Store(organization, *res) - return *res, nil + if organization == "" { + return nil, fmt.Errorf("organization required") + } + if v, ok := typesCache.Load(organization); ok { + return v.([]serviceendpoint.ServiceEndpointType), nil + } + + client, err := cmdCtx.ClientFactory().ServiceEndpoint(cmdCtx.Context(), organization) + if err != nil { + return nil, fmt.Errorf("create serviceendpoint client: %w", err) + } + + res, err := client.GetServiceEndpointTypes(cmdCtx.Context(), serviceendpoint.GetServiceEndpointTypesArgs{}) + if err != nil { + return nil, fmt.Errorf("get service endpoint types: %w", err) + } + if res == nil { + return nil, fmt.Errorf("no service endpoint types returned") + } + + typesCache.Store(organization, *res) + return *res, nil } // Helpers for tests func setTypesCacheForTest(org string, types []serviceendpoint.ServiceEndpointType) { - typesCache.Store(org, types) + typesCache.Store(org, types) } func clearTypesCacheForTest() { - typesCache = sync.Map{} + typesCache = sync.Map{} } - diff --git a/internal/cmd/serviceendpoint/shared/wait_ready.go b/internal/cmd/serviceendpoint/shared/wait_ready.go index 10f93b4d..fff7e613 100644 --- a/internal/cmd/serviceendpoint/shared/wait_ready.go +++ b/internal/cmd/serviceendpoint/shared/wait_ready.go @@ -1,59 +1,58 @@ package shared import ( - "context" - "errors" - "fmt" - "time" + "context" + "errors" + "fmt" + "time" - pollutil "github.com/tmeckel/azdo-cli/internal/util" - "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" + pollutil "github.com/tmeckel/azdo-cli/internal/util" ) // WaitForReady polls GetServiceEndpointDetails until IsReady==true or a terminal // failed state is detected in OperationStatus, or context/timeout occurs. func WaitForReady(ctx context.Context, client serviceendpoint.Client, project string, ep *serviceendpoint.ServiceEndpoint, timeout time.Duration) (*serviceendpoint.ServiceEndpoint, error) { - if ep == nil || ep.Id == nil { - return nil, fmt.Errorf("endpoint or id missing") - } - opts := pollutil.PollOptions{Tries: 0} - if timeout > 0 { - opts.Timeout = timeout - } + if ep == nil || ep.Id == nil { + return nil, fmt.Errorf("endpoint or id missing") + } + opts := pollutil.PollOptions{Tries: 0} + if timeout > 0 { + opts.Timeout = timeout + } - var last *serviceendpoint.ServiceEndpoint - err := pollutil.Poll(ctx, func() error { - id := *ep.Id - res, err := client.GetServiceEndpointDetails(ctx, serviceendpoint.GetServiceEndpointDetailsArgs{ - Project: &project, - EndpointId: &id, - }) - if err != nil { - // transient error, retry - last = nil - return err - } - last = res - if res != nil && res.IsReady != nil && *res.IsReady { - return nil - } - // Inspect OperationStatus defensively for a failure signal - if res != nil && res.OperationStatus != nil { - if opMap, ok := res.OperationStatus.(map[string]any); ok { - if stateRaw, ok := opMap["state"]; ok { - if stateStr, ok := stateRaw.(string); ok { - if stateStr == "failed" { - return fmt.Errorf("service endpoint creation failed: %v", res.OperationStatus) - } - } - } - } - } - return errors.New("not ready") - }, opts) - - if err != nil { - return last, err - } - return last, nil + var last *serviceendpoint.ServiceEndpoint + err := pollutil.Poll(ctx, func() error { + id := *ep.Id + res, err := client.GetServiceEndpointDetails(ctx, serviceendpoint.GetServiceEndpointDetailsArgs{ + Project: &project, + EndpointId: &id, + }) + if err != nil { + // transient error, retry + last = nil + return err + } + last = res + if res != nil && res.IsReady != nil && *res.IsReady { + return nil + } + // Inspect OperationStatus defensively for a failure signal + if res != nil && res.OperationStatus != nil { + if opMap, ok := res.OperationStatus.(map[string]any); ok { + if stateRaw, ok := opMap["state"]; ok { + if stateStr, ok := stateRaw.(string); ok { + if stateStr == "failed" { + return fmt.Errorf("service endpoint creation failed: %v", res.OperationStatus) + } + } + } + } + } + return errors.New("not ready") + }, opts) + if err != nil { + return last, err + } + return last, nil } diff --git a/internal/cmd/serviceendpoint/shared/wait_ready_test.go b/internal/cmd/serviceendpoint/shared/wait_ready_test.go index 7b8e8a72..71abd101 100644 --- a/internal/cmd/serviceendpoint/shared/wait_ready_test.go +++ b/internal/cmd/serviceendpoint/shared/wait_ready_test.go @@ -1,47 +1,47 @@ package shared import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" - "github.com/tmeckel/azdo-cli/internal/mocks" - "go.uber.org/mock/gomock" + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" + "github.com/tmeckel/azdo-cli/internal/mocks" + "go.uber.org/mock/gomock" ) func TestWaitForReadySuccess(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mock := mocks.NewMockServiceEndpointClient(ctrl) - // first call returns not ready, second returns ready - id := uuid.New() - ep := serviceendpoint.ServiceEndpoint{Id: &id} + mock := mocks.NewMockServiceEndpointClient(ctrl) + // first call returns not ready, second returns ready + id := uuid.New() + ep := serviceendpoint.ServiceEndpoint{Id: &id} - mock.EXPECT().GetServiceEndpointDetails(gomock.Any(), gomock.Any()).Return(&serviceendpoint.ServiceEndpoint{Id: &id, IsReady: newTrue()}, nil).AnyTimes() + mock.EXPECT().GetServiceEndpointDetails(gomock.Any(), gomock.Any()).Return(&serviceendpoint.ServiceEndpoint{Id: &id, IsReady: newTrue()}, nil).AnyTimes() - _, err := WaitForReady(context.Background(), mock, "proj", &ep, 1*time.Second) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } + _, err := WaitForReady(context.Background(), mock, "proj", &ep, 1*time.Second) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } } func TestWaitForReadyFailedState(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mock := mocks.NewMockServiceEndpointClient(ctrl) - id := uuid.New() - op := map[string]any{"state": "failed"} - mock.EXPECT().GetServiceEndpointDetails(gomock.Any(), gomock.Any()).Return(&serviceendpoint.ServiceEndpoint{Id: &id, OperationStatus: op}, nil).AnyTimes() + mock := mocks.NewMockServiceEndpointClient(ctrl) + id := uuid.New() + op := map[string]any{"state": "failed"} + mock.EXPECT().GetServiceEndpointDetails(gomock.Any(), gomock.Any()).Return(&serviceendpoint.ServiceEndpoint{Id: &id, OperationStatus: op}, nil).AnyTimes() - ep := serviceendpoint.ServiceEndpoint{Id: &id} - _, err := WaitForReady(context.Background(), mock, "proj", &ep, 500*time.Millisecond) - if err == nil { - t.Fatalf("expected error for failed state") - } + ep := serviceendpoint.ServiceEndpoint{Id: &id} + _, err := WaitForReady(context.Background(), mock, "proj", &ep, 500*time.Millisecond) + if err == nil { + t.Fatalf("expected error for failed state") + } } func newTrue() *bool { b := true; return &b } diff --git a/internal/util/poll.go b/internal/util/poll.go index ef2c9658..b5950e8e 100644 --- a/internal/util/poll.go +++ b/internal/util/poll.go @@ -1,10 +1,10 @@ package util import ( - "context" - "fmt" - "math" - "time" + "context" + "fmt" + "math" + "time" ) // PollFunc is the function to be executed by the Poll function. @@ -31,66 +31,66 @@ type PollOptions struct { // The function respects the provided context for cancellation. If `opts.Timeout` // is non-zero, the timeout is applied in addition to the provided context. func Poll(ctx context.Context, fn PollFunc, opts PollOptions) error { - var lastErr error + var lastErr error - // If neither tries nor timeout are set, default to a single try (legacy behavior) - if opts.Tries == 0 && opts.Timeout == 0 { - opts.Tries = 1 - } + // If neither tries nor timeout are set, default to a single try (legacy behavior) + if opts.Tries == 0 && opts.Timeout == 0 { + opts.Tries = 1 + } - // If a timeout is specified, derive a child context so we can cancel after timeout. - if opts.Timeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, opts.Timeout) - defer cancel() - } + // If a timeout is specified, derive a child context so we can cancel after timeout. + if opts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, opts.Timeout) + defer cancel() + } - const maxDelay = 30 * time.Second + const maxDelay = 30 * time.Second - for i := 0; opts.Tries == 0 || i < opts.Tries; i++ { - select { - case <-ctx.Done(): - // prefer returning the context error - return ctx.Err() - default: - } + for i := 0; opts.Tries == 0 || i < opts.Tries; i++ { + select { + case <-ctx.Done(): + // prefer returning the context error + return ctx.Err() + default: + } - err := fn() - if err == nil { - return nil - } - lastErr = err + err := fn() + if err == nil { + return nil + } + lastErr = err - if opts.Tries > 0 && i == opts.Tries-1 { - break - } + if opts.Tries > 0 && i == opts.Tries-1 { + break + } - var wait time.Duration - if opts.Delay > 0 { - wait = opts.Delay - } else { - // Binary exponential backoff with initial 2 seconds: 2^i * 2s - wait = time.Duration(math.Pow(2, float64(i))) * 2 * time.Second - if wait > maxDelay { - wait = maxDelay - } - } + var wait time.Duration + if opts.Delay > 0 { + wait = opts.Delay + } else { + // Binary exponential backoff with initial 2 seconds: 2^i * 2s + wait = time.Duration(math.Pow(2, float64(i))) * 2 * time.Second + if wait > maxDelay { + wait = maxDelay + } + } - // Sleep but wake early if context is done - select { - case <-time.After(wait): - // continue to next iteration - case <-ctx.Done(): - return ctx.Err() - } - } + // Sleep but wake early if context is done + select { + case <-time.After(wait): + // continue to next iteration + case <-ctx.Done(): + return ctx.Err() + } + } - if lastErr != nil { - if opts.Tries > 0 { - return fmt.Errorf("after %d attempts, last error: %w", opts.Tries, lastErr) - } - return fmt.Errorf("last error: %w", lastErr) - } + if lastErr != nil { + if opts.Tries > 0 { + return fmt.Errorf("after %d attempts, last error: %w", opts.Tries, lastErr) + } + return fmt.Errorf("last error: %w", lastErr) + } - return fmt.Errorf("polling failed without returning an error") + return fmt.Errorf("polling failed without returning an error") } diff --git a/internal/util/poll_test.go b/internal/util/poll_test.go index d90de2bf..9c5da445 100644 --- a/internal/util/poll_test.go +++ b/internal/util/poll_test.go @@ -1,100 +1,100 @@ package util import ( - "context" - "errors" - "testing" - "time" + "context" + "errors" + "testing" + "time" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/require" ) func TestPollDefaultsToSingleTry(t *testing.T) { - var calls int + var calls int - err := Poll(context.Background(), func() error { - calls++ - return nil - }, PollOptions{}) + err := Poll(context.Background(), func() error { + calls++ + return nil + }, PollOptions{}) - require.NoError(t, err) - require.Equal(t, 1, calls) + require.NoError(t, err) + require.Equal(t, 1, calls) } func TestPollRetriesUntilSuccess(t *testing.T) { - var calls int - errBoom := errors.New("temporary failure") - - err := Poll(context.Background(), func() error { - calls++ - if calls < 3 { - return errBoom - } - return nil - }, PollOptions{ - Tries: 5, - Delay: time.Millisecond, - }) - - require.NoError(t, err) - require.Equal(t, 3, calls) + var calls int + errBoom := errors.New("temporary failure") + + err := Poll(context.Background(), func() error { + calls++ + if calls < 3 { + return errBoom + } + return nil + }, PollOptions{ + Tries: 5, + Delay: time.Millisecond, + }) + + require.NoError(t, err) + require.Equal(t, 3, calls) } func TestPollReturnsAfterMaxAttempts(t *testing.T) { - var calls int - errBoom := errors.New("permanent failure") - - err := Poll(context.Background(), func() error { - calls++ - return errBoom - }, PollOptions{ - Tries: 3, - Delay: time.Millisecond, - }) - - require.Error(t, err) - require.ErrorContains(t, err, "after 3 attempts") - require.ErrorIs(t, err, errBoom) - require.Equal(t, 3, calls) + var calls int + errBoom := errors.New("permanent failure") + + err := Poll(context.Background(), func() error { + calls++ + return errBoom + }, PollOptions{ + Tries: 3, + Delay: time.Millisecond, + }) + + require.Error(t, err) + require.ErrorContains(t, err, "after 3 attempts") + require.ErrorIs(t, err, errBoom) + require.Equal(t, 3, calls) } func TestPollTimeout(t *testing.T) { - var calls int - errBoom := errors.New("timeout failure") - - err := Poll(context.Background(), func() error { - calls++ - return errBoom - }, PollOptions{ - Delay: 20 * time.Millisecond, - Timeout: 30 * time.Millisecond, - }) - - require.Error(t, err) - require.ErrorContains(t, err, "context deadline exceeded") - require.GreaterOrEqual(t, calls, 1) + var calls int + errBoom := errors.New("timeout failure") + + err := Poll(context.Background(), func() error { + calls++ + return errBoom + }, PollOptions{ + Delay: 20 * time.Millisecond, + Timeout: 30 * time.Millisecond, + }) + + require.Error(t, err) + require.ErrorContains(t, err, "context deadline exceeded") + require.GreaterOrEqual(t, calls, 1) } func TestPollRespectsContextCancel(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() - err := Poll(ctx, func() error { return errors.New("no-op") }, PollOptions{Delay: time.Millisecond}) - require.ErrorIs(t, err, context.Canceled) + err := Poll(ctx, func() error { return errors.New("no-op") }, PollOptions{Delay: time.Millisecond}) + require.ErrorIs(t, err, context.Canceled) } func TestPollBinaryExponentialBackoff(t *testing.T) { - var calls int - err := Poll(context.Background(), func() error { - calls++ - if calls >= 3 { - return nil - } - return errors.New("simulated failure") - }, PollOptions{ - Tries: 4, - }) - - require.NoError(t, err) - require.GreaterOrEqual(t, calls, 3) + var calls int + err := Poll(context.Background(), func() error { + calls++ + if calls >= 3 { + return nil + } + return errors.New("simulated failure") + }, PollOptions{ + Tries: 4, + }) + + require.NoError(t, err) + require.GreaterOrEqual(t, calls, 3) } From 229bc10778e45ab35a3abdd0d5120edb59ffc77b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 17:06:11 +0000 Subject: [PATCH 14/20] feat(workitem): add priority sort field --- internal/cmd/boards/workitem/list/list.go | 4 ++-- internal/cmd/boards/workitem/list/list_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/boards/workitem/list/list.go b/internal/cmd/boards/workitem/list/list.go index 2c320c9c..25936d65 100644 --- a/internal/cmd/boards/workitem/list/list.go +++ b/internal/cmd/boards/workitem/list/list.go @@ -89,7 +89,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.Flags().StringVar(&opts.changedAfter, "changed-after", "", "Lower bound on System.ChangedDate (RFC3339, YYYY-MM-DD, or 'today')") cmd.Flags().StringVar(&opts.createdAfter, "created-after", "", "Lower bound on System.CreatedDate (RFC3339, YYYY-MM-DD, or 'today')") cmd.Flags().StringVar(&opts.sortOrder, "order", "desc", "Sort direction for all --sort fields: asc or desc") - cmd.Flags().StringSliceVar(&opts.sortFields, "sort", nil, "Sort by field (repeatable): changed, created, id, state, title, assigned-to, type, tags") + cmd.Flags().StringSliceVar(&opts.sortFields, "sort", nil, "Sort by field (repeatable): changed, created, id, priority, state, title, assigned-to, type, tags") cmd.Flags().StringSliceVarP(&opts.status, "status", "s", []string{"open"}, "Filter by state category: open, closed, resolved, all (repeatable)") cmd.Flags().StringSliceVarP(&opts.workItemTypes, "type", "T", nil, "Filter by work item type (repeatable)") cmd.Flags().StringSliceVarP(&opts.assignedTo, "assigned-to", "a", nil, "Filter by assigned-to identity (repeatable); supports emails, descriptors, and @me") @@ -703,7 +703,7 @@ func resolveSort(fields []string, order string) (string, error) { key := strings.ToLower(strings.TrimSpace(raw)) ref, ok := sortFieldMap[key] if !ok { - return "", util.FlagErrorf("invalid --sort field %q (valid: changed, created, id, state, title, assigned-to, type, tags)", raw) + return "", util.FlagErrorf("invalid --sort field %q (valid: changed, created, id, priority, state, title, assigned-to, type, tags)", raw) } parts = append(parts, ref+" "+strings.ToUpper(dir)) } diff --git a/internal/cmd/boards/workitem/list/list_test.go b/internal/cmd/boards/workitem/list/list_test.go index bbda535b..1ed78f08 100644 --- a/internal/cmd/boards/workitem/list/list_test.go +++ b/internal/cmd/boards/workitem/list/list_test.go @@ -48,7 +48,7 @@ func TestResolveSort(t *testing.T) { {name: "invalid order", fields: []string{"id"}, order: "sideways", wantErr: "invalid --order"}, {name: "default direction is desc for changed/created/id", fields: []string{"id"}, order: "", want: "ORDER BY [System.Id] DESC"}, {name: "default direction is asc for others", fields: []string{"state"}, order: "", want: "ORDER BY [System.State] ASC"}, - {name: "all field mappings", fields: []string{"created", "assigned-to", "type", "tags"}, order: "asc", want: "ORDER BY [System.CreatedDate] ASC, [System.AssignedTo] ASC, [System.WorkItemType] ASC, [System.Tags] ASC"}, + {name: "all field mappings", fields: []string{"created", "priority", "assigned-to", "type", "tags"}, order: "asc", want: "ORDER BY [System.CreatedDate] ASC, [Microsoft.VSTS.Common.Priority] ASC, [System.AssignedTo] ASC, [System.WorkItemType] ASC, [System.Tags] ASC"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { From a89d6b99ee52ac87b1119c108213e3f4aa8f648b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 17:06:50 +0000 Subject: [PATCH 15/20] =?UTF-8?q?docs(workitem):=20=F0=9F=93=84=20add=20pr?= =?UTF-8?q?iority=20to=20sort=20field=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/azdo_boards_work-item_list.md | 2 +- docs/azdo_help_reference.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/azdo_boards_work-item_list.md b/docs/azdo_boards_work-item_list.md index de5a2b84..8fa4587d 100644 --- a/docs/azdo_boards_work-item_list.md +++ b/docs/azdo_boards_work-item_list.md @@ -67,7 +67,7 @@ work item details in batches. * `--sort` `strings` - Sort by field (repeatable): changed, created, id, state, title, assigned-to, type, tags + Sort by field (repeatable): changed, created, id, priority, state, title, assigned-to, type, tags * `--state` `strings` diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index c5139ace..e645ec29 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -144,7 +144,7 @@ List work items belonging to a project. -L, --limit int Maximum number of results to return (>=1) --order string Sort direction for all --sort fields: asc or desc (default "desc") -p, --priority ints Filter by priority (repeatable): 1-4 - --sort strings Sort by field (repeatable): changed, created, id, state, title, assigned-to, type, tags + --sort strings Sort by field (repeatable): changed, created, id, priority, state, title, assigned-to, type, tags --state strings Filter by exact workflow state name (repeatable; combines with --status) -s, --status strings Filter by state category: open, closed, resolved, all (repeatable) (default [open]) --tag strings Filter by tag (repeatable); items must contain all specified tags From 2ded9f5fd207a08f38014cd72b03d3423aca8988 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 17:33:01 +0000 Subject: [PATCH 16/20] chore: add mise.toml and update golangci config Add .mise.toml to pin development tool versions and source environment variables from .env. Remove goconst and commented gocyclo from enabled linters in .golangci.yml. --- .golangci.yml | 2 -- .mise.toml | 9 +++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .mise.toml diff --git a/.golangci.yml b/.golangci.yml index 35d05c9d..4f10dda4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,9 +6,7 @@ linters: - dupl - errorlint - exhaustive - - goconst - gocritic - #- gocyclo - gosec - misspell - nilerr diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 00000000..14744039 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,9 @@ +[env] +_.file = ".env" + +[tools] +go = "1.24" +"golangci-lint" = "2.12.2" +goreleaser = "latest" +prek = "latest" +gofumpt = "latest" From 9f54c78c4c7da6bbd150161e8f97bb53bfd74cff Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 19:08:11 +0000 Subject: [PATCH 17/20] fix: correct .PHONY declaration for tidy --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 817bb368..78dba410 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ lint: ## lint source @echo "Check for golangci-lint"; [ -e "$(shell which golangci-lint)" ] @echo "Executing golangci-lint"; golangci-lint run -v --timeout $(TIMEOUT) -.PHONY: help +.PHONY: tidy tidy: ## call go mod tidy on all existing go.mod files find . -name go.mod -execdir go mod tidy \; From 7ce365785fbdd5fd79c732498ef2a20766c19888 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 19:09:04 +0000 Subject: [PATCH 18/20] feat(workitem)!: combine sort field and direction into single flag * Replace the separate --sort and --order flags with a unified --sort flag that accepts field:direction syntax. This enables per-field sort direction control instead of a single global direction. * Change the default limit from 0 to 50 and set ChangedDate DESC as the implicit sort order when no sorting is specified. --- internal/cmd/boards/workitem/list/list.go | 70 +++++++++++++---------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/internal/cmd/boards/workitem/list/list.go b/internal/cmd/boards/workitem/list/list.go index 25936d65..270c76d7 100644 --- a/internal/cmd/boards/workitem/list/list.go +++ b/internal/cmd/boards/workitem/list/list.go @@ -40,8 +40,7 @@ type listOptions struct { tags []string - sortFields []string - sortOrder string + sort []string exporter util.Exporter } @@ -88,8 +87,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.Flags().StringSliceVar(&opts.tags, "tag", nil, "Filter by tag (repeatable); items must contain all specified tags") cmd.Flags().StringVar(&opts.changedAfter, "changed-after", "", "Lower bound on System.ChangedDate (RFC3339, YYYY-MM-DD, or 'today')") cmd.Flags().StringVar(&opts.createdAfter, "created-after", "", "Lower bound on System.CreatedDate (RFC3339, YYYY-MM-DD, or 'today')") - cmd.Flags().StringVar(&opts.sortOrder, "order", "desc", "Sort direction for all --sort fields: asc or desc") - cmd.Flags().StringSliceVar(&opts.sortFields, "sort", nil, "Sort by field (repeatable): changed, created, id, priority, state, title, assigned-to, type, tags") + cmd.Flags().StringSliceVar(&opts.sort, "sort", nil, "Sort by field with optional direction (repeatable): changed[:asc|:desc], created[:asc|:desc], id[:asc|:desc], state[:asc|:desc], title[:asc|:desc], assigned-to[:asc|:desc], type[:asc|:desc], tags[:asc|:desc]") cmd.Flags().StringSliceVarP(&opts.status, "status", "s", []string{"open"}, "Filter by state category: open, closed, resolved, all (repeatable)") cmd.Flags().StringSliceVarP(&opts.workItemTypes, "type", "T", nil, "Filter by work item type (repeatable)") cmd.Flags().StringSliceVarP(&opts.assignedTo, "assigned-to", "a", nil, "Filter by assigned-to identity (repeatable); supports emails, descriptors, and @me") @@ -97,7 +95,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.Flags().IntSliceVarP(&opts.priority, "priority", "p", nil, "Filter by priority (repeatable): 1-4") cmd.Flags().StringSliceVar(&opts.area, "area", nil, "Filter by area path (repeatable); prefix with Under: to include subtree (e.g., Under:Web/Payments)") cmd.Flags().StringSliceVar(&opts.iteration, "iteration", nil, "Filter by iteration path (repeatable); prefix with Under: to include subtree (e.g., Under:Release 2025/Sprint 1)") - cmd.Flags().IntVarP(&opts.limit, "limit", "L", 0, "Maximum number of results to return (>=1)") + cmd.Flags().IntVarP(&opts.limit, "limit", "L", 50, "Maximum number of results to return (>=1)") util.AddJSONFlags(cmd, &opts.exporter, []string{"url", "_links", "commentVersionRef", "fields", "id", "relations", "rev"}) @@ -135,7 +133,7 @@ func runList(ctx util.CmdContext, opts *listOptions) error { return util.FlagErrorWrap(err) } - orderBy, err := resolveSort(opts.sortFields, opts.sortOrder) + orderBy, err := resolveSort(opts.sort) if err != nil { return err } @@ -254,7 +252,7 @@ func validateListOptions(opts *listOptions) error { if err := validateTags("--tag", opts.tags); err != nil { return err } - if err := validateSort(opts.sortFields, opts.sortOrder); err != nil { + if err := validateSort(opts.sort); err != nil { return err } return nil @@ -670,41 +668,51 @@ var sortFieldMap = map[string]string{ "assigned-to": "[System.AssignedTo]", "type": "[System.WorkItemType]", "tags": "[System.Tags]", - "priority": "[Microsoft.VSTS.Common.Priority]", } -func validateSort(fields []string, order string) error { - if _, err := resolveSort(fields, order); err != nil { +func validateSort(values []string) error { + if _, err := resolveSort(values); err != nil { return err } return nil } -func resolveSort(fields []string, order string) (string, error) { - if len(fields) == 0 { - return "", nil +func resolveSort(values []string) (string, error) { + if len(values) == 0 { + return "ORDER BY [System.ChangedDate] DESC", nil } - dir := strings.ToLower(strings.TrimSpace(order)) - if dir == "" { - // default direction depends on the first field - first := strings.ToLower(strings.TrimSpace(fields[0])) - switch first { - case "changed", "created", "id": - dir = "desc" - default: - dir = "asc" + parts := make([]string, 0, len(values)) + resolvedFields := make(map[string]string, len(values)) + for _, raw := range values { + value := strings.ToLower(strings.TrimSpace(raw)) + field := value + dir := "" + if before, after, found := strings.Cut(value, ":"); found { + field = strings.TrimSpace(before) + dir = strings.TrimSpace(after) } - } - if dir != "asc" && dir != "desc" { - return "", util.FlagErrorf("invalid --order %q: must be asc or desc", order) - } - parts := make([]string, 0, len(fields)) - for _, raw := range fields { - key := strings.ToLower(strings.TrimSpace(raw)) - ref, ok := sortFieldMap[key] + ref, ok := sortFieldMap[field] if !ok { - return "", util.FlagErrorf("invalid --sort field %q (valid: changed, created, id, priority, state, title, assigned-to, type, tags)", raw) + return "", util.FlagErrorf("invalid --sort field %q (valid: changed, created, id, state, title, assigned-to, type, tags)", raw) + } + if dir == "" { + switch field { + case "changed", "created", "id": + dir = "desc" + default: + dir = "asc" + } + } + if dir != "asc" && dir != "desc" { + return "", util.FlagErrorf("invalid --sort direction %q in %q: must be asc or desc", dir, raw) + } + if existing, ok := resolvedFields[field]; ok { + if existing == dir { + continue + } + return "", util.FlagErrorf("conflicting --sort directives for %q: %s and %s", field, existing, dir) } + resolvedFields[field] = dir parts = append(parts, ref+" "+strings.ToUpper(dir)) } return "ORDER BY " + strings.Join(parts, ", "), nil From 4527baa5690addd749e58117cf491e35de616b06 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 19:22:08 +0000 Subject: [PATCH 19/20] test(workitem): update tests for combined sort --- .../cmd/boards/workitem/list/list_test.go | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/internal/cmd/boards/workitem/list/list_test.go b/internal/cmd/boards/workitem/list/list_test.go index 1ed78f08..c68b1490 100644 --- a/internal/cmd/boards/workitem/list/list_test.go +++ b/internal/cmd/boards/workitem/list/list_test.go @@ -34,26 +34,28 @@ func TestResolveSort(t *testing.T) { t.Parallel() cases := []struct { name string - fields []string - order string + values []string want string wantErr string }{ - {name: "no fields returns empty", fields: nil, want: ""}, - {name: "single field default desc", fields: []string{"changed"}, order: "", want: "ORDER BY [System.ChangedDate] DESC"}, - {name: "single field explicit desc", fields: []string{"changed"}, order: "desc", want: "ORDER BY [System.ChangedDate] DESC"}, - {name: "single field asc", fields: []string{"title"}, order: "asc", want: "ORDER BY [System.Title] ASC"}, - {name: "multiple fields", fields: []string{"priority", "id"}, order: "asc", want: "ORDER BY [Microsoft.VSTS.Common.Priority] ASC, [System.Id] ASC"}, - {name: "invalid field", fields: []string{"banana"}, wantErr: "invalid --sort field"}, - {name: "invalid order", fields: []string{"id"}, order: "sideways", wantErr: "invalid --order"}, - {name: "default direction is desc for changed/created/id", fields: []string{"id"}, order: "", want: "ORDER BY [System.Id] DESC"}, - {name: "default direction is asc for others", fields: []string{"state"}, order: "", want: "ORDER BY [System.State] ASC"}, - {name: "all field mappings", fields: []string{"created", "priority", "assigned-to", "type", "tags"}, order: "asc", want: "ORDER BY [System.CreatedDate] ASC, [Microsoft.VSTS.Common.Priority] ASC, [System.AssignedTo] ASC, [System.WorkItemType] ASC, [System.Tags] ASC"}, + {name: "no values uses default", values: nil, want: "ORDER BY [System.ChangedDate] DESC"}, + {name: "single field default desc", values: []string{"changed"}, want: "ORDER BY [System.ChangedDate] DESC"}, + {name: "single field explicit desc", values: []string{"changed:desc"}, want: "ORDER BY [System.ChangedDate] DESC"}, + {name: "single field asc", values: []string{"title:asc"}, want: "ORDER BY [System.Title] ASC"}, + {name: "multiple fields", values: []string{"state", "id:desc"}, want: "ORDER BY [System.State] ASC, [System.Id] DESC"}, + {name: "duplicate identical explicit ignored", values: []string{"title:asc", "title:asc"}, want: "ORDER BY [System.Title] ASC"}, + {name: "duplicate identical effective ignored", values: []string{"title", "title:asc"}, want: "ORDER BY [System.Title] ASC"}, + {name: "invalid field", values: []string{"banana"}, wantErr: "invalid --sort field"}, + {name: "invalid direction", values: []string{"id:sideways"}, wantErr: "invalid --sort direction"}, + {name: "conflicting direction", values: []string{"title:asc", "title:desc"}, wantErr: "conflicting --sort directives"}, + {name: "default direction is desc for changed/created/id", values: []string{"id"}, want: "ORDER BY [System.Id] DESC"}, + {name: "default direction is asc for others", values: []string{"state"}, want: "ORDER BY [System.State] ASC"}, + {name: "all field mappings", values: []string{"created:asc", "assigned-to", "type", "tags:desc"}, want: "ORDER BY [System.CreatedDate] ASC, [System.AssignedTo] ASC, [System.WorkItemType] ASC, [System.Tags] DESC"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := resolveSort(tc.fields, tc.order) + got, err := resolveSort(tc.values) if tc.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) @@ -101,9 +103,8 @@ func TestRunList_SortTitleAsc(t *testing.T) { ) err := runList(deps.cmd, &listOptions{ - scopeArg: "org/Fabrikam", - sortFields: []string{"title"}, - sortOrder: "asc", + scopeArg: "org/Fabrikam", + sort: []string{"title:asc"}, }) require.NoError(t, err) assert.Contains(t, captured, "ORDER BY [System.Title] ASC") @@ -119,8 +120,8 @@ func TestRunList_SortInvalidField(t *testing.T) { deps.cmd.EXPECT().IOStreams().Return(ios, nil).AnyTimes() err := runList(deps.cmd, &listOptions{ - scopeArg: "org/Fabrikam", - sortFields: []string{"banana"}, + scopeArg: "org/Fabrikam", + sort: []string{"banana"}, }) require.Error(t, err) assert.Contains(t, err.Error(), "invalid --sort field") @@ -741,7 +742,6 @@ func TestNewCmd_FlagShortcuts(t *testing.T) { {"created-by", ""}, {"authored-by", ""}, {"sort", ""}, - {"order", ""}, {"state", ""}, {"tag", ""}, {"status", "s"}, From cf9c6f7314112ea00950ba63aef4e58f3b73f0bf Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Wed, 3 Jun 2026 19:24:09 +0000 Subject: [PATCH 20/20] =?UTF-8?q?docs(workitem):=20=F0=9F=93=84=20update?= =?UTF-8?q?=20docs=20for=20combined=20sort=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/azdo_boards_work-item_list.md | 8 ++------ docs/azdo_help_reference.md | 5 ++--- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/docs/azdo_boards_work-item_list.md b/docs/azdo_boards_work-item_list.md index 8fa4587d..6e177d82 100644 --- a/docs/azdo_boards_work-item_list.md +++ b/docs/azdo_boards_work-item_list.md @@ -53,21 +53,17 @@ work item details in batches. Output JSON with the specified fields. Prefix a field with '-' to exclude it. -* `-L`, `--limit` `int` (default `0`) +* `-L`, `--limit` `int` (default `50`) Maximum number of results to return (>=1) -* `--order` `string` (default `"desc"`) - - Sort direction for all --sort fields: asc or desc - * `-p`, `--priority` `ints` Filter by priority (repeatable): 1-4 * `--sort` `strings` - Sort by field (repeatable): changed, created, id, priority, state, title, assigned-to, type, tags + Sort by field with optional direction (repeatable): changed[:asc|:desc], created[:asc|:desc], id[:asc|:desc], state[:asc|:desc], title[:asc|:desc], assigned-to[:asc|:desc], type[:asc|:desc], tags[:asc|:desc] * `--state` `strings` diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index e645ec29..f5867b10 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -141,10 +141,9 @@ List work items belonging to a project. --iteration strings Filter by iteration path (repeatable); prefix with Under: to include subtree (e.g., Under:Release 2025/Sprint 1) -q, --jq expression Filter JSON output using a jq expression --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. --L, --limit int Maximum number of results to return (>=1) - --order string Sort direction for all --sort fields: asc or desc (default "desc") +-L, --limit int Maximum number of results to return (>=1) (default 50) -p, --priority ints Filter by priority (repeatable): 1-4 - --sort strings Sort by field (repeatable): changed, created, id, priority, state, title, assigned-to, type, tags + --sort strings Sort by field with optional direction (repeatable): changed[:asc|:desc], created[:asc|:desc], id[:asc|:desc], state[:asc|:desc], title[:asc|:desc], assigned-to[:asc|:desc], type[:asc|:desc], tags[:asc|:desc] --state strings Filter by exact workflow state name (repeatable; combines with --status) -s, --status strings Filter by state category: open, closed, resolved, all (repeatable) (default [open]) --tag strings Filter by tag (repeatable); items must contain all specified tags