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" 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 \; 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..6e177d82 --- /dev/null +++ b/docs/azdo_boards_work-item_list.md @@ -0,0 +1,119 @@ +## 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 + +* `--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` (default `50`) + + Maximum number of results to return (>=1) + +* `-p`, `--priority` `ints` + + Filter by priority (repeatable): 1-4 + +* `--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` (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" + +* `-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..f5867b10 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -122,6 +122,41 @@ 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 + --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) +-p, --priority ints Filter by priority (repeatable): 1-4 + --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 +-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" 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/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 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..270c76d7 --- /dev/null +++ b/internal/cmd/boards/workitem/list/list.go @@ -0,0 +1,1072 @@ +package list + +import ( + "fmt" + "slices" + "strconv" + "strings" + "time" + + "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/azdo/extensions" + "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 + + changedAfter string + createdAfter string + createdBy []string + authoredByRaw []string // populated by --authored-by, merged into createdBy in PreRunE + + state []string + + tags []string + + sort []string + + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &listOptions{} + + 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().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().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") + 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", 50, "Maximum number of results to return (>=1)") + + 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 +} + +func runList(ctx util.CmdContext, opts *listOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if err := validateListOptions(opts); err != nil { + return err + } + + scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + orderBy, err := resolveSort(opts.sort) + 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) + } + + 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 + } + + 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 + } + + 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), + ) + + wiqlQueryArgs := workitemtracking.QueryByWiqlArgs{ + Wiql: &workitemtracking.Wiql{Query: &query}, + Project: &scope.Project, + } + + 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) + } + + ids := extractWorkItemIDs(result) + if len(ids) == 0 { + return util.NewNoResultsError("no work items matched the provided filters") + } + + 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) + } + + 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 + } + if err := validateState(opts.state); err != nil { + return err + } + if err := validateTags("--tag", opts.tags); err != nil { + return err + } + if err := validateSort(opts.sort); 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 + } + + 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 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(map[string]struct{}) + for _, s := range statuses { + switch s { + case "open": + addWantedCategory(wantedCategories, "New") + addWantedCategory(wantedCategories, "Active") + addWantedCategory(wantedCategories, "Proposed") + addWantedCategory(wantedCategories, "InProgress") + case "closed": + addWantedCategory(wantedCategories, "Completed") + addWantedCategory(wantedCategories, "Removed") + case "resolved": + addWantedCategory(wantedCategories, "Resolved") + default: + return "", util.FlagErrorf("invalid value for --status: %q (valid: open, closed, resolved, all)", s) + } + } + + 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) + } + appendStateNamesByCategory(&stateNames, 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 { + appendStateNamesByCategory(&stateNames, 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) + } + appendStateNamesByCategory(&stateNames, 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 appendStateNamesByCategory(stateNames *[]string, states *[]workitemtracking.WorkItemStateColor, wantedCategories map[string]struct{}) { + if stateNames == nil || states == nil || len(*states) == 0 { + return + } + + for _, st := range *states { + category := types.GetValue(st.Category, "") + name := types.GetValue(st.Name, "") + if category == "" || name == "" { + continue + } + if _, ok := wantedCategories[canonCategory(category)]; ok { + *stateNames = append(*stateNames, name) + } + } +} + +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.Unique(normalized) +} + +func addWantedCategory(categories map[string]struct{}, category string) { + if categories == nil { + return + } + categories[canonCategory(category)] = struct{}{} +} + +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 +} + +//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 + } + + 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 + } + } + + 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 + } + + //nolint:dupl // intentional duplicate of resolveCreatedByFilter loop + resolved := make([]string, 0, len(assignedTo)) + for _, raw := range assignedTo { + 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 + } + + // 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 + } + 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 +} + +//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, "@") +} + +func identityAccountOrDisplay(ident identity.Identity) string { + if account := identityAccount(ident.Properties); account != "" { + return account + } + return types.GetValue(ident.ProviderDisplayName, "") +} + +func identityAccount(properties any) string { + props, ok := properties.(map[string]any) + if !ok { + return "" + } + raw, ok := props["Account"] + if !ok || raw == nil { + return "" + } + + account, ok := raw.(map[string]any) + if !ok { + return "" + } + if value, ok := account["$value"].(string); ok { + return value + } + return "" +} + +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]", +} + +func validateSort(values []string) error { + if _, err := resolveSort(values); err != nil { + return err + } + return nil +} + +func resolveSort(values []string) (string, error) { + if len(values) == 0 { + return "ORDER BY [System.ChangedDate] DESC", nil + } + 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) + } + ref, ok := sortFieldMap[field] + if !ok { + 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 +} + +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 statePredicate != "" { + clauses = append(clauses, statePredicate) + } + + 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(createdBy) > 0 { + clauses = append(clauses, fmt.Sprintf("[System.CreatedBy] IN (%s)", wiqlQuoteList(createdBy))) + } + + 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) + } + } + + 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 { + 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 { + values = types.Unique(values) + parts := make([]string, 0, len(values)) + for _, v := range values { + parts = append(parts, strconv.Itoa(v)) + } + 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/list/list_test.go b/internal/cmd/boards/workitem/list/list_test.go new file mode 100644 index 00000000..c68b1490 --- /dev/null +++ b/internal/cmd/boards/workitem/list/list_test.go @@ -0,0 +1,1146 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "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 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 + values []string + want string + wantErr string + }{ + {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.values) + 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", + sort: []string{"title: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", + sort: []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() + + 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, "", "", "", "", nil) + 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. + {"changed-after", ""}, + {"created-after", ""}, + {"created-by", ""}, + {"authored-by", ""}, + {"sort", ""}, + {"state", ""}, + {"tag", ""}, + {"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") +} diff --git a/internal/cmd/boards/workitem/workitem.go b/internal/cmd/boards/workitem/workitem.go new file mode 100644 index 00000000..24fe0422 --- /dev/null +++ b/internal/cmd/boards/workitem/workitem.go @@ -0,0 +1,24 @@ +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 +} 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/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 })) +} 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) } 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 +}