diff --git a/docs/azdo_boards_iteration_project.md b/docs/azdo_boards_iteration_project.md index 80e8b005..ad12353b 100644 --- a/docs/azdo_boards_iteration_project.md +++ b/docs/azdo_boards_iteration_project.md @@ -7,6 +7,7 @@ Project-scoped iteration commands. * [azdo boards iteration project create](./azdo_boards_iteration_project_create.md) * [azdo boards iteration project delete](./azdo_boards_iteration_project_delete.md) * [azdo boards iteration project list](./azdo_boards_iteration_project_list.md) +* [azdo boards iteration project show](./azdo_boards_iteration_project_show.md) ### ALIASES @@ -18,6 +19,9 @@ Project-scoped iteration commands. ```bash # List iterations for a project azdo boards iteration project list Fabrikam + +# Show a specific iteration +azdo boards iteration project show Fabrikam --path "Sprint 1" ``` ### See also diff --git a/docs/azdo_boards_iteration_project_show.md b/docs/azdo_boards_iteration_project_show.md new file mode 100644 index 00000000..2a135d9b --- /dev/null +++ b/docs/azdo_boards_iteration_project_show.md @@ -0,0 +1,70 @@ +## Command `azdo boards iteration project show` + +``` +azdo boards iteration project show [ORGANIZATION/]PROJECT [flags] +``` + +Display the details of a single iteration (sprint) node in a project. +The iteration is identified by its fully-qualified path under /Iteration. + + +### Options + + +* `--depth` `int` (default `0`) + + Depth of child nodes to fetch (0-10). + +* `--include-children` + + Include child nodes in the template output. + +* `-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. + +* `--path` `string` + + Iteration path under /Iteration (required). + +* `-r`, `--raw` + + Dump the raw SDK node to stderr. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `view` +- `status` + +### JSON Fields + +`_links`, `attributes`, `children`, `hasChildren`, `id`, `identifier`, `name`, `path`, `structureType`, `url` + +### Examples + +```bash +# Show a top-level iteration +azdo boards iteration project show Fabrikam --path "Sprint 1" + +# Show a nested iteration +azdo boards iteration project show myorg/Fabrikam --path "Release 2025/Sprint 1" + +# Include child nodes in the template output +azdo boards iteration project show Fabrikam --path "Release 2025" --include-children + +# Emit the raw SDK node as JSON +azdo boards iteration project show Fabrikam --path "Sprint 1" --json +``` + +### See also + +* [azdo boards iteration project](./azdo_boards_iteration_project.md) diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 1e499b9d..28b37ceb 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -188,6 +188,26 @@ Aliases ls, l ``` +##### `azdo boards iteration project show [ORGANIZATION/]PROJECT [flags]` + +Show an iteration in a project. + +``` + --depth int Depth of child nodes to fetch (0-10). + --include-children Include child nodes in the template output. +-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. + --path string Iteration path under /Iteration (required). +-r, --raw Dump the raw SDK node to stderr. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +view, status +``` + ### `azdo boards work-item ` Work with Azure Boards work items. diff --git a/internal/cmd/boards/iteration/project/create/create.go b/internal/cmd/boards/iteration/project/create/create.go index 0207772c..70d283cf 100644 --- a/internal/cmd/boards/iteration/project/create/create.go +++ b/internal/cmd/boards/iteration/project/create/create.go @@ -80,9 +80,6 @@ func runCreate(ctx util.CmdContext, opts *createOptions) error { ios.StartProgressIndicator() defer ios.StopProgressIndicator() - if parts := strings.Split(strings.TrimSpace(opts.scopeArg), "/"); len(parts) > 2 { - return util.FlagErrorf("invalid project scope %q: expected [ORGANIZATION/]PROJECT", opts.scopeArg) - } scope, err := util.ParseProjectScope(ctx, opts.scopeArg) if err != nil { diff --git a/internal/cmd/boards/iteration/project/delete/delete.go b/internal/cmd/boards/iteration/project/delete/delete.go index d18d74f4..0018bd49 100644 --- a/internal/cmd/boards/iteration/project/delete/delete.go +++ b/internal/cmd/boards/iteration/project/delete/delete.go @@ -77,10 +77,6 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { ios.StartProgressIndicator() defer ios.StopProgressIndicator() - if parts := strings.Split(strings.TrimSpace(opts.scopeArg), "/"); len(parts) > 2 { - return util.FlagErrorf("invalid project scope %q: expected [ORGANIZATION/]PROJECT", opts.scopeArg) - } - scope, err := util.ParseProjectScope(ctx, opts.scopeArg) if err != nil { return util.FlagErrorWrap(err) diff --git a/internal/cmd/boards/iteration/project/project.go b/internal/cmd/boards/iteration/project/project.go index 97d7a7ab..c7b51d69 100644 --- a/internal/cmd/boards/iteration/project/project.go +++ b/internal/cmd/boards/iteration/project/project.go @@ -6,6 +6,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/boards/iteration/project/create" "github.com/tmeckel/azdo-cli/internal/cmd/boards/iteration/project/delete" "github.com/tmeckel/azdo-cli/internal/cmd/boards/iteration/project/list" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/iteration/project/show" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -17,6 +18,9 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { Example: heredoc.Doc(` # List iterations for a project azdo boards iteration project list Fabrikam + + # Show a specific iteration + azdo boards iteration project show Fabrikam --path "Sprint 1" `), Aliases: []string{ "prj", @@ -27,6 +31,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(create.NewCmd(ctx)) cmd.AddCommand(delete.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(show.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/boards/iteration/project/show/show.go b/internal/cmd/boards/iteration/project/show/show.go new file mode 100644 index 00000000..ddcbc722 --- /dev/null +++ b/internal/cmd/boards/iteration/project/show/show.go @@ -0,0 +1,171 @@ +package show + +import ( + _ "embed" + "fmt" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/spewerspew/spew" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/boards/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/template" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type showOptions struct { + scopeArg string + path string + depth int + includeChildren bool + raw bool + exporter util.Exporter +} + +//go:embed show.tpl +var showTpl string + +type templateData struct { + Node *workitemtracking.WorkItemClassificationNode + IncludeChildren bool +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &showOptions{} + + cmd := &cobra.Command{ + Use: "show [ORGANIZATION/]PROJECT", + Short: "Show an iteration in a project.", + Long: heredoc.Doc(` + Display the details of a single iteration (sprint) node in a project. + The iteration is identified by its fully-qualified path under /Iteration. + `), + Example: heredoc.Doc(` + # Show a top-level iteration + azdo boards iteration project show Fabrikam --path "Sprint 1" + + # Show a nested iteration + azdo boards iteration project show myorg/Fabrikam --path "Release 2025/Sprint 1" + + # Include child nodes in the template output + azdo boards iteration project show Fabrikam --path "Release 2025" --include-children + + # Emit the raw SDK node as JSON + azdo boards iteration project show Fabrikam --path "Sprint 1" --json + `), + Aliases: []string{"view", "status"}, + Args: util.ExactArgs(1, "project argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + return runShow(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.path, "path", "", "Iteration path under /Iteration (required).") + cmd.Flags().IntVar(&opts.depth, "depth", 0, "Depth of child nodes to fetch (0-10).") + cmd.Flags().BoolVar(&opts.includeChildren, "include-children", false, "Include child nodes in the template output.") + cmd.Flags().BoolVarP(&opts.raw, "raw", "r", false, "Dump the raw SDK node to stderr.") + _ = cmd.MarkFlagRequired("path") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "identifier", "name", "path", "structureType", + "hasChildren", "attributes", "url", "_links", "children", + }) + + return cmd +} + +func runShow(ctx util.CmdContext, opts *showOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if opts.depth < 0 || opts.depth > 10 { + return util.FlagErrorf("--depth must be between 0 and 10") + } + + scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + rawPath := strings.TrimSpace(opts.path) + if rawPath == "" { + return util.FlagErrorf("--path must not be empty") + } + nodePath, err := shared.BuildClassificationPath(scope.Project, true, "Iteration", rawPath) + if err != nil { + return util.FlagErrorf("invalid --path: %w", err) + } + if nodePath == "" { + return util.FlagErrorf("--path must reference a child of /Iteration, not the iteration root") + } + + wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to get classification client: %w", err) + } + + zap.L().Debug( + "fetching iteration", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("path", nodePath), + zap.Int("depth", opts.depth), + ) + + args := workitemtracking.GetClassificationNodeArgs{ + Project: types.ToPtr(scope.Project), + StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Iterations), + Path: types.ToPtr(nodePath), + Depth: types.ToPtr(opts.depth), + } + res, err := wit.GetClassificationNode(ctx.Context(), args) + if err != nil { + return fmt.Errorf("failed to get iteration: %w", err) + } + if res == nil { + return fmt.Errorf("iteration node is nil") + } + + ios.StopProgressIndicator() + + if opts.raw { + spew.NewDefaultConfig().Fdump(ios.ErrOut, res) + return nil + } + + if opts.exporter != nil { + return opts.exporter.Write(ios, res) + } + + t := template.New( + ios.Out, + ios.TerminalWidth(), + ios.ColorEnabled(), + ). + WithTheme(ios.TerminalTheme()). + WithFuncs(map[string]any{ + "hasText": template.HasText, + "s": template.StringOrEmpty, + "bool": template.BoolString, + "int": func(v *int) string { return strconv.Itoa(types.GetValue(v, 0)) }, + "uuid": template.UUIDString, + }) + if err := t.Parse(showTpl); err != nil { + return err + } + + return t.ExecuteData(templateData{ + Node: res, + IncludeChildren: opts.includeChildren, + }) +} diff --git a/internal/cmd/boards/iteration/project/show/show.tpl b/internal/cmd/boards/iteration/project/show/show.tpl new file mode 100644 index 00000000..4d101cfe --- /dev/null +++ b/internal/cmd/boards/iteration/project/show/show.tpl @@ -0,0 +1,21 @@ +{{bold "url:"}} {{if hasText .Node.Url}}{{hyperlink (s .Node.Url) (s .Node.Url)}}{{else}}{{s .Node.Url}}{{end}} +{{bold "id:"}} {{int .Node.Id}} +{{bold "identifier:"}} {{uuid .Node.Identifier}} +{{bold "name:"}} {{s .Node.Name}} +{{bold "path:"}} {{s .Node.Path}} +{{bold "structure:"}} {{s .Node.StructureType}} +{{bold "has children:"}} {{bool .Node.HasChildren}} +{{if .Node.Attributes}} + +{{bold "attributes:"}} +{{range $key, $value := .Node.Attributes}}{{printf "%-14s" (printf "%s:" $key)}} {{timefmt "2006-01-02" $value}} +{{end -}} +{{end -}} +{{if .IncludeChildren}} +{{if .Node.Children}} + +{{bold "children:"}} +{{range .Node.Children}} - {{s .Name}}{{if hasText .Identifier}} ({{uuid .Identifier}}){{end}}{{if .HasChildren}} (hasChildren: {{bool .HasChildren}}){{end}} +{{end -}} +{{end -}} +{{end -}} diff --git a/internal/cmd/boards/iteration/project/show/show_test.go b/internal/cmd/boards/iteration/project/show/show_test.go new file mode 100644 index 00000000..ba2aa3c9 --- /dev/null +++ b/internal/cmd/boards/iteration/project/show/show_test.go @@ -0,0 +1,424 @@ +package show + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/google/uuid" + "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/types" +) + +type dependencies struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + wit *mocks.MockWorkItemTrackingClient + stdout *bytes.Buffer + stderr *bytes.Buffer + org string +} + +func newDependencies(t *testing.T, organization string) *dependencies { + t.Helper() + + return newDependenciesWithClientFactoryError(t, organization, nil) +} + +func newDependenciesWithClientFactoryError(t *testing.T, organization string, factoryErr error) *dependencies { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, errOut := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &dependencies{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + wit: mocks.NewMockWorkItemTrackingClient(ctrl), + stdout: out, + stderr: errOut, + org: organization, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + if factoryErr != nil { + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), organization).Return(nil, factoryErr).AnyTimes() + } else { + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), organization).Return(deps.wit, nil).AnyTimes() + } + + return deps +} + +func newDependenciesWithDefaultOrg(t *testing.T, defaultOrg string) *dependencies { + t.Helper() + + deps := newDependencies(t, defaultOrg) + cfg := mocks.NewMockConfig(deps.ctrl) + auth := mocks.NewMockAuthConfig(deps.ctrl) + + deps.cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return(defaultOrg, nil).AnyTimes() + + return deps +} + +func showNode() *workitemtracking.WorkItemClassificationNode { + id := 42 + identifier := uuid.New() + hasChildren := true + name := "Sprint 1" + path := "Fabrikam\\Iteration\\Sprint 1" + structureType := workitemtracking.TreeNodeStructureTypeValues.Iteration + url := "https://dev.azure.com/org/Fabrikam/_apis/wit/classificationNodes/iterations/42" + return &workitemtracking.WorkItemClassificationNode{ + Id: &id, + Identifier: &identifier, + Name: &name, + Path: &path, + StructureType: &structureType, + HasChildren: &hasChildren, + Url: &url, + Links: map[string]any{ + "self": map[string]any{"href": url}, + }, + } +} + +func TestNewCmd_RegistersAsShowLeaf(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + + assert.Equal(t, "show", cmd.Name()) + assert.Equal(t, []string{"view", "status"}, cmd.Aliases) + assert.True(t, strings.HasPrefix(cmd.Use, "show [ORGANIZATION/]PROJECT")) +} + +func TestNewCmd_PathFlagRequired(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"Fabrikam"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "path") +} + + +func TestRunShow_PathRequired(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + }{ + {name: "empty", path: ""}, + {name: "whitespace", path: " "}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam", path: tc.path}) + + requireFlagError(t, err, "--path must not be empty") + }) + } +} + +func TestRunShow_DepthBounds(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", depth: 11}) + + requireFlagError(t, err, "--depth must be between 0 and 10") +} + +func TestRunShow_RequestArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts *showOptions + wantPath string + wantProj string + wantDepth int + }{ + {name: "root level", opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}, wantPath: "Sprint%201", wantProj: "Fabrikam", wantDepth: 0}, + {name: "normalizes project path", opts: &showOptions{scopeArg: "org/Fabrikam", path: "Fabrikam/Iteration/Sprint 1"}, wantPath: "Sprint%201", wantProj: "Fabrikam", wantDepth: 0}, + {name: "escapes nested path", opts: &showOptions{scopeArg: "org/Fabrikam", path: "My Sprint/Sub Sprint"}, wantPath: "My%20Sprint/Sub%20Sprint", wantProj: "Fabrikam", wantDepth: 0}, + {name: "uses explicit depth", opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", depth: 2}, wantPath: "Sprint%201", wantProj: "Fabrikam", wantDepth: 2}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + args, err := captureShowArgs(t, deps, tc.opts, showNode()) + + require.NoError(t, err) + assert.Equal(t, tc.wantPath, *args.Path) + assert.Equal(t, tc.wantProj, *args.Project) + assert.Equal(t, tc.wantDepth, *args.Depth) + assert.Equal(t, workitemtracking.TreeStructureGroupValues.Iterations, *args.StructureGroup) + assert.Equal(t, "iterations", string(*args.StructureGroup)) + }) + } +} + +func TestRunShow_TemplateOutput(t *testing.T) { + t.Parallel() + childID := uuid.New() + tests := []struct { + name string + node *workitemtracking.WorkItemClassificationNode + opts *showOptions + contains []string + notContains []string + }{ + { + name: "basic fields without attributes", + node: showNode(), + opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}, + contains: []string{ + "url:", + "https://dev.azure.com/org/Fabrikam/_apis/wit/classificationNodes/iterations/42", + "id:", + "42", + "identifier:", + "name:", + "Sprint 1", + "path:", + "Fabrikam\\Iteration\\Sprint 1", + "structure:", + "iteration", + "has children:", + "true", + }, + notContains: []string{"attributes:"}, + }, + { + name: "attributes included", + node: func() *workitemtracking.WorkItemClassificationNode { + node := showNode() + attrs := map[string]any{ + "startDate": "2024-01-01T00:00:00Z", + "finishDate": "2024-01-15T00:00:00Z", + } + node.Attributes = &attrs + return node + }(), + opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}, + contains: []string{"attributes:", "startDate: 2024-01-01", "finishDate: 2024-01-15"}, + }, + { + name: "children included when requested", + node: func() *workitemtracking.WorkItemClassificationNode { + node := showNode() + children := []workitemtracking.WorkItemClassificationNode{{ + Name: types.ToPtr("Sub Sprint"), + Identifier: &childID, + HasChildren: types.ToPtr(true), + }} + node.Children = &children + return node + }(), + opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", includeChildren: true}, + contains: []string{"children:", "- Sub Sprint", childID.String(), "hasChildren: true"}, + }, + { + name: "children omitted by default", + node: func() *workitemtracking.WorkItemClassificationNode { + node := showNode() + children := []workitemtracking.WorkItemClassificationNode{{Name: types.ToPtr("Sub Sprint")}} + node.Children = &children + return node + }(), + opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}, + notContains: []string{" - Sub Sprint"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(tc.node, nil) + + err := runShow(deps.cmd, tc.opts) + + require.NoError(t, err) + out := deps.stdout.String() + for _, want := range tc.contains { + assert.Contains(t, out, want) + } + for _, unwanted := range tc.notContains { + assert.NotContains(t, out, unwanted) + } + }) + } +} + +func TestRunShow_JSONOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + node := showNode() + attrs := map[string]any{"startDate": "2024-01-01T00:00:00Z"} + node.Attributes = &attrs + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(node, nil) + + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", exporter: util.NewJSONExporter()}) + + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(deps.stdout.Bytes(), &got)) + assert.Equal(t, float64(42), got["id"]) + assert.Equal(t, node.Identifier.String(), got["identifier"]) + assert.Equal(t, "Sprint 1", got["name"]) + assert.Equal(t, "Fabrikam\\Iteration\\Sprint 1", got["path"]) + assert.Equal(t, "iteration", got["structureType"]) + assert.Equal(t, true, got["hasChildren"]) + assert.Equal(t, "https://dev.azure.com/org/Fabrikam/_apis/wit/classificationNodes/iterations/42", got["url"]) + attrsJSON, ok := got["attributes"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "2024-01-01T00:00:00Z", attrsJSON["startDate"]) + linksJSON, ok := got["_links"].(map[string]any) + require.True(t, ok) + assert.Contains(t, linksJSON, "self") +} + +func TestRunShow_RawFlag(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(showNode(), nil) + + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", raw: true}) + + require.NoError(t, err) + assert.Contains(t, deps.stderr.String(), "WorkItemClassificationNode") +} + +func TestRunShow_ProjectScopeParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopeArg string + org string + project string + wantErr string + defaultOrg string + }{ + {name: "organization and project", scopeArg: "org/proj", org: "org", project: "proj"}, + {name: "project uses default organization", scopeArg: "proj", org: "default-org", project: "proj", defaultOrg: "default-org"}, + {name: "too many segments", scopeArg: "org/proj/extra", wantErr: "expected 1-2 segments, got 3"}, + {name: "empty scope", scopeArg: "", wantErr: "expected"}, + {name: "organization from config default", scopeArg: "Fabrikam", org: "default-org", project: "Fabrikam", defaultOrg: "default-org"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var deps *dependencies + if tc.defaultOrg != "" { + deps = newDependenciesWithDefaultOrg(t, tc.defaultOrg) + } else { + deps = newDependencies(t, tc.org) + } + opts := &showOptions{scopeArg: tc.scopeArg, path: "Sprint 1"} + + if tc.wantErr != "" { + err := runShow(deps.cmd, opts) + requireFlagError(t, err, tc.wantErr) + return + } + + args, err := captureShowArgs(t, deps, opts, showNode()) + require.NoError(t, err) + assert.Equal(t, tc.project, *args.Project) + }) + } +} + +func TestRunShow_ClientFactoryError(t *testing.T) { + t.Parallel() + + deps := newDependenciesWithClientFactoryError(t, "org", errors.New("boom")) + + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get classification client") +} + +func TestRunShow_SDKError(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get iteration") +} + +func captureShowArgs(t *testing.T, deps *dependencies, opts *showOptions, response *workitemtracking.WorkItemClassificationNode) (workitemtracking.GetClassificationNodeArgs, error) { + t.Helper() + + var got workitemtracking.GetClassificationNodeArgs + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetClassificationNodeArgs) (*workitemtracking.WorkItemClassificationNode, error) { + got = args + return response, nil + }, + ) + + err := runShow(deps.cmd, opts) + return got, err +} + +func requireFlagError(t *testing.T, err error, substr string) { + t.Helper() + + require.Error(t, err) + var flagErr *util.FlagError + require.ErrorAs(t, err, &flagErr) + assert.Contains(t, err.Error(), substr) +} diff --git a/internal/cmd/pipelines/delete/delete.go b/internal/cmd/pipelines/delete/delete.go index abf00285..5280d8a0 100644 --- a/internal/cmd/pipelines/delete/delete.go +++ b/internal/cmd/pipelines/delete/delete.go @@ -123,7 +123,6 @@ func runDelete(cmdCtx util.CmdContext, opts *deleteOptions) error { ios.StopProgressIndicator() - // ponytail: SDK delete returns no resource, so keep stdout text only. fmt.Fprintf(ios.Out, "Pipeline %d was deleted successfully.\n", pipelineID) return nil } diff --git a/internal/cmd/pipelines/queue/list/list.go b/internal/cmd/pipelines/queue/list/list.go index 0297d342..fd12866d 100644 --- a/internal/cmd/pipelines/queue/list/list.go +++ b/internal/cmd/pipelines/queue/list/list.go @@ -85,18 +85,10 @@ func run(cmdCtx util.CmdContext, opts *opts) error { return util.FlagErrorf("invalid --max-items value %d; must be >= 0", opts.maxItems) } - scopeArg := strings.TrimSpace(opts.scope) - if strings.Count(scopeArg, "/") > 1 { - return util.FlagErrorf("invalid project argument: expected [ORGANIZATION/]PROJECT") - } - - scope, err := util.ParseProjectScope(cmdCtx, scopeArg) + scope, err := util.ParseProjectScope(cmdCtx, strings.TrimSpace(opts.scope)) if err != nil { return util.FlagErrorf("invalid project argument: %w", err) } - if len(scope.Targets) != 0 { - return util.FlagErrorf("invalid project argument: expected [ORGANIZATION/]PROJECT") - } taskClient, err := cmdCtx.ClientFactory().TaskAgent(cmdCtx.Context(), scope.Organization) if err != nil { diff --git a/internal/cmd/util/scope.go b/internal/cmd/util/scope.go index bd46d0c0..db308a43 100644 --- a/internal/cmd/util/scope.go +++ b/internal/cmd/util/scope.go @@ -26,7 +26,60 @@ type ParseOptions struct { MaxTargets int } -// Parse splits a raw user input into a Path according to opts. +// Parse splits raw command input into a Path using fixed Azure DevOps-style path rules. +// +// Input is first trimmed, then split on "/". Each segment is trimmed again, and empty +// segments are rejected. The resulting path is interpreted as: +// +// [ORGANIZATION][/PROJECT][/TARGET[/TARGET...]] +// +// `opts` controls which prefix segments are allowed and how many trailing target +// segments must exist: +// +// - AllowImplicitOrg allows organization to be omitted. When omitted, Parse loads the +// default organization from ctx.Config().Authentication().GetDefaultOrganization(). +// - RequireProject requires one project segment before any target segments. +// - MinTargets defines required trailing target count. +// - MaxTargets defines allowed trailing target count. When MaxTargets is zero, Parse +// treats it as exactly MinTargets. +// +// Parse only supports at most two non-target prefix segments: optional organization and +// optional project. Targets are always the trailing segments and the prefix is the +// leading segments. +// +// When MinTargets == MaxTargets the target count is fixed, so the prefix is simply the +// remaining leading segments (0, 1, or 2). When MaxTargets > MinTargets the split would +// otherwise be ambiguous, so Parse pins the prefix to its maximum shape (organization + +// project) and treats every remaining segment as a target. In that mode both +// organization and project must be present in the path. +// +// Examples: +// +// Parse(ctx, "org", ParseOptions{AllowImplicitOrg: false}) +// // => &Path{Organization: "org"} +// +// Parse(ctx, "", ParseOptions{AllowImplicitOrg: true}) +// // => &Path{Organization: } +// +// Parse(ctx, "project", ParseOptions{AllowImplicitOrg: true, RequireProject: true}) +// // => &Path{Organization: , Project: "project"} +// +// Parse(nil, "org/project/group", ParseOptions{MinTargets: 1, MaxTargets: 1}) +// // => &Path{Organization: "org", Project: "project", Targets: []string{"group"}} +// +// Parse(ctx, "pool/agent", ParseOptions{AllowImplicitOrg: true, MinTargets: 2, MaxTargets: 2}) +// // => &Path{Organization: , Targets: []string{"pool", "agent"}} +// +// Parse(nil, "org/project/target/extra", ParseOptions{MinTargets: 1, MaxTargets: 2}) +// // => &Path{Organization: "org", Project: "project", Targets: []string{"target", "extra"}} +// +// Error conditions: +// - opts are invalid, for example MaxTargets is non-zero and smaller than MinTargets +// - any segment is empty after trimming, for example "org/" or "org/ /project" +// - total segment count (and therefore the resulting target count) falls outside the +// allowed range derived from opts +// - organization is omitted but ctx is nil +// - organization is omitted and default organization lookup fails or returns empty func Parse(ctx CmdContext, raw string, opts ParseOptions) (*Path, error) { trimmed := strings.TrimSpace(raw) var parts []string @@ -42,6 +95,17 @@ func Parse(ctx CmdContext, raw string, opts ParseOptions) (*Path, error) { n := len(parts) + // Validate and normalize the allowed target range. + minTargets := opts.MinTargets + maxTargets := opts.MaxTargets + if maxTargets == 0 { + maxTargets = minTargets + } + if minTargets < 0 || maxTargets < minTargets { + return nil, fmt.Errorf("invalid options: target range [%d,%d] is not satisfiable", opts.MinTargets, opts.MaxTargets) + } + + // Allowed prefix (organization + project) segment counts. minOrg := 0 if !opts.AllowImplicitOrg { minOrg = 1 @@ -50,29 +114,38 @@ func Parse(ctx CmdContext, raw string, opts ParseOptions) (*Path, error) { if opts.RequireProject { minProject = 1 } + const maxOrg = 1 + const maxProject = 1 minPrefix := minOrg + minProject - minTotal := minPrefix + opts.MinTargets + maxPrefix := maxOrg + maxProject - maxPrefix := 2 - maxTargets := opts.MaxTargets - if maxTargets == 0 { - maxTargets = 999 - } + minTotal := minPrefix + minTargets maxTotal := maxPrefix + maxTargets - if n < minTotal || n > maxTotal { + // Determine the prefix length. + // + // Fixed target count: the prefix is whatever leads the fixed-size target tail. + // Variable target count: pin the prefix to its maximum shape so the split is + // unambiguous, and let the remaining segments be targets. + var prefixLen int + if minTargets == maxTargets { + prefixLen = n - minTargets + } else { + prefixLen = maxPrefix + } + targetCount := n - prefixLen + + if prefixLen < minPrefix || prefixLen > maxPrefix || targetCount < minTargets || targetCount > maxTargets { return nil, fmt.Errorf("invalid input %q: expected %d-%d segments, got %d", raw, minTotal, maxTotal, n) } p := &Path{} - if opts.MinTargets > 0 { - p.Targets = make([]string, opts.MinTargets) - if n >= opts.MinTargets { - copy(p.Targets, parts[n-opts.MinTargets:]) - } + if targetCount > 0 { + p.Targets = make([]string, targetCount) + copy(p.Targets, parts[prefixLen:]) } - switch extra := n - opts.MinTargets; extra { + switch prefixLen { case 0: case 1: if opts.RequireProject { @@ -192,6 +265,9 @@ func ResolveScopeDescriptor(ctx CmdContext, organization, project string) (*stri if project == "" { return nil, nil, nil } + if strings.TrimSpace(organization) == "" { + return nil, nil, fmt.Errorf("organization is required") + } coreClient, err := ctx.ClientFactory().Core(ctx.Context(), organization) if err != nil { @@ -223,11 +299,6 @@ func ResolveScopeDescriptor(ctx CmdContext, organization, project string) (*stri return nil, nil, fmt.Errorf("project descriptor is empty") } - var projectID *string - if projectRef.Id != nil { - id := projectRef.Id.String() - projectID = &id - } - + projectID := types.ToPtr(projectRef.Id.String()) return descriptor.Value, projectID, nil } diff --git a/internal/cmd/util/scope_test.go b/internal/cmd/util/scope_test.go index 28885fa3..fa90abcc 100644 --- a/internal/cmd/util/scope_test.go +++ b/internal/cmd/util/scope_test.go @@ -68,6 +68,16 @@ func TestParseScope(t *testing.T) { _, err := util.ParseScope(mockCtx, "org/") require.Error(t, err) }) + + t.Run("rejects more than two segments", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockCtx := mocks.NewMockCmdContext(ctrl) + + _, err := util.ParseScope(mockCtx, "org/proj/extra") + require.Error(t, err) + }) } func TestParseOrganizationArg(t *testing.T) { @@ -146,6 +156,15 @@ func TestParseProjectScope(t *testing.T) { _, err := util.ParseProjectScope(mocks.NewMockCmdContext(ctrl), "") require.Error(t, err) }) + + t.Run("too many segments", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + _, err := util.ParseProjectScope(mocks.NewMockCmdContext(ctrl), "org/project/extra") + require.Error(t, err) + assert.Contains(t, err.Error(), "expected 1-2 segments, got 3") + }) } func TestParseTarget(t *testing.T) { @@ -381,6 +400,50 @@ func TestParse(t *testing.T) { opts: util.ParseOptions{AllowImplicitOrg: true}, wantErr: "contains empty segment", }, + { + name: "unbounded targets when MaxTargets unset rejects extras", + raw: "a/b/c/d", + opts: util.ParseOptions{AllowImplicitOrg: false, MinTargets: 1}, + wantErr: "expected 2-3 segments, got 4", + }, + { + name: "scope with extra segments is rejected", + raw: "org/proj/extra", + opts: util.ParseOptions{AllowImplicitOrg: true}, + wantErr: "expected 0-2 segments, got 3", + }, + { + name: "variable target counts allow one target", + raw: "org/project/target", + opts: util.ParseOptions{AllowImplicitOrg: false, MinTargets: 1, MaxTargets: 2}, + want: &util.Path{ + Organization: "org", + Project: "project", + Targets: []string{"target"}, + }, + }, + { + name: "variable target counts allow two targets", + raw: "org/project/target/extra", + opts: util.ParseOptions{AllowImplicitOrg: false, MinTargets: 1, MaxTargets: 2}, + want: &util.Path{ + Organization: "org", + Project: "project", + Targets: []string{"target", "extra"}, + }, + }, + { + name: "variable target counts reject too few targets", + raw: "org/project", + opts: util.ParseOptions{AllowImplicitOrg: false, MinTargets: 1, MaxTargets: 2}, + wantErr: "expected 2-4 segments, got 2", + }, + { + name: "variable target counts reject too many targets", + raw: "org/project/target/extra/extra2", + opts: util.ParseOptions{AllowImplicitOrg: false, MinTargets: 1, MaxTargets: 2}, + wantErr: "expected 2-4 segments, got 5", + }, } for _, tt := range tests { @@ -416,6 +479,19 @@ func TestParse(t *testing.T) { } } +func TestResolveScopeDescriptor_EmptyOrganization(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockCtx := mocks.NewMockCmdContext(ctrl) + + descriptor, projectID, err := util.ResolveScopeDescriptor(mockCtx, "", "project") + require.Error(t, err) + assert.Contains(t, err.Error(), "organization is required") + assert.Nil(t, descriptor) + assert.Nil(t, projectID) +} + func TestResolveScopeDescriptor_NoProject(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish)