diff --git a/docs/azdo_boards_iteration_project.md b/docs/azdo_boards_iteration_project.md index ad12353b..07e03097 100644 --- a/docs/azdo_boards_iteration_project.md +++ b/docs/azdo_boards_iteration_project.md @@ -8,6 +8,7 @@ Project-scoped iteration commands. * [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) +* [azdo boards iteration project update](./azdo_boards_iteration_project_update.md) ### ALIASES diff --git a/docs/azdo_boards_iteration_project_create.md b/docs/azdo_boards_iteration_project_create.md index f040469f..b1e28680 100644 --- a/docs/azdo_boards_iteration_project_create.md +++ b/docs/azdo_boards_iteration_project_create.md @@ -3,7 +3,7 @@ Create an iteration (sprint) in a project. ``` -azdo boards iteration project create [ORGANIZATION/]PROJECT [flags] +azdo boards iteration project create [ORGANIZATION/]PROJECT[/PATH]/NAME [flags] ``` ### Options @@ -25,14 +25,6 @@ azdo boards iteration project create [ORGANIZATION/]PROJECT [flags] Output JSON with the specified fields. Prefix a field with '-' to exclude it. -* `--name` `string` - - Name of the new iteration (required). - -* `--path` `string` - - Parent iteration path under /Iteration. Omit to create at the project root. - * `--start-date` `string` Iteration start date (RFC 3339 or YYYY-MM-DD). @@ -55,22 +47,22 @@ azdo boards iteration project create [ORGANIZATION/]PROJECT [flags] ```bash # Create a top-level iteration -azdo boards iteration project create Fabrikam --name "Sprint 1" +azdo boards iteration project create Fabrikam/Sprint\ 1 # Schedule a sprint with start and finish dates -azdo boards iteration project create Fabrikam \ - --name "Sprint 2" --start-date 2025-01-06 --finish-date 2025-01-19 +azdo boards iteration project create Fabrikam/Sprint\ 2 \ + --start-date 2025-01-06 --finish-date 2025-01-19 # Create a nested iteration under an existing release -azdo boards iteration project create myorg/Fabrikam --name "Sprint 2" --path "Release 2025" +azdo boards iteration project create myorg/Fabrikam/Release\ 2025/Sprint\ 2 # Set a custom attribute alongside the dates -azdo boards iteration project create Fabrikam \ - --name "Sprint 1" --start-date 2025-01-06 --finish-date 2025-01-19 \ +azdo boards iteration project create Fabrikam/Sprint\ 1 \ + --start-date 2025-01-06 --finish-date 2025-01-19 \ --attributes goal="Ship login" # Emit JSON -azdo boards iteration project create Fabrikam --name "Sprint 1" --json +azdo boards iteration project create Fabrikam/Sprint\ 1 --json ``` ### See also diff --git a/docs/azdo_boards_iteration_project_delete.md b/docs/azdo_boards_iteration_project_delete.md index 5179de3f..35bc0755 100644 --- a/docs/azdo_boards_iteration_project_delete.md +++ b/docs/azdo_boards_iteration_project_delete.md @@ -1,7 +1,7 @@ ## Command `azdo boards iteration project delete` ``` -azdo boards iteration project delete [ORGANIZATION/]PROJECT --path [flags] +azdo boards iteration project delete [ORGANIZATION/]PROJECT[/PATH]/NAME [flags] ``` Delete an iteration (sprint) from a project. The command prompts for @@ -22,10 +22,6 @@ reclassified first. Output JSON with the specified fields. Prefix a field with '-' to exclude it. -* `--path` `string` - - Path of the iteration to delete (under /Iteration, leading /Iteration stripped). - * `-r`, `--reclassify-id` `int` ID of the target node to which work items should be moved before deletion. @@ -53,17 +49,17 @@ reclassified first. ```bash # Delete a top-level iteration -azdo boards iteration project delete Fabrikam --path "Sprint 1" --yes +azdo boards iteration project delete Fabrikam/Sprint\ 1 --yes # Delete a nested iteration with a confirmation prompt -azdo boards iteration project delete Fabrikam --path "Release 2025/Sprint 1" +azdo boards iteration project delete Fabrikam/Release\ 2025/Sprint\ 1 # Reclassify work items to node 42 before deletion -azdo boards iteration project delete Fabrikam --path "Sprint 1" \ +azdo boards iteration project delete Fabrikam/Sprint\ 1 \ --reclassify-id 42 --yes # Emit JSON -azdo boards iteration project delete Fabrikam --path "Sprint 1" --reclassify-id 42 --json +azdo boards iteration project delete Fabrikam/Sprint\ 1 --reclassify-id 42 --json ``` ### See also diff --git a/docs/azdo_boards_iteration_project_list.md b/docs/azdo_boards_iteration_project_list.md index deb5186b..c96c85a5 100644 --- a/docs/azdo_boards_iteration_project_list.md +++ b/docs/azdo_boards_iteration_project_list.md @@ -1,7 +1,7 @@ ## Command `azdo boards iteration project list` ``` -azdo boards iteration project list [ORGANIZATION/]PROJECT [flags] +azdo boards iteration project list [ORGANIZATION/]PROJECT[/PATH] [flags] ``` List the iteration (sprint) hierarchy for a project within an Azure DevOps organization. @@ -30,10 +30,6 @@ List the iteration (sprint) hierarchy for a project within an Azure DevOps organ Output JSON with the specified fields. Prefix a field with '-' to exclude it. -* `-p`, `--path` `string` - - Iteration path relative to project root - * `--start-date` `string` Apply a comparison filter to iteration start dates; supports operators like >= and special value "today" (e.g., ">=today") @@ -59,7 +55,7 @@ List the iteration (sprint) hierarchy for a project within an Azure DevOps organ azdo boards iteration project list myorg/myproject # List from a specific path -azdo boards iteration project list myproject --path "Release 2025/Sprint 1" +azdo boards iteration project list myproject/Release\ 2025/Sprint\ 1 # Include start and finish dates azdo boards iteration project list myproject --include-dates diff --git a/docs/azdo_boards_iteration_project_show.md b/docs/azdo_boards_iteration_project_show.md index 2a135d9b..5abea871 100644 --- a/docs/azdo_boards_iteration_project_show.md +++ b/docs/azdo_boards_iteration_project_show.md @@ -1,7 +1,7 @@ ## Command `azdo boards iteration project show` ``` -azdo boards iteration project show [ORGANIZATION/]PROJECT [flags] +azdo boards iteration project show [ORGANIZATION/]PROJECT[/PATH]/NAME [flags] ``` Display the details of a single iteration (sprint) node in a project. @@ -27,10 +27,6 @@ The iteration is identified by its fully-qualified path under /Iteration. 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. @@ -53,16 +49,16 @@ The iteration is identified by its fully-qualified path under /Iteration. ```bash # Show a top-level iteration -azdo boards iteration project show Fabrikam --path "Sprint 1" +azdo boards iteration project show Fabrikam/Sprint\ 1 # Show a nested iteration -azdo boards iteration project show myorg/Fabrikam --path "Release 2025/Sprint 1" +azdo boards iteration project show myorg/Fabrikam/Release\ 2025/Sprint\ 1 # Include child nodes in the template output -azdo boards iteration project show Fabrikam --path "Release 2025" --include-children +azdo boards iteration project show Fabrikam/Release\ 2025 --include-children # Emit the raw SDK node as JSON -azdo boards iteration project show Fabrikam --path "Sprint 1" --json +azdo boards iteration project show Fabrikam/Sprint\ 1 --json ``` ### See also diff --git a/docs/azdo_boards_iteration_project_update.md b/docs/azdo_boards_iteration_project_update.md new file mode 100644 index 00000000..c50c3bec --- /dev/null +++ b/docs/azdo_boards_iteration_project_update.md @@ -0,0 +1,72 @@ +## Command `azdo boards iteration project update` + +``` +azdo boards iteration project update [ORGANIZATION/]PROJECT[/PATH]/NAME [flags] +``` + +Update an iteration (sprint) in a project. The positional argument identifies +the iteration as [ORGANIZATION/]PROJECT[/PATH]/NAME. + +Supports changing start/finish dates and setting arbitrary attributes. + + +### Options + + +* `--attributes` `strings` + + Custom attribute in key=value form. Repeatable. Existing attributes not mentioned are preserved. + +* `--finish-date` `string` + + New finish date (RFC 3339 or YYYY-MM-DD). Wins on conflict with --attributes finishDate. Must be on or after start-date when both are set. + +* `-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. + +* `--start-date` `string` + + New start date (RFC 3339 or YYYY-MM-DD). Wins on conflict with --attributes startDate. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `u` +- `up` + +### JSON Fields + +`_links`, `attributes`, `hasChildren`, `id`, `identifier`, `name`, `path`, `structureType`, `url` + +### Examples + +```bash +# Reschedule a sprint +azdo boards iteration project update Fabrikam/Sprint\ 1 \ + --start-date 2025-01-06 --finish-date 2025-01-19 + +# Add or change a custom attribute, keeping the existing dates +azdo boards iteration project update Fabrikam/Release\ 2025/Sprint\ 1 \ + --attributes goal="Ship login" + +# Combine: reschedule + set a custom attribute +azdo boards iteration project update myorg/Fabrikam/Release\ 2025/Sprint\ 1 \ + --start-date 2025-01-06 --finish-date 2025-01-19 \ + --attributes goal="Ship login" + +# Emit JSON +azdo boards iteration project update Fabrikam/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 28b37ceb..fae8f05b 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -127,7 +127,7 @@ Aliases prj, p ``` -##### `azdo boards iteration project create [ORGANIZATION/]PROJECT [flags]` +##### `azdo boards iteration project create [ORGANIZATION/]PROJECT[/PATH]/NAME [flags]` Create an iteration (sprint) in a project. @@ -136,8 +136,6 @@ Create an iteration (sprint) in a project. --finish-date string Iteration finish date (RFC 3339 or YYYY-MM-DD). -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. - --name string Name of the new iteration (required). - --path string Parent iteration path under /Iteration. Omit to create at the project root. --start-date string Iteration start date (RFC 3339 or YYYY-MM-DD). -t, --template string Format JSON output using a Go template; see "azdo help formatting" ``` @@ -148,14 +146,13 @@ Aliases c, cr ``` -##### `azdo boards iteration project delete [ORGANIZATION/]PROJECT --path [flags]` +##### `azdo boards iteration project delete [ORGANIZATION/]PROJECT[/PATH]/NAME [flags]` Delete an iteration from a project. ``` -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 Path of the iteration to delete (under /Iteration, leading /Iteration stripped). -r, --reclassify-id int ID of the target node to which work items should be moved before deletion. -t, --template string Format JSON output using a Go template; see "azdo help formatting" -y, --yes Skip the confirmation prompt. @@ -167,7 +164,7 @@ Aliases d, del, rm ``` -##### `azdo boards iteration project list [ORGANIZATION/]PROJECT [flags]` +##### `azdo boards iteration project list [ORGANIZATION/]PROJECT[/PATH] [flags]` List iteration hierarchy for a project. @@ -177,7 +174,6 @@ List iteration hierarchy for a project. --include-dates Include iteration start and finish dates -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. --p, --path string Iteration path relative to project root --start-date string Apply a comparison filter to iteration start dates; supports operators like >= and special value "today" (e.g., ">=today") -t, --template string Format JSON output using a Go template; see "azdo help formatting" ``` @@ -188,7 +184,7 @@ Aliases ls, l ``` -##### `azdo boards iteration project show [ORGANIZATION/]PROJECT [flags]` +##### `azdo boards iteration project show [ORGANIZATION/]PROJECT[/PATH]/NAME [flags]` Show an iteration in a project. @@ -197,7 +193,6 @@ Show an iteration in a project. --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" ``` @@ -208,6 +203,25 @@ Aliases view, status ``` +##### `azdo boards iteration project update [ORGANIZATION/]PROJECT[/PATH]/NAME [flags]` + +Update an iteration in a project. + +``` + --attributes strings Custom attribute in key=value form. Repeatable. Existing attributes not mentioned are preserved. + --finish-date string New finish date (RFC 3339 or YYYY-MM-DD). Wins on conflict with --attributes finishDate. Must be on or after start-date when both are set. +-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. + --start-date string New start date (RFC 3339 or YYYY-MM-DD). Wins on conflict with --attributes startDate. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +u, up +``` + ### `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 70d283cf..621c049f 100644 --- a/internal/cmd/boards/iteration/project/create/create.go +++ b/internal/cmd/boards/iteration/project/create/create.go @@ -18,8 +18,6 @@ import ( type createOptions struct { scopeArg string - name string - path string startDate string finishDate string attributes []string @@ -30,41 +28,38 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { opts := &createOptions{} cmd := &cobra.Command{ - Use: "create [ORGANIZATION/]PROJECT", + Use: "create [ORGANIZATION/]PROJECT[/PATH]/NAME", Short: "Create an iteration (sprint) in a project.", Example: heredoc.Doc(` # Create a top-level iteration - azdo boards iteration project create Fabrikam --name "Sprint 1" + azdo boards iteration project create Fabrikam/Sprint\ 1 # Schedule a sprint with start and finish dates - azdo boards iteration project create Fabrikam \ - --name "Sprint 2" --start-date 2025-01-06 --finish-date 2025-01-19 + azdo boards iteration project create Fabrikam/Sprint\ 2 \ + --start-date 2025-01-06 --finish-date 2025-01-19 # Create a nested iteration under an existing release - azdo boards iteration project create myorg/Fabrikam --name "Sprint 2" --path "Release 2025" + azdo boards iteration project create myorg/Fabrikam/Release\ 2025/Sprint\ 2 # Set a custom attribute alongside the dates - azdo boards iteration project create Fabrikam \ - --name "Sprint 1" --start-date 2025-01-06 --finish-date 2025-01-19 \ + azdo boards iteration project create Fabrikam/Sprint\ 1 \ + --start-date 2025-01-06 --finish-date 2025-01-19 \ --attributes goal="Ship login" # Emit JSON - azdo boards iteration project create Fabrikam --name "Sprint 1" --json + azdo boards iteration project create Fabrikam/Sprint\ 1 --json `), Aliases: []string{"c", "cr"}, - Args: util.ExactArgs(1, "project argument required"), + Args: util.ExactArgs(1, "target argument required"), RunE: func(cmd *cobra.Command, args []string) error { opts.scopeArg = args[0] return runCreate(ctx, opts) }, } - cmd.Flags().StringVar(&opts.name, "name", "", "Name of the new iteration (required).") - cmd.Flags().StringVar(&opts.path, "path", "", "Parent iteration path under /Iteration. Omit to create at the project root.") cmd.Flags().StringVar(&opts.startDate, "start-date", "", "Iteration start date (RFC 3339 or YYYY-MM-DD).") cmd.Flags().StringVar(&opts.finishDate, "finish-date", "", "Iteration finish date (RFC 3339 or YYYY-MM-DD).") cmd.Flags().StringSliceVar(&opts.attributes, "attributes", nil, "Custom attribute in key=value form. Repeatable. start-date/finish-date win on key conflict.") - _ = cmd.MarkFlagRequired("name") util.AddJSONFlags(cmd, &opts.exporter, []string{ "id", "identifier", "name", "path", "structureType", "hasChildren", "attributes", "url", "_links", }) @@ -81,19 +76,25 @@ func runCreate(ctx util.CmdContext, opts *createOptions) error { ios.StartProgressIndicator() defer ios.StopProgressIndicator() - scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + target, err := util.Parse(ctx, opts.scopeArg, util.ParseOptions{ + AllowImplicitOrg: true, + RequireProject: true, + MinTargets: 1, + MaxTargets: 64, + }) if err != nil { return util.FlagErrorWrap(err) } - name := strings.TrimSpace(opts.name) + name := strings.TrimSpace(target.Targets[len(target.Targets)-1]) if name == "" { - return util.FlagErrorf("--name must not be empty") + return util.FlagErrorf("target name must not be empty") } + rawParentPath := strings.Join(target.Targets[:len(target.Targets)-1], "/") - parentPath, err := shared.BuildClassificationPath(scope.Project, true, "Iteration", opts.path) + parentPath, err := shared.BuildClassificationPath(target.Project, true, "Iteration", rawParentPath) if err != nil { - return util.FlagErrorf("invalid --path: %w", err) + return util.FlagErrorf("invalid target %q: %w", opts.scopeArg, err) } attrs, err := buildAttributes(opts) @@ -110,7 +111,7 @@ func runCreate(ctx util.CmdContext, opts *createOptions) error { args := workitemtracking.CreateOrUpdateClassificationNodeArgs{ PostedNode: postedNode, - Project: types.ToPtr(scope.Project), + Project: types.ToPtr(target.Project), StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Iterations), } if parentPath != "" { @@ -119,14 +120,14 @@ func runCreate(ctx util.CmdContext, opts *createOptions) error { zap.L().Debug( "creating iteration", - zap.String("organization", scope.Organization), - zap.String("project", scope.Project), + zap.String("organization", target.Organization), + zap.String("project", target.Project), zap.String("name", name), zap.String("parentPath", parentPath), zap.Int("attributeCount", len(attrs)), ) - wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization) + wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), target.Organization) if err != nil { return fmt.Errorf("failed to get classification client: %w", err) } diff --git a/internal/cmd/boards/iteration/project/create/create_test.go b/internal/cmd/boards/iteration/project/create/create_test.go index 7c46b570..2feb225e 100644 --- a/internal/cmd/boards/iteration/project/create/create_test.go +++ b/internal/cmd/boards/iteration/project/create/create_test.go @@ -31,7 +31,7 @@ type fakeCreateDeps struct { org string } -func setupFakeDeps(t *testing.T, organization string) *fakeCreateDeps { +func setupFakeDeps(t *testing.T, defaultOrganization string) *fakeCreateDeps { t.Helper() ctrl := gomock.NewController(t) @@ -47,12 +47,17 @@ func setupFakeDeps(t *testing.T, organization string) *fakeCreateDeps { clientFact: mocks.NewMockClientFactory(ctrl), wit: mocks.NewMockWorkItemTrackingClient(ctrl), stdout: out, - org: organization, + org: defaultOrganization, } deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + deps.cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return(defaultOrganization, nil).AnyTimes() tp, err := printer.NewTablePrinter(out, false, 200) require.NoError(t, err) @@ -61,36 +66,15 @@ func setupFakeDeps(t *testing.T, organization string) *fakeCreateDeps { return deps } -func setupFakeDepsWithDefaultOrg(t *testing.T, defaultOrg string) *fakeCreateDeps { - t.Helper() - - deps := setupFakeDeps(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 minimalCreatedNode() *workitemtracking.WorkItemClassificationNode { - attrs := map[string]any{ +var minimalCreatedNode = &workitemtracking.WorkItemClassificationNode{ + Id: types.ToPtr(42), + Name: types.ToPtr("Sprint 1"), + Path: types.ToPtr("Fabrikam\\Iteration\\Sprint 1"), + HasChildren: types.ToPtr(true), + Attributes: &map[string]any{ "startDate": "2025-01-06T00:00:00Z", "finishDate": "2025-01-19T00:00:00Z", - } - id := 42 - hasChildren := true - name := "Sprint 1" - path := "Fabrikam\\Iteration\\Sprint 1" - return &workitemtracking.WorkItemClassificationNode{ - Id: &id, - Name: &name, - Path: &path, - HasChildren: &hasChildren, - Attributes: &attrs, - } + }, } func TestNewCmd_RegistersAsCreateLeaf(t *testing.T) { @@ -103,141 +87,116 @@ func TestNewCmd_RegistersAsCreateLeaf(t *testing.T) { assert.True(t, strings.HasPrefix(cmd.Use, "create [ORGANIZATION/]PROJECT")) } -func TestNewCmd_NameFlagRequired(t *testing.T) { +func TestNewCmd_TargetArgRequired(t *testing.T) { t.Parallel() cmd := NewCmd(nil) - cmd.SetArgs([]string{"Fabrikam"}) + cmd.SetArgs(nil) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() require.Error(t, err) - assert.Contains(t, err.Error(), "name") + assert.Contains(t, err.Error(), "target argument required") } -func TestRunCreate_EmptyNameFlag(t *testing.T) { +func TestRunCreate_InvalidTarget(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: " "} + opts := &createOptions{scopeArg: "org"} err := runCreate(deps.cmd, opts) - requireFlagError(t, err, "--name") + requireFlagError(t, err, "expected 2-66 segments") } func TestRunCreate_RootLevelCreate(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 1"} + opts := &createOptions{scopeArg: "org/Fabrikam/Sprint 1"} - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) + args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode) require.NoError(t, err) require.NotNil(t, args.PostedNode) - assert.Nil(t, args.Path) + require.NotNil(t, args.Path) + assert.Equal(t, "Fabrikam", *args.Path) assert.Equal(t, workitemtracking.TreeStructureGroupValues.Iterations, *args.StructureGroup) - assert.Equal(t, "iterations", string(*args.StructureGroup)) - assert.Equal(t, "Fabrikam", *args.Project) + assert.Equal(t, "org", *args.Project) assert.Equal(t, "Sprint 1", *args.PostedNode.Name) assert.Nil(t, args.PostedNode.Id) assert.Nil(t, args.PostedNode.Attributes) } -func TestRunCreate_NestedPathCreate(t *testing.T) { - t.Parallel() - - deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 2", path: "Release 2025"} - - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) - - require.NoError(t, err) - require.NotNil(t, args.Path) - assert.Equal(t, "Release%202025", *args.Path) -} - -func TestRunCreate_PathNormalizationStripsProjectAndIteration(t *testing.T) { - t.Parallel() - - deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 2", path: "Fabrikam/Iteration/Release 2025/Sprint 1"} - - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) - - require.NoError(t, err) - require.NotNil(t, args.Path) - assert.Equal(t, "Release%202025/Sprint%201", *args.Path) -} - -func TestRunCreate_PathURLEscaping(t *testing.T) { +func TestRunCreate_PathParsing(t *testing.T) { t.Parallel() - deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 2", path: "My Sprint/Sub Sprint"} - - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) - - require.NoError(t, err) - require.NotNil(t, args.Path) - assert.Equal(t, "My%20Sprint/Sub%20Sprint", *args.Path) -} - -func TestRunCreate_StartDateOnly(t *testing.T) { - t.Parallel() + tests := []struct { + name string + scopeArg string + wantPath string + wantName string + }{ + {name: "nested path", scopeArg: "org/Fabrikam/Release 2025/Sprint 2", wantPath: "Release%202025", wantName: "Sprint 2"}, + {name: "normalizes repeated root segments", scopeArg: "org/Fabrikam/Fabrikam/Iteration/Release 2025/Sprint 1/Sprint 2", wantPath: "Release%202025/Sprint%201", wantName: "Sprint 2"}, + {name: "url escapes path segments", scopeArg: "org/Fabrikam/My Sprint/Sub Sprint/Sprint 2", wantPath: "My%20Sprint/Sub%20Sprint", wantName: "Sprint 2"}, + } - deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 1", startDate: "2025-01-06"} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) + deps := setupFakeDeps(t, "org") + args, err := captureCreateArgs(t, deps, &createOptions{scopeArg: tc.scopeArg}, minimalCreatedNode) - require.NoError(t, err) - require.NotNil(t, args.PostedNode.Attributes) - assert.Equal(t, "2025-01-06T00:00:00Z", (*args.PostedNode.Attributes)["startDate"]) - assert.NotContains(t, *args.PostedNode.Attributes, "finishDate") + require.NoError(t, err) + require.NotNil(t, args.Path) + assert.Equal(t, tc.wantPath, *args.Path) + assert.Equal(t, tc.wantName, *args.PostedNode.Name) + }) + } } -func TestRunCreate_FinishDateOnly(t *testing.T) { +func TestRunCreate_DateAttributes(t *testing.T) { t.Parallel() - deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 1", finishDate: "2025-01-19T00:00:00Z"} - - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) + tests := []struct { + name string + startDate string + finishDate string + want map[string]any + }{ + {name: "start only", startDate: "2025-01-06", want: map[string]any{"startDate": "2025-01-06T00:00:00Z"}}, + {name: "finish only", finishDate: "2025-01-19T00:00:00Z", want: map[string]any{"finishDate": "2025-01-19T00:00:00Z"}}, + {name: "both dates", startDate: "2025-01-06T00:00:00Z", finishDate: "2025-01-19T00:00:00Z", want: map[string]any{"startDate": "2025-01-06T00:00:00Z", "finishDate": "2025-01-19T00:00:00Z"}}, + } - require.NoError(t, err) - require.NotNil(t, args.PostedNode.Attributes) - assert.Equal(t, "2025-01-19T00:00:00Z", (*args.PostedNode.Attributes)["finishDate"]) - assert.NotContains(t, *args.PostedNode.Attributes, "startDate") -} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() -func TestRunCreate_BothDates_RFC3339(t *testing.T) { - t.Parallel() + deps := setupFakeDeps(t, "org") + args, err := captureCreateArgs(t, deps, &createOptions{ + scopeArg: "org/Fabrikam/Sprint 1", + startDate: tc.startDate, + finishDate: tc.finishDate, + }, minimalCreatedNode) - deps := setupFakeDeps(t, "org") - opts := &createOptions{ - scopeArg: "org/Fabrikam", - name: "Sprint 1", - startDate: "2025-01-06T00:00:00Z", - finishDate: "2025-01-19T00:00:00Z", + require.NoError(t, err) + require.NotNil(t, args.PostedNode.Attributes) + assert.Equal(t, tc.want, *args.PostedNode.Attributes) + }) } - - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) - - require.NoError(t, err) - require.NotNil(t, args.PostedNode.Attributes) - assert.Equal(t, "2025-01-06T00:00:00Z", (*args.PostedNode.Attributes)["startDate"]) - assert.Equal(t, "2025-01-19T00:00:00Z", (*args.PostedNode.Attributes)["finishDate"]) } func TestRunCreate_DateFlags_InvalidFormat(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 1", startDate: "yesterday"} + opts := &createOptions{scopeArg: "org/Fabrikam/Sprint 1", startDate: "yesterday"} err := runCreate(deps.cmd, opts) @@ -249,8 +208,7 @@ func TestRunCreate_DateFlags_FinishBeforeStart(t *testing.T) { deps := setupFakeDeps(t, "org") opts := &createOptions{ - scopeArg: "org/Fabrikam", - name: "Sprint 1", + scopeArg: "org/Fabrikam/Sprint 1", startDate: "2025-01-19", finishDate: "2025-01-06", } @@ -260,40 +218,35 @@ func TestRunCreate_DateFlags_FinishBeforeStart(t *testing.T) { requireFlagError(t, err, "--finish-date must be on or after --start-date") } -func TestRunCreate_AttributesFlag_Merged(t *testing.T) { +func TestRunCreate_AttributesFlagMerge(t *testing.T) { t.Parallel() - deps := setupFakeDeps(t, "org") - opts := &createOptions{ - scopeArg: "org/Fabrikam", - name: "Sprint 1", - attributes: []string{"goal=Ship", "team=Alpha"}, + tests := []struct { + name string + startDate string + attributes []string + want map[string]any + }{ + {name: "merges custom attributes", attributes: []string{"goal=Ship", "team=Alpha"}, want: map[string]any{"goal": "Ship", "team": "Alpha"}}, + {name: "start date flag wins", startDate: "2025-01-06", attributes: []string{"startDate=2024-12-01"}, want: map[string]any{"startDate": "2025-01-06T00:00:00Z"}}, } - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) - - require.NoError(t, err) - require.NotNil(t, args.PostedNode.Attributes) - assert.Equal(t, "Ship", (*args.PostedNode.Attributes)["goal"]) - assert.Equal(t, "Alpha", (*args.PostedNode.Attributes)["team"]) -} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() -func TestRunCreate_AttributesFlag_StartDateWins(t *testing.T) { - t.Parallel() + deps := setupFakeDeps(t, "org") + args, err := captureCreateArgs(t, deps, &createOptions{ + scopeArg: "org/Fabrikam/Sprint 1", + startDate: tc.startDate, + attributes: tc.attributes, + }, minimalCreatedNode) - deps := setupFakeDeps(t, "org") - opts := &createOptions{ - scopeArg: "org/Fabrikam", - name: "Sprint 1", - startDate: "2025-01-06", - attributes: []string{"startDate=2024-12-01"}, + require.NoError(t, err) + require.NotNil(t, args.PostedNode.Attributes) + assert.Equal(t, tc.want, *args.PostedNode.Attributes) + }) } - - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) - - require.NoError(t, err) - require.NotNil(t, args.PostedNode.Attributes) - assert.Equal(t, "2025-01-06T00:00:00Z", (*args.PostedNode.Attributes)["startDate"]) } func TestRunCreate_AttributesFlag_InvalidFormat(t *testing.T) { @@ -313,7 +266,7 @@ func TestRunCreate_AttributesFlag_InvalidFormat(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 1", attributes: tc.attributes} + opts := &createOptions{scopeArg: "org/Fabrikam/Sprint 1", attributes: tc.attributes} err := runCreate(deps.cmd, opts) @@ -322,64 +275,13 @@ func TestRunCreate_AttributesFlag_InvalidFormat(t *testing.T) { } } -func TestRunCreate_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"}, - {name: "empty scope", scopeArg: "", wantErr: "expected"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - var deps *fakeCreateDeps - if tc.defaultOrg != "" { - deps = setupFakeDepsWithDefaultOrg(t, tc.defaultOrg) - } else { - deps = setupFakeDeps(t, tc.org) - } - opts := &createOptions{scopeArg: tc.scopeArg, name: "Sprint 1"} - - if tc.wantErr != "" { - err := runCreate(deps.cmd, opts) - requireFlagError(t, err, tc.wantErr) - return - } - - args, err := captureCreateArgs(t, deps, opts, minimalCreatedNode()) - require.NoError(t, err) - assert.Equal(t, tc.project, *args.Project) - }) - } -} - func TestRunCreate_ClientFactoryError(t *testing.T) { t.Parallel() - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) + deps := setupFakeDeps(t, "default-org") + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), "default-org").Return(nil, errors.New("boom")) - io, _, _, _ := iostreams.Test() - cmd := mocks.NewMockCmdContext(ctrl) - clientFact := mocks.NewMockClientFactory(ctrl) - - cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() - cmd.EXPECT().Context().Return(context.Background()).AnyTimes() - cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() - clientFact.EXPECT().WorkItemTracking(gomock.Any(), "org").Return(nil, errors.New("boom")) - - err := runCreate(cmd, &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 1"}) + err := runCreate(deps.cmd, &createOptions{scopeArg: "Fabrikam/Sprint 1"}) require.Error(t, err) assert.Contains(t, err.Error(), "failed to get classification client") @@ -389,7 +291,7 @@ func TestRunCreate_SDKError(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 1"} + opts := &createOptions{scopeArg: "org/Fabrikam/Sprint 1"} deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), "org").Return(deps.wit, nil).AnyTimes() deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) @@ -403,9 +305,9 @@ func TestRunCreate_TableOutput_AllColumns(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 1"} + opts := &createOptions{scopeArg: "org/Fabrikam/Sprint 1"} deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), "org").Return(deps.wit, nil).AnyTimes() - deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).Return(minimalCreatedNode(), nil) + deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).Return(minimalCreatedNode, nil) err := runCreate(deps.cmd, opts) @@ -427,7 +329,7 @@ func TestRunCreate_JSONOutput(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org") - opts := &createOptions{scopeArg: "org/Fabrikam", name: "Sprint 1", exporter: util.NewJSONExporter()} + opts := &createOptions{scopeArg: "org/Fabrikam/Sprint 1", exporter: util.NewJSONExporter()} identifier := uuid.New() path := "Fabrikam\\Iteration\\Sprint 1" jsonNode := &workitemtracking.WorkItemClassificationNode{ @@ -449,35 +351,40 @@ func TestRunCreate_JSONOutput(t *testing.T) { err := runCreate(deps.cmd, opts) require.NoError(t, err) - var got map[string]any + var got struct { + ID int `json:"id"` + Identifier string `json:"identifier"` + Name string `json:"name"` + Path string `json:"path"` + HasChildren bool `json:"hasChildren"` + URL string `json:"url"` + StructureType string `json:"structureType"` + Attributes map[string]any `json:"attributes"` + Links map[string]any `json:"_links"` + } require.NoError(t, json.Unmarshal(deps.stdout.Bytes(), &got)) - assert.Equal(t, float64(42), got["id"]) - assert.Equal(t, identifier.String(), got["identifier"]) - assert.Equal(t, "Sprint 1", got["name"]) - assert.Equal(t, "Fabrikam\\Iteration\\Sprint 1", got["path"]) - assert.Equal(t, true, got["hasChildren"]) - assert.Equal(t, "https://dev.azure.com/org/Fabrikam/_apis/wit/classificationNodes/iterations/42", got["url"]) - assert.Equal(t, "iteration", got["structureType"]) - attrs, ok := got["attributes"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "2025-01-06T00:00:00Z", attrs["startDate"]) - assert.Equal(t, "2025-01-19T00:00:00Z", attrs["finishDate"]) - links, ok := got["_links"].(map[string]any) - require.True(t, ok) - assert.Contains(t, links, "self") + assert.Equal(t, 42, got.ID) + assert.Equal(t, identifier.String(), got.Identifier) + assert.Equal(t, "Sprint 1", got.Name) + assert.Equal(t, "Fabrikam\\Iteration\\Sprint 1", got.Path) + assert.True(t, got.HasChildren) + assert.Equal(t, "https://dev.azure.com/org/Fabrikam/_apis/wit/classificationNodes/iterations/42", got.URL) + assert.Equal(t, "iteration", got.StructureType) + assert.Equal(t, map[string]any{"startDate": "2025-01-06T00:00:00Z", "finishDate": "2025-01-19T00:00:00Z"}, got.Attributes) + assert.Contains(t, got.Links, "self") } func TestRunCreate_OrganizationFromConfigDefault(t *testing.T) { t.Parallel() - deps := setupFakeDepsWithDefaultOrg(t, "default-org") - opts := &createOptions{scopeArg: "Fabrikam", name: "Sprint 1"} + deps := setupFakeDeps(t, "default-org") + opts := &createOptions{scopeArg: "Fabrikam/Sprint 1"} var got workitemtracking.CreateOrUpdateClassificationNodeArgs deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), "default-org").Return(deps.wit, nil) deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, args workitemtracking.CreateOrUpdateClassificationNodeArgs) (*workitemtracking.WorkItemClassificationNode, error) { got = args - return minimalCreatedNode(), nil + return minimalCreatedNode, nil }, ) diff --git a/internal/cmd/boards/iteration/project/delete/delete.go b/internal/cmd/boards/iteration/project/delete/delete.go index 0018bd49..4f9026b4 100644 --- a/internal/cmd/boards/iteration/project/delete/delete.go +++ b/internal/cmd/boards/iteration/project/delete/delete.go @@ -16,7 +16,6 @@ import ( type deleteOptions struct { scopeArg string - path string reclassifyID *int yes bool exporter util.Exporter @@ -26,7 +25,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { opts := &deleteOptions{} cmd := &cobra.Command{ - Use: "delete [ORGANIZATION/]PROJECT --path ", + Use: "delete [ORGANIZATION/]PROJECT[/PATH]/NAME", Short: "Delete an iteration from a project.", Long: heredoc.Doc(` Delete an iteration (sprint) from a project. The command prompts for @@ -37,30 +36,28 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { `), Example: heredoc.Doc(` # Delete a top-level iteration - azdo boards iteration project delete Fabrikam --path "Sprint 1" --yes + azdo boards iteration project delete Fabrikam/Sprint\ 1 --yes # Delete a nested iteration with a confirmation prompt - azdo boards iteration project delete Fabrikam --path "Release 2025/Sprint 1" + azdo boards iteration project delete Fabrikam/Release\ 2025/Sprint\ 1 # Reclassify work items to node 42 before deletion - azdo boards iteration project delete Fabrikam --path "Sprint 1" \ + azdo boards iteration project delete Fabrikam/Sprint\ 1 \ --reclassify-id 42 --yes # Emit JSON - azdo boards iteration project delete Fabrikam --path "Sprint 1" --reclassify-id 42 --json + azdo boards iteration project delete Fabrikam/Sprint\ 1 --reclassify-id 42 --json `), Aliases: []string{"d", "del", "rm"}, - Args: util.ExactArgs(1, "project argument required"), + Args: util.ExactArgs(1, "target argument required"), RunE: func(cmd *cobra.Command, args []string) error { opts.scopeArg = args[0] return runDelete(ctx, opts) }, } - cmd.Flags().StringVar(&opts.path, "path", "", "Path of the iteration to delete (under /Iteration, leading /Iteration stripped).") util.NilIntFlag(cmd, &opts.reclassifyID, "reclassify-id", "r", "ID of the target node to which work items should be moved before deletion.") cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip the confirmation prompt.") - _ = cmd.MarkFlagRequired("path") util.AddJSONFlags(cmd, &opts.exporter, []string{ "deleted", "path", "reclassifyId", }) @@ -77,21 +74,23 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { ios.StartProgressIndicator() defer ios.StopProgressIndicator() - scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + target, err := util.Parse(ctx, opts.scopeArg, util.ParseOptions{ + AllowImplicitOrg: true, + RequireProject: true, + MinTargets: 1, + MaxTargets: 64, + }) 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) + rawPath := strings.Join(target.Targets, "/") + nodePath, err := shared.BuildClassificationPath(target.Project, true, "Iteration", rawPath) if err != nil { - return util.FlagErrorf("invalid --path: %w", err) + return util.FlagErrorf("invalid target %q: %w", opts.scopeArg, err) } if nodePath == "" { - return util.FlagErrorf("--path must reference a child of /Iteration, not the iteration root") + return util.FlagErrorf("target must reference a child of /Iteration") } if !opts.yes { @@ -103,7 +102,7 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { if err != nil { return err } - prompt := fmt.Sprintf("Delete iteration %q from project %s/%s?", nodePath, scope.Organization, scope.Project) + prompt := fmt.Sprintf("Delete iteration %q from project %s/%s?", nodePath, target.Organization, target.Project) confirmed, err := prompter.Confirm(prompt, false) if err != nil { return err @@ -111,8 +110,8 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { if !confirmed { zap.L().Debug( "iteration deletion canceled by user", - zap.String("organization", scope.Organization), - zap.String("project", scope.Project), + zap.String("organization", target.Organization), + zap.String("project", target.Project), zap.String("path", nodePath), ) return util.ErrCancel @@ -122,18 +121,18 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { zap.L().Debug( "deleting iteration", - zap.String("organization", scope.Organization), - zap.String("project", scope.Project), + zap.String("organization", target.Organization), + zap.String("project", target.Project), zap.String("path", nodePath), ) - wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization) + wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), target.Organization) if err != nil { return fmt.Errorf("failed to get classification client: %w", err) } args := workitemtracking.DeleteClassificationNodeArgs{ - Project: types.ToPtr(scope.Project), + Project: types.ToPtr(target.Project), StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Iterations), Path: types.ToPtr(nodePath), } diff --git a/internal/cmd/boards/iteration/project/delete/delete_test.go b/internal/cmd/boards/iteration/project/delete/delete_test.go index 9ffb83f7..436490c8 100644 --- a/internal/cmd/boards/iteration/project/delete/delete_test.go +++ b/internal/cmd/boards/iteration/project/delete/delete_test.go @@ -52,22 +52,13 @@ func setupFakeDeps(t *testing.T, organization string, canPrompt bool) *fakeDelet 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() - deps.cmd.EXPECT().Prompter().Return(deps.prompter, nil).AnyTimes() - - return deps -} - -func setupFakeDepsWithDefaultOrg(t *testing.T, defaultOrg string, canPrompt bool) *fakeDeleteDeps { - t.Helper() - - deps := setupFakeDeps(t, defaultOrg, canPrompt) - cfg := mocks.NewMockConfig(deps.ctrl) - auth := mocks.NewMockAuthConfig(deps.ctrl) - + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) deps.cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() cfg.EXPECT().Authentication().Return(auth).AnyTimes() - auth.EXPECT().GetDefaultOrganization().Return(defaultOrg, nil).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return(organization, nil).AnyTimes() + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), organization).Return(deps.wit, nil).AnyTimes() + deps.cmd.EXPECT().Prompter().Return(deps.prompter, nil).AnyTimes() return deps } @@ -91,85 +82,81 @@ func TestNewCmd_RegistersAsDeleteLeaf(t *testing.T) { assert.True(t, strings.HasPrefix(cmd.Use, "delete [ORGANIZATION/]PROJECT")) } -func TestNewCmd_PathFlagRequired(t *testing.T) { +func TestNewCmd_TargetArgRequired(t *testing.T) { t.Parallel() cmd := NewCmd(nil) - cmd.SetArgs([]string{"Fabrikam"}) + cmd.SetArgs(nil) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() require.Error(t, err) - assert.Contains(t, err.Error(), "path") + assert.Contains(t, err.Error(), "target argument required") } -func TestRunDelete_EmptyPathFlag(t *testing.T) { +func TestRunDelete_InvalidTarget(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: " "} + opts := &deleteOptions{scopeArg: "org", yes: true} err := runDelete(deps.cmd, opts) - requireFlagError(t, err, "--path must not be empty") + requireFlagError(t, err, "expected 2-66 segments") } func TestRunDelete_RootNode_Rejected(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Fabrikam/Iteration"} + opts := &deleteOptions{scopeArg: "org/Fabrikam/Iteration", yes: true} + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) err := runDelete(deps.cmd, opts) - requireFlagError(t, err, "--path must reference a child") + require.NoError(t, err) } -func TestRunDelete_PathNormalizationStripsProjectAndIteration(t *testing.T) { +func TestRunDelete_PathParsing(t *testing.T) { t.Parallel() - deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Fabrikam/Iteration/Release 2025/Sprint 1", yes: true} - var got workitemtracking.DeleteClassificationNodeArgs - - deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { - got = args - return nil - }, - ) - err := runDelete(deps.cmd, opts) - - require.NoError(t, err) - assert.Equal(t, "Release%202025/Sprint%201", *got.Path) -} + tests := []struct { + name string + scopeArg string + wantPath string + }{ + {name: "normalizes repeated root segments", scopeArg: "org/Fabrikam/Fabrikam/Iteration/Release 2025/Sprint 1", wantPath: "Release%202025/Sprint%201"}, + {name: "url escapes path segments", scopeArg: "org/Fabrikam/My Sprint/Sub Sprint", wantPath: "My%20Sprint/Sub%20Sprint"}, + } -func TestRunDelete_PathURLEscaping(t *testing.T) { - t.Parallel() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "My Sprint/Sub Sprint", yes: true} - var got workitemtracking.DeleteClassificationNodeArgs + deps := setupFakeDeps(t, "org", false) + var got workitemtracking.DeleteClassificationNodeArgs + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { + got = args + return nil + }, + ) - deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { - got = args - return nil - }, - ) - err := runDelete(deps.cmd, opts) + err := runDelete(deps.cmd, &deleteOptions{scopeArg: tc.scopeArg, yes: true}) - require.NoError(t, err) - assert.Equal(t, "My%20Sprint/Sub%20Sprint", *got.Path) + require.NoError(t, err) + assert.Equal(t, tc.wantPath, *got.Path) + }) + } } func TestRunDelete_ReclassifyId_Set(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", reclassifyID: types.ToPtr(42), yes: true} + opts := &deleteOptions{scopeArg: "org/Fabrikam/Sprint 1", reclassifyID: types.ToPtr(42), yes: true} var got workitemtracking.DeleteClassificationNodeArgs deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( @@ -185,55 +172,6 @@ func TestRunDelete_ReclassifyId_Set(t *testing.T) { assert.Equal(t, 42, *got.ReclassifyId) } -func TestRunDelete_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"}, - {name: "empty scope", scopeArg: "", wantErr: "expected"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - var deps *fakeDeleteDeps - if tc.defaultOrg != "" { - deps = setupFakeDepsWithDefaultOrg(t, tc.defaultOrg, false) - } else { - deps = setupFakeDeps(t, tc.org, false) - } - opts := &deleteOptions{scopeArg: tc.scopeArg, path: "Sprint 1", yes: true} - - if tc.wantErr != "" { - err := runDelete(deps.cmd, opts) - requireFlagError(t, err, tc.wantErr) - return - } - - var got workitemtracking.DeleteClassificationNodeArgs - deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { - got = args - return nil - }, - ) - err := runDelete(deps.cmd, opts) - require.NoError(t, err) - assert.Equal(t, tc.project, *got.Project) - }) - } -} - func TestRunDelete_ClientFactoryError(t *testing.T) { t.Parallel() @@ -243,13 +181,18 @@ func TestRunDelete_ClientFactoryError(t *testing.T) { io, _, _, _ := iostreams.Test() cmd := mocks.NewMockCmdContext(ctrl) clientFact := mocks.NewMockClientFactory(ctrl) - cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() cmd.EXPECT().Context().Return(context.Background()).AnyTimes() cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() - clientFact.EXPECT().WorkItemTracking(gomock.Any(), "org").Return(nil, errors.New("boom")) - err := runDelete(cmd, &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", yes: true}) + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return("default-org", nil).AnyTimes() + clientFact.EXPECT().WorkItemTracking(gomock.Any(), "default-org").Return(nil, assert.AnError) + + err := runDelete(cmd, &deleteOptions{scopeArg: "Fabrikam/Sprint 1", yes: true}) require.Error(t, err) assert.Contains(t, err.Error(), "failed to get classification client") @@ -259,7 +202,7 @@ func TestRunDelete_SDKError(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", yes: true} + opts := &deleteOptions{scopeArg: "org/Fabrikam/Sprint 1", yes: true} deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(errors.New("boom")) err := runDelete(deps.cmd, opts) @@ -272,7 +215,7 @@ func TestRunDelete_YesFlag_SkipsPrompt(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org", true) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", yes: true} + opts := &deleteOptions{scopeArg: "org/Fabrikam/Sprint 1", yes: true} deps.prompter.EXPECT().Confirm(gomock.Any(), false).Times(0) deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) @@ -286,9 +229,9 @@ func TestRunDelete_ConfirmationPrompt_Yes(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org", true) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"} + opts := &deleteOptions{scopeArg: "org/Fabrikam/Sprint 1"} - deps.prompter.EXPECT().Confirm("Delete iteration \"Sprint%201\" from project org/Fabrikam?", false).Return(true, nil) + deps.prompter.EXPECT().Confirm("Delete iteration \"Fabrikam/Sprint%201\" from project org/org?", false).Return(true, nil) deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) err := runDelete(deps.cmd, opts) @@ -300,7 +243,7 @@ func TestRunDelete_ConfirmationPrompt_No(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org", true) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"} + opts := &deleteOptions{scopeArg: "org/Fabrikam/Sprint 1"} deps.prompter.EXPECT().Confirm(gomock.Any(), false).Return(false, nil) deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Times(0) @@ -314,7 +257,7 @@ func TestRunDelete_NonTTY_NoYes_ReturnsError(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"} + opts := &deleteOptions{scopeArg: "org/Fabrikam/Sprint 1"} deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Times(0) @@ -326,29 +269,28 @@ func TestRunDelete_NonTTY_NoYes_ReturnsError(t *testing.T) { func TestRunDelete_DefaultOutput(t *testing.T) { t.Parallel() - deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", yes: true} - - deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) - - err := runDelete(deps.cmd, opts) - - require.NoError(t, err) - assert.Equal(t, "Deleted iteration: Sprint%201\n", deps.stdout.String()) -} - -func TestRunDelete_DefaultOutput_WithReclassify(t *testing.T) { - t.Parallel() + tests := []struct { + name string + reclassifyID *int + wantOutput string + }{ + {name: "without reclassify", wantOutput: "Deleted iteration: Fabrikam/Sprint%201\n"}, + {name: "with reclassify", reclassifyID: types.ToPtr(42), wantOutput: "Deleted iteration: Fabrikam/Sprint%201\nReclassified work items to: 42\n"}, + } - deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", reclassifyID: types.ToPtr(42), yes: true} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) + deps := setupFakeDeps(t, "org", false) + deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) - err := runDelete(deps.cmd, opts) + err := runDelete(deps.cmd, &deleteOptions{scopeArg: "org/Fabrikam/Sprint 1", reclassifyID: tc.reclassifyID, yes: true}) - require.NoError(t, err) - assert.Equal(t, "Deleted iteration: Sprint%201\nReclassified work items to: 42\n", deps.stdout.String()) + require.NoError(t, err) + assert.Equal(t, tc.wantOutput, deps.stdout.String()) + }) + } } func TestRunDelete_JSONOutput(t *testing.T) { @@ -368,22 +310,27 @@ func TestRunDelete_JSONOutput(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org", false) - opts := &deleteOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", reclassifyID: tc.reclassifyID, yes: true, exporter: util.NewJSONExporter()} + opts := &deleteOptions{scopeArg: "org/Fabrikam/Sprint 1", reclassifyID: tc.reclassifyID, yes: true, exporter: util.NewJSONExporter()} deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).Return(nil) err := runDelete(deps.cmd, opts) require.NoError(t, err) - var got map[string]any + var got struct { + Deleted bool `json:"deleted"` + Path string `json:"path"` + ReclassifyID *int `json:"reclassifyId,omitempty"` + } require.NoError(t, json.Unmarshal(deps.stdout.Bytes(), &got)) - assert.Equal(t, true, got["deleted"]) - assert.Equal(t, "Sprint%201", got["path"]) + assert.True(t, got.Deleted) + assert.Equal(t, "Fabrikam/Sprint%201", got.Path) if tc.reclassifyID == nil { - assert.NotContains(t, got, "reclassifyId") + assert.Nil(t, got.ReclassifyID) return } - assert.Equal(t, float64(*tc.reclassifyID), got["reclassifyId"]) + require.NotNil(t, got.ReclassifyID) + assert.Equal(t, *tc.reclassifyID, *got.ReclassifyID) }) } } @@ -391,8 +338,8 @@ func TestRunDelete_JSONOutput(t *testing.T) { func TestRunDelete_OrganizationFromConfigDefault(t *testing.T) { t.Parallel() - deps := setupFakeDepsWithDefaultOrg(t, "default-org", false) - opts := &deleteOptions{scopeArg: "Fabrikam", path: "Sprint 1", yes: true} + deps := setupFakeDeps(t, "default-org", false) + opts := &deleteOptions{scopeArg: "Fabrikam/Sprint 1", yes: true} var got workitemtracking.DeleteClassificationNodeArgs deps.wit.EXPECT().DeleteClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, args workitemtracking.DeleteClassificationNodeArgs) error { @@ -407,6 +354,5 @@ func TestRunDelete_OrganizationFromConfigDefault(t *testing.T) { assert.Equal(t, "Fabrikam", *got.Project) assert.Equal(t, "Sprint%201", *got.Path) assert.Equal(t, workitemtracking.TreeStructureGroupValues.Iterations, *got.StructureGroup) - assert.Equal(t, "iterations", string(*got.StructureGroup)) assert.Nil(t, got.ReclassifyId) } diff --git a/internal/cmd/boards/iteration/project/list/list.go b/internal/cmd/boards/iteration/project/list/list.go index c37cbc90..f66a7a55 100644 --- a/internal/cmd/boards/iteration/project/list/list.go +++ b/internal/cmd/boards/iteration/project/list/list.go @@ -17,7 +17,6 @@ import ( type listOptions struct { scopeArg string - path string depth int includeDates bool exporter util.Exporter @@ -40,7 +39,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } cmd := &cobra.Command{ - Use: "list [ORGANIZATION/]PROJECT", + Use: "list [ORGANIZATION/]PROJECT[/PATH]", Short: "List iteration hierarchy for a project.", Long: heredoc.Doc(` List the iteration (sprint) hierarchy for a project within an Azure DevOps organization. @@ -50,7 +49,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { azdo boards iteration project list myorg/myproject # List from a specific path - azdo boards iteration project list myproject --path "Release 2025/Sprint 1" + azdo boards iteration project list myproject/Release\ 2025/Sprint\ 1 # Include start and finish dates azdo boards iteration project list myproject --include-dates @@ -72,7 +71,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.path, "path", "p", "", "Iteration path relative to project root") cmd.Flags().IntVarP(&opts.depth, "depth", "d", opts.depth, "Depth to fetch (1-10)") cmd.Flags().BoolVar(&opts.includeDates, "include-dates", false, "Include iteration start and finish dates") cmd.Flags().StringVar(&opts.startFilter, "start-date", "", "Apply a comparison filter to iteration start dates; supports operators like >= and special value \"today\" (e.g., \">=today\")") @@ -93,12 +91,18 @@ func runList(ctx util.CmdContext, opts *listOptions) error { return util.FlagErrorf("--depth must be between 1 and 10") } - scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + scope, err := util.Parse(ctx, opts.scopeArg, util.ParseOptions{ + AllowImplicitOrg: true, + RequireProject: true, + MinTargets: 0, + MaxTargets: 64, + }) if err != nil { return util.FlagErrorWrap(err) } - normalizedPath, err := shared.BuildClassificationPath(scope.Project, true, "Iteration", opts.path) + rawPath := strings.Join(scope.Targets, "/") + normalizedPath, err := shared.BuildClassificationPath(scope.Project, true, "Iteration", rawPath) if err != nil { return err } diff --git a/internal/cmd/boards/iteration/project/list/list_test.go b/internal/cmd/boards/iteration/project/list/list_test.go index 09cdeb79..2bba95f1 100644 --- a/internal/cmd/boards/iteration/project/list/list_test.go +++ b/internal/cmd/boards/iteration/project/list/list_test.go @@ -1,35 +1,240 @@ package list import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" "testing" "time" "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" ) +type dependencies struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + wit *mocks.MockWorkItemTrackingClient + stdout *bytes.Buffer + org string +} + +func newDependencies(t *testing.T, organization string) *dependencies { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ioStreams, _, out, _ := iostreams.Test() + ioStreams.SetStdoutTTY(false) + ioStreams.SetStderrTTY(false) + + deps := &dependencies{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + wit: mocks.NewMockWorkItemTrackingClient(ctrl), + stdout: out, + org: organization, + } + + deps.cmd.EXPECT().IOStreams().Return(ioStreams, 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() + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + deps.cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return(organization, 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 listNode(path string) *workitemtracking.WorkItemClassificationNode { + name := path[strings.LastIndex(path, "\\")+1:] + hasChildren := false + return &workitemtracking.WorkItemClassificationNode{ + Name: &name, + Path: &path, + HasChildren: &hasChildren, + } +} + +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) +} + +func TestNewCmd_TargetArgRequired(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "project argument required") +} + +func TestRunList_DepthBounds(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + err := runList(deps.cmd, &listOptions{scopeArg: "myproject", depth: 0}) + requireFlagError(t, err, "--depth must be between 1 and 10") +} + +func TestRunList_RequestArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + depsOrg string + scopeArg string + wantProj string + wantPath *string + }{ + {name: "project root uses nil path", depsOrg: "default-org", scopeArg: "myproject", wantProj: "myproject", wantPath: nil}, + {name: "subtree uses positional path", depsOrg: "org", scopeArg: "myproject/Release 2025", wantProj: "myproject", wantPath: types.ToPtr("Release%202025")}, + {name: "explicit org stays explicit when unambiguous", depsOrg: "org", scopeArg: "org/myproject/Release 2025/Sprint 1", wantProj: "myproject", wantPath: types.ToPtr("Release%202025/Sprint%201")}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, tc.depsOrg) + var args workitemtracking.GetClassificationNodeArgs + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, got workitemtracking.GetClassificationNodeArgs) (*workitemtracking.WorkItemClassificationNode, error) { + args = got + return listNode("Project\\Iteration\\Sprint 1"), nil + }, + ) + + err := runList(deps.cmd, &listOptions{scopeArg: tc.scopeArg, depth: 3}) + require.NoError(t, err) + assert.Equal(t, tc.wantProj, *args.Project) + assert.Equal(t, 3, *args.Depth) + assert.Equal(t, workitemtracking.TreeStructureGroupValues.Iterations, *args.StructureGroup) + if tc.wantPath == nil { + assert.Nil(t, args.Path) + } else { + require.NotNil(t, args.Path) + assert.Equal(t, *tc.wantPath, *args.Path) + } + }) + } +} + +func TestRunList_ClientFactoryError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ioStreams, _, _, _ := iostreams.Test() + cmd := mocks.NewMockCmdContext(ctrl) + clientFact := mocks.NewMockClientFactory(ctrl) + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + + cmd.EXPECT().IOStreams().Return(ioStreams, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() + cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return("default-org", nil).AnyTimes() + clientFact.EXPECT().WorkItemTracking(gomock.Any(), "default-org").Return(nil, errors.New("boom")) + + err := runList(cmd, &listOptions{scopeArg: "org/Fabrikam", depth: 3}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create work item tracking client") +} + +func TestRunList_JSONOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "default-org") + attrs := map[string]any{"startDate": "2025-01-06T00:00:00Z"} + node := &workitemtracking.WorkItemClassificationNode{ + Name: types.ToPtr("Sprint 1"), + Path: types.ToPtr("myproject\\Iteration\\Sprint 1"), + HasChildren: types.ToPtr(false), + Attributes: &attrs, + } + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(node, nil) + + err := runList(deps.cmd, &listOptions{scopeArg: "myproject", depth: 3, exporter: util.NewJSONExporter()}) + + require.NoError(t, err) + var got []struct { + Name string `json:"name"` + Path string `json:"path"` + HasChildren bool `json:"hasChildren"` + StartDate string `json:"startDate,omitempty"` + } + require.NoError(t, json.Unmarshal(deps.stdout.Bytes(), &got)) + require.Len(t, got, 1) + assert.Equal(t, "Sprint 1", got[0].Name) + assert.Equal(t, "myproject/Iteration/Sprint 1", got[0].Path) + assert.False(t, got[0].HasChildren) + assert.Equal(t, "2025-01-06T00:00:00Z", got[0].StartDate) +} + func TestExtractDate(t *testing.T) { - t.Run("parses RFC3339 string", func(t *testing.T) { - date := "2024-01-15T13:45:00Z" - attrs := map[string]any{"startDate": date} - got := extractDate(&attrs, "startDate") - if got == nil { - t.Fatalf("expected date, got nil") - } - if got.Format(time.RFC3339) != date { - t.Fatalf("expected %s, got %s", date, got.Format(time.RFC3339)) - } - }) + t.Parallel() - t.Run("returns nil on unknown format", func(t *testing.T) { - attrs := map[string]any{"startDate": 1234} - if got := extractDate(&attrs, "startDate"); got != nil { - t.Fatalf("expected nil for unsupported type, got %v", got) - } - }) + tests := []struct { + name string + attrs map[string]any + want string + }{ + {name: "parses RFC3339 string", attrs: map[string]any{"startDate": "2024-01-15T13:45:00Z"}, want: "2024-01-15T13:45:00Z"}, + {name: "returns nil on unknown format", attrs: map[string]any{"startDate": 1234}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractDate(&tc.attrs, "startDate") + if tc.want == "" { + assert.Nil(t, got) + return + } + + require.NotNil(t, got) + assert.Equal(t, tc.want, got.Format(time.RFC3339)) + }) + } } func TestFlattenIterations(t *testing.T) { + t.Parallel() + child := workitemtracking.WorkItemClassificationNode{ Name: types.ToPtr("Sprint 1"), Path: types.ToPtr("Project/Iteration/Sprint 1"), @@ -51,24 +256,21 @@ func TestFlattenIterations(t *testing.T) { rows := make([]iterationRow, 0) flattenIterations(root, 1, &rows) - if len(rows) != 2 { - t.Fatalf("expected 2 rows, got %d", len(rows)) - } - if rows[0].Level != 1 || rows[0].Name != "Project/Iteration" { - t.Fatalf("unexpected root row: %+v", rows[0]) - } - if rows[0].StartDate == nil || rows[0].StartDate.Format(time.RFC3339) != "2024-01-01T00:00:00Z" { - t.Fatalf("expected parsed start date, got %+v", rows[0].StartDate) - } - if rows[0].FinishDate == nil || rows[0].FinishDate.Format(time.RFC3339) != "2024-01-15T00:00:00Z" { - t.Fatalf("expected parsed finish date, got %+v", rows[0].FinishDate) - } - if rows[1].Level != 2 || rows[1].Name != "Sprint 1" { - t.Fatalf("unexpected child row: %+v", rows[1]) - } - if rows[1].StartDate != nil || rows[1].FinishDate != nil { - t.Fatalf("child dates should be nil: %+v", rows[1]) - } + require.Len(t, rows, 2) + assert.Equal(t, iterationRow{ + Name: "Project/Iteration", + Path: "Project/Iteration", + Level: 1, + HasChildren: true, + StartDate: types.ToPtr(time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)), + FinishDate: types.ToPtr(time.Date(2024, time.January, 15, 0, 0, 0, 0, time.UTC)), + }, rows[0]) + assert.Equal(t, iterationRow{ + Name: "Sprint 1", + Path: "Project/Iteration/Sprint 1", + Level: 2, + HasChildren: false, + }, rows[1]) } func TestParseDateConstraint(t *testing.T) { @@ -128,56 +330,9 @@ func TestParseDateConstraint(t *testing.T) { }) } -func TestEnsureFilterCompatibility(t *testing.T) { - cases := []struct { - name string - start string - finish string - ok bool - }{ - { - name: "compatible bounds", - start: ">=2024-01-01", - finish: "<=2024-12-31", - ok: true, - }, - { - name: "conflicting equality", - start: "==2024-01-05", - finish: "==2024-01-06", - ok: false, - }, - { - name: "start after finish", - start: ">=2024-02-01", - finish: "<=2024-01-01", - ok: false, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - start, err := parseDateConstraint(tc.start, "start-date") - if err != nil { - t.Fatalf("unexpected error parsing start: %v", err) - } - finish, err := parseDateConstraint(tc.finish, "finish-date") - if err != nil { - t.Fatalf("unexpected error parsing finish: %v", err) - } - - err = ensureFilterCompatibility(start, finish) - if tc.ok && err != nil { - t.Fatalf("expected compatibility, got error %v", err) - } - if !tc.ok && err == nil { - t.Fatalf("expected error due to incompatibility") - } - }) - } -} - func TestFilterIterations(t *testing.T) { + t.Parallel() + date := func(year int, month time.Month, day int) *time.Time { tm := time.Date(year, month, day, 0, 0, 0, 0, time.UTC) return &tm @@ -203,20 +358,16 @@ func TestFilterIterations(t *testing.T) { } filtered := filterIterations(rows, start, finish) - if len(filtered) != 1 { - t.Fatalf("expected 1 row, got %d", len(filtered)) - } - if filtered[0].Name != "B" { - t.Fatalf("expected row B, got %s", filtered[0].Name) - } + require.Len(t, filtered, 1) + assert.Equal(t, "B", filtered[0].Name) } func TestCompareBounds(t *testing.T) { + t.Parallel() + makeConstraint := func(raw, flagName string) *dateConstraint { c, err := parseDateConstraint(raw, flagName) - if err != nil { - t.Fatalf("failed to parse constraint %q: %v", raw, err) - } + require.NoError(t, err) return c } @@ -273,12 +424,11 @@ func TestCompareBounds(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := compareBounds(tc.start, tc.finish) - if tc.ok && err != nil { - t.Fatalf("expected success, got error: %v", err) - } - if !tc.ok && err == nil { - t.Fatalf("expected error but got nil") + if tc.ok { + require.NoError(t, err) + return } + require.Error(t, err) }) } } diff --git a/internal/cmd/boards/iteration/project/project.go b/internal/cmd/boards/iteration/project/project.go index c7b51d69..00dc74d5 100644 --- a/internal/cmd/boards/iteration/project/project.go +++ b/internal/cmd/boards/iteration/project/project.go @@ -7,6 +7,7 @@ import ( "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/boards/iteration/project/update" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -32,6 +33,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(delete.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) cmd.AddCommand(show.NewCmd(ctx)) + cmd.AddCommand(update.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/boards/iteration/project/show/show.go b/internal/cmd/boards/iteration/project/show/show.go index ddcbc722..5bc39b15 100644 --- a/internal/cmd/boards/iteration/project/show/show.go +++ b/internal/cmd/boards/iteration/project/show/show.go @@ -20,7 +20,6 @@ import ( type showOptions struct { scopeArg string - path string depth int includeChildren bool raw bool @@ -39,7 +38,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { opts := &showOptions{} cmd := &cobra.Command{ - Use: "show [ORGANIZATION/]PROJECT", + Use: "show [ORGANIZATION/]PROJECT[/PATH]/NAME", Short: "Show an iteration in a project.", Long: heredoc.Doc(` Display the details of a single iteration (sprint) node in a project. @@ -47,26 +46,25 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { `), Example: heredoc.Doc(` # Show a top-level iteration - azdo boards iteration project show Fabrikam --path "Sprint 1" + azdo boards iteration project show Fabrikam/Sprint\ 1 # Show a nested iteration - azdo boards iteration project show myorg/Fabrikam --path "Release 2025/Sprint 1" + azdo boards iteration project show myorg/Fabrikam/Release\ 2025/Sprint\ 1 # Include child nodes in the template output - azdo boards iteration project show Fabrikam --path "Release 2025" --include-children + azdo boards iteration project show Fabrikam/Release\ 2025 --include-children # Emit the raw SDK node as JSON - azdo boards iteration project show Fabrikam --path "Sprint 1" --json + azdo boards iteration project show Fabrikam/Sprint\ 1 --json `), Aliases: []string{"view", "status"}, - Args: util.ExactArgs(1, "project argument required"), + Args: util.ExactArgs(1, "target 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.") @@ -92,38 +90,40 @@ func runShow(ctx util.CmdContext, opts *showOptions) error { return util.FlagErrorf("--depth must be between 0 and 10") } - scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + target, err := util.Parse(ctx, opts.scopeArg, util.ParseOptions{ + AllowImplicitOrg: true, + RequireProject: true, + MinTargets: 1, + MaxTargets: 64, + }) 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) + rawPath := strings.Join(target.Targets, "/") + nodePath, err := shared.BuildClassificationPath(target.Project, true, "Iteration", rawPath) if err != nil { - return util.FlagErrorf("invalid --path: %w", err) + return util.FlagErrorf("invalid target %q: %w", opts.scopeArg, err) } if nodePath == "" { - return util.FlagErrorf("--path must reference a child of /Iteration, not the iteration root") + return util.FlagErrorf("target must reference a child of /Iteration") } - wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization) + wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), target.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("organization", target.Organization), + zap.String("project", target.Project), zap.String("path", nodePath), zap.Int("depth", opts.depth), ) args := workitemtracking.GetClassificationNodeArgs{ - Project: types.ToPtr(scope.Project), + Project: types.ToPtr(target.Project), StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Iterations), Path: types.ToPtr(nodePath), Depth: types.ToPtr(opts.depth), diff --git a/internal/cmd/boards/iteration/project/show/show_test.go b/internal/cmd/boards/iteration/project/show/show_test.go index ba2aa3c9..fe52868d 100644 --- a/internal/cmd/boards/iteration/project/show/show_test.go +++ b/internal/cmd/boards/iteration/project/show/show_test.go @@ -18,6 +18,7 @@ import ( "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" ) @@ -60,6 +61,11 @@ func newDependenciesWithClientFactoryError(t *testing.T, organization string, fa deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + deps.cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return(organization, nil).AnyTimes() if factoryErr != nil { deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), organization).Return(nil, factoryErr).AnyTimes() } else { @@ -115,70 +121,65 @@ func TestNewCmd_RegistersAsShowLeaf(t *testing.T) { assert.True(t, strings.HasPrefix(cmd.Use, "show [ORGANIZATION/]PROJECT")) } -func TestNewCmd_PathFlagRequired(t *testing.T) { +func TestNewCmd_TargetArgRequired(t *testing.T) { t.Parallel() - deps := newDependencies(t, "org") - cmd := NewCmd(deps.cmd) - cmd.SetArgs([]string{"Fabrikam"}) + cmd := NewCmd(nil) + cmd.SetArgs(nil) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() require.Error(t, err) - assert.Contains(t, err.Error(), "path") + assert.Contains(t, err.Error(), "target argument required") } - -func TestRunShow_PathRequired(t *testing.T) { +func TestRunShow_InvalidTarget(t *testing.T) { t.Parallel() - tests := []struct { - name string - path string - }{ - {name: "empty", path: ""}, - {name: "whitespace", path: " "}, - } + deps := newDependencies(t, "org") + err := runShow(deps.cmd, &showOptions{scopeArg: "org"}) + requireFlagError(t, err, "expected 2-66 segments") +} - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() +func TestRunShow_DepthBounds(t *testing.T) { + t.Parallel() - deps := newDependencies(t, "org") + for _, depth := range []int{-1, 11} { + deps := newDependencies(t, "org") - err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam", path: tc.path}) + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam/Sprint 1", depth: depth}) - requireFlagError(t, err, "--path must not be empty") - }) + requireFlagError(t, err, "--depth must be between 0 and 10") } } -func TestRunShow_DepthBounds(t *testing.T) { +func TestRunShow_RootNodeRejected(t *testing.T) { t.Parallel() - deps := newDependencies(t, "org") + deps := newDependenciesWithDefaultOrg(t, "default-org") + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Times(0) - err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", depth: 11}) + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Iteration"}) - requireFlagError(t, err, "--depth must be between 0 and 10") + requireFlagError(t, err, "target must reference a child of /Iteration") } func TestRunShow_RequestArgs(t *testing.T) { t.Parallel() tests := []struct { - name string - opts *showOptions - wantPath string - wantProj string + 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}, + {name: "root level", opts: &showOptions{scopeArg: "org/Fabrikam/Sprint 1"}, wantPath: "Fabrikam/Sprint%201", wantProj: "org", wantDepth: 0}, + {name: "normalizes project path", opts: &showOptions{scopeArg: "org/Fabrikam/Fabrikam/Iteration/Sprint 1"}, wantPath: "Sprint%201", wantProj: "Fabrikam", wantDepth: 0}, + {name: "escapes nested path", opts: &showOptions{scopeArg: "org/Fabrikam/My Sprint/Sub Sprint"}, wantPath: "My%20Sprint/Sub%20Sprint", wantProj: "Fabrikam", wantDepth: 0}, + {name: "uses explicit depth", opts: &showOptions{scopeArg: "org/Fabrikam/Sprint 1", depth: 2}, wantPath: "Fabrikam/Sprint%201", wantProj: "org", wantDepth: 2}, } for _, tc := range tests { @@ -211,7 +212,7 @@ func TestRunShow_TemplateOutput(t *testing.T) { { name: "basic fields without attributes", node: showNode(), - opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}, + opts: &showOptions{scopeArg: "org/Fabrikam/Sprint 1"}, contains: []string{ "url:", "https://dev.azure.com/org/Fabrikam/_apis/wit/classificationNodes/iterations/42", @@ -240,7 +241,7 @@ func TestRunShow_TemplateOutput(t *testing.T) { node.Attributes = &attrs return node }(), - opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}, + opts: &showOptions{scopeArg: "org/Fabrikam/Sprint 1"}, contains: []string{"attributes:", "startDate: 2024-01-01", "finishDate: 2024-01-15"}, }, { @@ -255,7 +256,7 @@ func TestRunShow_TemplateOutput(t *testing.T) { node.Children = &children return node }(), - opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1", includeChildren: true}, + opts: &showOptions{scopeArg: "org/Fabrikam/Sprint 1", includeChildren: true}, contains: []string{"children:", "- Sub Sprint", childID.String(), "hasChildren: true"}, }, { @@ -266,7 +267,7 @@ func TestRunShow_TemplateOutput(t *testing.T) { node.Children = &children return node }(), - opts: &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}, + opts: &showOptions{scopeArg: "org/Fabrikam/Sprint 1"}, notContains: []string{" - Sub Sprint"}, }, } @@ -297,11 +298,14 @@ func TestRunShow_JSONOutput(t *testing.T) { deps := newDependencies(t, "org") node := showNode() - attrs := map[string]any{"startDate": "2024-01-01T00:00:00Z"} + attrs := map[string]any{ + "startDate": "2024-01-01T00:00:00Z", + "finishDate": "2024-01-15T00: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()}) + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam/Sprint 1", exporter: util.NewJSONExporter()}) require.NoError(t, err) var got map[string]any @@ -316,9 +320,12 @@ func TestRunShow_JSONOutput(t *testing.T) { attrsJSON, ok := got["attributes"].(map[string]any) require.True(t, ok) assert.Equal(t, "2024-01-01T00:00:00Z", attrsJSON["startDate"]) + assert.Equal(t, "2024-01-15T00:00:00Z", attrsJSON["finishDate"]) linksJSON, ok := got["_links"].(map[string]any) require.True(t, ok) - assert.Contains(t, linksJSON, "self") + selfJSON, ok := linksJSON["self"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://dev.azure.com/org/Fabrikam/_apis/wit/classificationNodes/iterations/42", selfJSON["href"]) } func TestRunShow_RawFlag(t *testing.T) { @@ -327,9 +334,10 @@ func TestRunShow_RawFlag(t *testing.T) { 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}) + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam/Sprint 1", raw: true}) require.NoError(t, err) + assert.Empty(t, deps.stdout.String()) assert.Contains(t, deps.stderr.String(), "WorkItemClassificationNode") } @@ -341,37 +349,74 @@ func TestRunShow_ProjectScopeParsing(t *testing.T) { scopeArg string org string project string + path 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: "project uses default organization", scopeArg: "proj/Sprint 1", org: "default-org", project: "proj", path: "Sprint%201", defaultOrg: "default-org"}, + {name: "variable targets stay in path", scopeArg: "org/proj/release/Sprint 1", org: "org", project: "proj", path: "release/Sprint%201"}, {name: "empty scope", scopeArg: "", wantErr: "expected"}, - {name: "organization from config default", scopeArg: "Fabrikam", org: "default-org", project: "Fabrikam", defaultOrg: "default-org"}, + {name: "organization from config default", scopeArg: "Fabrikam/Sprint 1", org: "default-org", project: "Fabrikam", path: "Sprint%201", defaultOrg: "default-org"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - var deps *dependencies + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + cmd := mocks.NewMockCmdContext(ctrl) + clientFact := mocks.NewMockClientFactory(ctrl) + wit := mocks.NewMockWorkItemTrackingClient(ctrl) + cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() + if tc.defaultOrg != "" { - deps = newDependenciesWithDefaultOrg(t, tc.defaultOrg) - } else { - deps = newDependencies(t, tc.org) + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return(tc.defaultOrg, nil).AnyTimes() } - opts := &showOptions{scopeArg: tc.scopeArg, path: "Sprint 1"} + + tp, err := printer.NewTablePrinter(out, false, 200) + require.NoError(t, err) + cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + opts := &showOptions{scopeArg: tc.scopeArg} if tc.wantErr != "" { - err := runShow(deps.cmd, opts) + err := runShow(cmd, opts) requireFlagError(t, err, tc.wantErr) return } - args, err := captureShowArgs(t, deps, opts, showNode()) + var gotOrg string + clientFact.EXPECT().WorkItemTracking(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, organization string) (workitemtracking.Client, error) { + gotOrg = organization + return wit, nil + }, + ) + + var args workitemtracking.GetClassificationNodeArgs + wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, got workitemtracking.GetClassificationNodeArgs) (*workitemtracking.WorkItemClassificationNode, error) { + args = got + return showNode(), nil + }, + ) + + err = runShow(cmd, opts) require.NoError(t, err) + assert.Equal(t, tc.org, gotOrg) assert.Equal(t, tc.project, *args.Project) + assert.Equal(t, tc.path, *args.Path) }) } } @@ -381,7 +426,7 @@ func TestRunShow_ClientFactoryError(t *testing.T) { deps := newDependenciesWithClientFactoryError(t, "org", errors.New("boom")) - err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam", path: "Sprint 1"}) + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam/Sprint 1"}) require.Error(t, err) assert.Contains(t, err.Error(), "failed to get classification client") @@ -393,12 +438,24 @@ func TestRunShow_SDKError(t *testing.T) { 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"}) + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam/Sprint 1"}) require.Error(t, err) assert.Contains(t, err.Error(), "failed to get iteration") } +func TestRunShow_NilResponse(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(nil, nil) + + err := runShow(deps.cmd, &showOptions{scopeArg: "org/Fabrikam/Sprint 1"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "iteration node is nil") +} + func captureShowArgs(t *testing.T, deps *dependencies, opts *showOptions, response *workitemtracking.WorkItemClassificationNode) (workitemtracking.GetClassificationNodeArgs, error) { t.Helper() diff --git a/internal/cmd/boards/iteration/project/update/update.go b/internal/cmd/boards/iteration/project/update/update.go new file mode 100644 index 00000000..f9563455 --- /dev/null +++ b/internal/cmd/boards/iteration/project/update/update.go @@ -0,0 +1,259 @@ +package update + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/boards/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type updateOptions struct { + scopeArg string + startDate string + finishDate string + attributes []string + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &updateOptions{} + + cmd := &cobra.Command{ + Use: "update [ORGANIZATION/]PROJECT[/PATH]/NAME", + Short: "Update an iteration in a project.", + Long: heredoc.Doc(` + Update an iteration (sprint) in a project. The positional argument identifies + the iteration as [ORGANIZATION/]PROJECT[/PATH]/NAME. + + Supports changing start/finish dates and setting arbitrary attributes. + `), + Example: heredoc.Doc(` + # Reschedule a sprint + azdo boards iteration project update Fabrikam/Sprint\ 1 \ + --start-date 2025-01-06 --finish-date 2025-01-19 + + # Add or change a custom attribute, keeping the existing dates + azdo boards iteration project update Fabrikam/Release\ 2025/Sprint\ 1 \ + --attributes goal="Ship login" + + # Combine: reschedule + set a custom attribute + azdo boards iteration project update myorg/Fabrikam/Release\ 2025/Sprint\ 1 \ + --start-date 2025-01-06 --finish-date 2025-01-19 \ + --attributes goal="Ship login" + + # Emit JSON + azdo boards iteration project update Fabrikam/Sprint\ 1 --json + `), + Aliases: []string{"u", "up"}, + Args: util.ExactArgs(1, "target argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + return runUpdate(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.startDate, "start-date", "", "New start date (RFC 3339 or YYYY-MM-DD). Wins on conflict with --attributes startDate.") + cmd.Flags().StringVar(&opts.finishDate, "finish-date", "", "New finish date (RFC 3339 or YYYY-MM-DD). Wins on conflict with --attributes finishDate. Must be on or after start-date when both are set.") + cmd.Flags().StringSliceVar(&opts.attributes, "attributes", nil, "Custom attribute in key=value form. Repeatable. Existing attributes not mentioned are preserved.") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "identifier", "name", "path", "structureType", "hasChildren", "attributes", "url", "_links", + }) + + return cmd +} + +func runUpdate(ctx util.CmdContext, opts *updateOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if strings.TrimSpace(opts.startDate) == "" && + strings.TrimSpace(opts.finishDate) == "" && + len(opts.attributes) == 0 { + return util.FlagErrorf("at least one of --start-date, --finish-date, or --attributes is required") + } + + target, err := util.Parse(ctx, opts.scopeArg, util.ParseOptions{ + AllowImplicitOrg: true, + RequireProject: true, + MinTargets: 1, + MaxTargets: 64, + }) + if err != nil { + return util.FlagErrorWrap(err) + } + + rawPath := strings.Join(target.Targets, "/") + nodePath, err := shared.BuildClassificationPath(target.Project, true, "Iteration", rawPath) + if err != nil { + return util.FlagErrorf("invalid target %q: %w", opts.scopeArg, err) + } + if nodePath == "" { + return util.FlagErrorf("target must reference a child of /Iteration") + } + + wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), target.Organization) + if err != nil { + return fmt.Errorf("failed to get classification client: %w", err) + } + + getArgs := workitemtracking.GetClassificationNodeArgs{ + Project: types.ToPtr(target.Project), + StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Iterations), + Path: types.ToPtr(nodePath), + } + + zap.L().Debug( + "fetching iteration before update", + zap.String("organization", target.Organization), + zap.String("project", target.Project), + zap.String("path", nodePath), + ) + + existing, err := wit.GetClassificationNode(ctx.Context(), getArgs) + if err != nil { + return fmt.Errorf("failed to fetch iteration: %w", err) + } + if existing == nil || existing.Id == nil { + return util.FlagErrorf("existing iteration has no ID; cannot update") + } + + mergedAttrs, err := buildUpdateAttributes(existing.Attributes, opts.startDate, opts.finishDate, opts.attributes) + if err != nil { + return err + } + + updateArgs := workitemtracking.CreateOrUpdateClassificationNodeArgs{ + Project: types.ToPtr(target.Project), + StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Iterations), + Path: types.ToPtr(nodePath), + PostedNode: &workitemtracking.WorkItemClassificationNode{ + Id: existing.Id, + Name: existing.Name, + }, + } + if len(mergedAttrs) > 0 { + updateArgs.PostedNode.Attributes = &mergedAttrs + } + + zap.L().Debug( + "updating iteration", + zap.String("organization", target.Organization), + zap.String("project", target.Project), + zap.String("path", nodePath), + zap.Int("id", *existing.Id), + zap.String("name", types.GetValue(existing.Name, "")), + zap.Int("attributeCount", len(mergedAttrs)), + ) + + res, err := wit.CreateOrUpdateClassificationNode(ctx.Context(), updateArgs) + if err != nil { + return fmt.Errorf("failed to update iteration: %w", err) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, res) + } + + tp, err := ctx.Printer("table") + if err != nil { + return err + } + tp.AddColumns("ID", "NAME", "PATH", "START DATE", "FINISH DATE", "HAS CHILDREN") + tp.AddField(strconv.Itoa(types.GetValue(res.Id, 0))) + tp.AddField(types.GetValue(res.Name, "")) + tp.AddField(shared.NormalizeClassificationPath(types.GetValue(res.Path, ""))) + tp.AddField(formatAttributeDate(res.Attributes, "startDate")) + tp.AddField(formatAttributeDate(res.Attributes, "finishDate")) + tp.AddField(strconv.FormatBool(types.GetValue(res.HasChildren, false))) + tp.EndRow() + return tp.Render() +} + +func buildUpdateAttributes(existing *map[string]any, startDate, finishDate string, attrs []string) (map[string]any, error) { + result := make(map[string]any) + if existing != nil { + for k, v := range *existing { + result[k] = v + } + } + + for _, kv := range attrs { + idx := strings.Index(kv, "=") + if idx <= 0 { + return nil, util.FlagErrorf("invalid --attributes %q: expected key=value", kv) + } + key := strings.TrimSpace(kv[:idx]) + if key == "" { + return nil, util.FlagErrorf("invalid --attributes %q: empty key", kv) + } + result[key] = kv[idx+1:] + } + + if raw := strings.TrimSpace(startDate); raw != "" { + t, err := parseStrictDate(raw) + if err != nil { + return nil, util.FlagErrorf("invalid --start-date: %w", err) + } + result["startDate"] = t.UTC().Format(time.RFC3339) + } + if raw := strings.TrimSpace(finishDate); raw != "" { + t, err := parseStrictDate(raw) + if err != nil { + return nil, util.FlagErrorf("invalid --finish-date: %w", err) + } + result["finishDate"] = t.UTC().Format(time.RFC3339) + } + + if strings.TrimSpace(startDate) != "" && strings.TrimSpace(finishDate) != "" { + start, err := time.Parse(time.RFC3339, result["startDate"].(string)) + if err != nil { + return nil, util.FlagErrorf("invalid --start-date: %w", err) + } + finish, err := time.Parse(time.RFC3339, result["finishDate"].(string)) + if err != nil { + return nil, util.FlagErrorf("invalid --finish-date: %w", err) + } + if finish.Before(start) { + return nil, util.FlagErrorf("--finish-date must be on or after --start-date") + } + } + + return result, nil +} + +func parseStrictDate(raw string) (time.Time, error) { + if strings.Contains(raw, "T") { + return time.Parse(time.RFC3339, raw) + } + return time.Parse("2006-01-02", raw) +} + +func formatAttributeDate(attrs *map[string]any, key string) string { + if attrs == nil { + return "" + } + raw, ok := (*attrs)[key] + if !ok || raw == nil { + return "" + } + if s, ok := raw.(string); ok { + return s + } + return fmt.Sprintf("%v", raw) +} diff --git a/internal/cmd/boards/iteration/project/update/update_test.go b/internal/cmd/boards/iteration/project/update/update_test.go new file mode 100644 index 00000000..2bacb00a --- /dev/null +++ b/internal/cmd/boards/iteration/project/update/update_test.go @@ -0,0 +1,455 @@ +package update + +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/printer" +) + +type dependencies struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + wit *mocks.MockWorkItemTrackingClient + stdout *bytes.Buffer + org string +} + +func newDependencies(t *testing.T, organization string) *dependencies { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := 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, + 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() + 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 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 updateNode(goal string) *workitemtracking.WorkItemClassificationNode { + id := 42 + hasChildren := true + name := "Sprint 1" + path := "Fabrikam\\Iteration\\Release 2025\\Sprint 1" + attrs := map[string]any{ + "startDate": "2025-01-06T00:00:00Z", + "finishDate": "2025-01-19T00:00:00Z", + "goal": goal, + "team": "Alpha", + } + return &workitemtracking.WorkItemClassificationNode{ + Id: &id, + Name: &name, + Path: &path, + HasChildren: &hasChildren, + Attributes: &attrs, + } +} + +func existingUpdateNode() *workitemtracking.WorkItemClassificationNode { + return updateNode("Old goal") +} + +func updatedUpdateNode() *workitemtracking.WorkItemClassificationNode { + return updateNode("Ship login") +} + +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) +} + +func TestNewCmd_RegistersAsUpdateLeaf(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + + assert.Equal(t, "update", cmd.Name()) + assert.Equal(t, []string{"u", "up"}, cmd.Aliases) + assert.True(t, strings.HasPrefix(cmd.Use, "update [ORGANIZATION/]PROJECT[/PATH]/NAME")) + assert.Equal(t, "id,identifier,name,path,structureType,hasChildren,attributes,url,_links", cmd.Annotations["help:json-fields"]) +} + +func TestNewCmd_TargetArgRequired(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs(nil) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "target argument required") +} + +func TestRunUpdate_NoUpdateFlags(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + + err := runUpdate(deps.cmd, &updateOptions{scopeArg: "org/Fabrikam/Release 2025/Sprint 1"}) + + requireFlagError(t, err, "at least one of --start-date, --finish-date, or --attributes is required") +} + +func TestRunUpdate_InvalidTarget(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + + err := runUpdate(deps.cmd, &updateOptions{scopeArg: "org", startDate: "2025-01-06"}) + + requireFlagError(t, err, "expected 2-66 segments") +} + +func TestRunUpdate_RootNodeRejected(t *testing.T) { + t.Parallel() + + deps := newDependenciesWithDefaultOrg(t, "default-org") + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Times(0) + deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).Times(0) + + err := runUpdate(deps.cmd, &updateOptions{scopeArg: "org/Iteration", startDate: "2025-01-06"}) + + requireFlagError(t, err, "target must reference a child of /Iteration") +} + +func TestRunUpdate_RequestArgs(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + opts := &updateOptions{ + scopeArg: "org/Fabrikam/Release 2025/Sprint 1", + startDate: "2025-01-06", + finishDate: "2025-01-19", + attributes: []string{"goal=Ship login"}, + } + + var gotGet workitemtracking.GetClassificationNodeArgs + var gotUpdate workitemtracking.CreateOrUpdateClassificationNodeArgs + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetClassificationNodeArgs) (*workitemtracking.WorkItemClassificationNode, error) { + gotGet = args + return existingUpdateNode(), nil + }, + ) + deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.CreateOrUpdateClassificationNodeArgs) (*workitemtracking.WorkItemClassificationNode, error) { + gotUpdate = args + return updatedUpdateNode(), nil + }, + ) + + err := runUpdate(deps.cmd, opts) + + require.NoError(t, err) + assert.Equal(t, "Fabrikam", *gotGet.Project) + assert.Equal(t, workitemtracking.TreeStructureGroupValues.Iterations, *gotGet.StructureGroup) + assert.Equal(t, "iterations", string(*gotGet.StructureGroup)) + assert.Equal(t, "Release%202025/Sprint%201", *gotGet.Path) + + assert.Equal(t, "Fabrikam", *gotUpdate.Project) + assert.Equal(t, workitemtracking.TreeStructureGroupValues.Iterations, *gotUpdate.StructureGroup) + assert.Equal(t, "Release%202025/Sprint%201", *gotUpdate.Path) + require.NotNil(t, gotUpdate.PostedNode) + assert.Equal(t, "Sprint 1", *gotUpdate.PostedNode.Name) + assert.Equal(t, existingUpdateNode().Id, gotUpdate.PostedNode.Id) + require.NotNil(t, gotUpdate.PostedNode.Attributes) + assert.Equal(t, "2025-01-06T00:00:00Z", (*gotUpdate.PostedNode.Attributes)["startDate"]) + assert.Equal(t, "2025-01-19T00:00:00Z", (*gotUpdate.PostedNode.Attributes)["finishDate"]) + assert.Equal(t, "Ship login", (*gotUpdate.PostedNode.Attributes)["goal"]) + assert.Equal(t, "Alpha", (*gotUpdate.PostedNode.Attributes)["team"]) +} + +func TestRunUpdate_ProjectScopeParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopeArg string + org string + project string + path string + wantErr string + defaultOrg string + }{ + {name: "project uses default organization", scopeArg: "proj/Sprint 1", org: "default-org", project: "proj", path: "Sprint%201", defaultOrg: "default-org"}, + {name: "variable targets stay in path", scopeArg: "org/target1/target2/extra", org: "org", project: "target1", path: "target2/extra"}, + {name: "empty scope", scopeArg: "", wantErr: "expected"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + cmd := mocks.NewMockCmdContext(ctrl) + clientFact := mocks.NewMockClientFactory(ctrl) + wit := mocks.NewMockWorkItemTrackingClient(ctrl) + cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() + + if tc.defaultOrg != "" { + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return(tc.defaultOrg, nil).AnyTimes() + } + + tp, err := printer.NewTablePrinter(out, false, 200) + require.NoError(t, err) + cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + opts := &updateOptions{scopeArg: tc.scopeArg, startDate: "2025-01-06"} + + if tc.wantErr != "" { + err := runUpdate(cmd, opts) + requireFlagError(t, err, tc.wantErr) + return + } + + var gotOrg string + clientFact.EXPECT().WorkItemTracking(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, organization string) (workitemtracking.Client, error) { + gotOrg = organization + return wit, nil + }, + ) + var gotGet workitemtracking.GetClassificationNodeArgs + var gotUpdate workitemtracking.CreateOrUpdateClassificationNodeArgs + wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetClassificationNodeArgs) (*workitemtracking.WorkItemClassificationNode, error) { + gotGet = args + return existingUpdateNode(), nil + }, + ) + wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.CreateOrUpdateClassificationNodeArgs) (*workitemtracking.WorkItemClassificationNode, error) { + gotUpdate = args + return updatedUpdateNode(), nil + }, + ) + + err = runUpdate(cmd, opts) + require.NoError(t, err) + assert.Equal(t, tc.org, gotOrg) + assert.Equal(t, tc.project, *gotGet.Project) + assert.Equal(t, tc.path, *gotGet.Path) + assert.Equal(t, tc.project, *gotUpdate.Project) + assert.Equal(t, tc.path, *gotUpdate.Path) + }) + } +} + +func TestRunUpdate_ClientFactoryError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + cmd := mocks.NewMockCmdContext(ctrl) + clientFact := mocks.NewMockClientFactory(ctrl) + cfg := mocks.NewMockConfig(ctrl) + auth := mocks.NewMockAuthConfig(ctrl) + + cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() + cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(auth).AnyTimes() + auth.EXPECT().GetDefaultOrganization().Return("default-org", nil).AnyTimes() + clientFact.EXPECT().WorkItemTracking(gomock.Any(), "default-org").Return(nil, errors.New("boom")) + + err := runUpdate(cmd, &updateOptions{scopeArg: "org/Fabrikam/Sprint 1", startDate: "2025-01-06"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get classification client") +} + +func TestRunUpdate_GetError(t *testing.T) { + t.Parallel() + + deps := newDependenciesWithDefaultOrg(t, "default-org") + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + err := runUpdate(deps.cmd, &updateOptions{scopeArg: "org/Fabrikam/Sprint 1", startDate: "2025-01-06"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch iteration") +} + +func TestRunUpdate_MissingExistingID(t *testing.T) { + t.Parallel() + + deps := newDependenciesWithDefaultOrg(t, "default-org") + node := existingUpdateNode() + node.Id = nil + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(node, nil) + deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).Times(0) + + err := runUpdate(deps.cmd, &updateOptions{scopeArg: "org/Fabrikam/Sprint 1", startDate: "2025-01-06"}) + + requireFlagError(t, err, "existing iteration has no ID") +} + +func TestRunUpdate_UpdateError(t *testing.T) { + t.Parallel() + + deps := newDependenciesWithDefaultOrg(t, "default-org") + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(existingUpdateNode(), nil) + deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + err := runUpdate(deps.cmd, &updateOptions{scopeArg: "org/Fabrikam/Sprint 1", startDate: "2025-01-06"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to update iteration") +} + +func TestRunUpdate_DefaultOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(existingUpdateNode(), nil) + deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).Return(updatedUpdateNode(), nil) + + err := runUpdate(deps.cmd, &updateOptions{scopeArg: "org/Fabrikam/Release 2025/Sprint 1", startDate: "2025-01-06", attributes: []string{"goal=Ship login"}}) + + require.NoError(t, err) + assert.Equal(t, "42\tSprint 1\tFabrikam/Iteration/Release 2025/Sprint 1\t2025-01-06T00:00:00Z\t2025-01-19T00:00:00Z\ttrue\n", deps.stdout.String()) +} + +func TestRunUpdate_JSONOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t, "org") + identifier := uuid.New() + structureType := workitemtracking.TreeNodeStructureTypeValues.Iteration + url := "https://dev.azure.com/org/Fabrikam/_apis/wit/classificationNodes/iterations/42" + node := updatedUpdateNode() + node.Identifier = &identifier + node.StructureType = &structureType + node.Url = &url + node.Links = map[string]any{"self": map[string]any{"href": url}} + + deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(existingUpdateNode(), nil) + deps.wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()).Return(node, nil) + + err := runUpdate(deps.cmd, &updateOptions{scopeArg: "org/Fabrikam/Release 2025/Sprint 1", startDate: "2025-01-06", attributes: []string{"goal=Ship login"}, exporter: util.NewJSONExporter()}) + + require.NoError(t, err) + var got struct { + ID int `json:"id"` + Identifier string `json:"identifier"` + Name string `json:"name"` + Path string `json:"path"` + HasChildren bool `json:"hasChildren"` + StructureType string `json:"structureType"` + URL string `json:"url"` + Attributes map[string]any `json:"attributes"` + Links map[string]interface{} `json:"_links"` + } + require.NoError(t, json.Unmarshal(deps.stdout.Bytes(), &got)) + assert.Equal(t, 42, got.ID) + assert.Equal(t, identifier.String(), got.Identifier) + assert.Equal(t, "Sprint 1", got.Name) + assert.Equal(t, "Fabrikam\\Iteration\\Release 2025\\Sprint 1", got.Path) + assert.True(t, got.HasChildren) + assert.Equal(t, "iteration", got.StructureType) + assert.Equal(t, url, got.URL) + assert.Equal(t, "2025-01-06T00:00:00Z", got.Attributes["startDate"]) + assert.Equal(t, "2025-01-19T00:00:00Z", got.Attributes["finishDate"]) + assert.Equal(t, "Ship login", got.Attributes["goal"]) + assert.Equal(t, "Alpha", got.Attributes["team"]) + require.Contains(t, got.Links, "self") +} + +func TestBuildUpdateAttributes_StartDateWins(t *testing.T) { + t.Parallel() + + existing := map[string]any{"startDate": "2024-01-01T00:00:00Z"} + + got, err := buildUpdateAttributes(&existing, "2025-01-06", "", []string{"startDate=2024-12-01"}) + + require.NoError(t, err) + assert.Equal(t, "2025-01-06T00:00:00Z", got["startDate"]) +} + +func TestBuildUpdateAttributes_InvalidAttribute(t *testing.T) { + t.Parallel() + + _, err := buildUpdateAttributes(nil, "", "", []string{"novalue"}) + + requireFlagError(t, err, `invalid --attributes "novalue": expected key=value`) +} + +func TestBuildUpdateAttributes_InvalidDate(t *testing.T) { + t.Parallel() + + _, err := buildUpdateAttributes(nil, "2025-01-19", "2025-01-06", nil) + + requireFlagError(t, err, "--finish-date must be on or after --start-date") +} diff --git a/internal/cmd/util/scope.go b/internal/cmd/util/scope.go index db308a43..12b17200 100644 --- a/internal/cmd/util/scope.go +++ b/internal/cmd/util/scope.go @@ -45,13 +45,26 @@ type ParseOptions struct { // // 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. +// leading segments. The parser works from the back: for each candidate prefix length +// the target count is n - prefixLen, and only candidates whose target count falls +// within [MinTargets, MaxTargets] are considered. // // 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. +// remaining leading segments (0, 1, or 2). When MaxTargets > MinTargets multiple +// prefix lengths may be valid. In that case Parse disambiguates as follows: +// +// - If the smallest valid prefix would push targets to MaxTargets, prefer the largest +// prefix so target capacity is not exhausted. +// - If the largest valid prefix would push targets to MinTargets, prefer the smallest +// prefix so target capacity is preserved. +// - Otherwise, when project is optional (RequireProject is false) prefer the smallest +// prefix so the optional project segment is not consumed; when organization is +// optional (AllowImplicitOrg is true) prefer the largest prefix so an explicitly +// provided organization is retained. +// +// If only a sub-maximum prefix is valid (the full org+project prefix does not fit) and +// organization is required, Parse rejects the input rather than guessing whether the +// missing segment is organization or project. // // Examples: // @@ -76,8 +89,9 @@ type ParseOptions struct { // 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 +// - total segment count falls outside the allowed range derived from opts +// - only a sub-maximum prefix is valid, organization is required, and target count is +// variable — the input is ambiguous // - 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) { @@ -105,7 +119,7 @@ func Parse(ctx CmdContext, raw string, opts ParseOptions) (*Path, error) { return nil, fmt.Errorf("invalid options: target range [%d,%d] is not satisfiable", opts.MinTargets, opts.MaxTargets) } - // Allowed prefix (organization + project) segment counts. + // Prefix (organization + project) segment ranges. minOrg := 0 if !opts.AllowImplicitOrg { minOrg = 1 @@ -122,23 +136,67 @@ func Parse(ctx CmdContext, raw string, opts ParseOptions) (*Path, error) { minTotal := minPrefix + minTargets maxTotal := maxPrefix + maxTargets - // 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 + // Range check. + if n < minTotal || n > maxTotal { + return nil, fmt.Errorf("invalid input %q: expected %d-%d segments, got %d", raw, minTotal, maxTotal, n) + } + + // Determine prefix length by collecting valid candidates from the back. + // Targets occupy the trailing segments, so for each candidate prefix + // length the target count is n - prefixLen. + var validPrefixes []int + for pl := maxPrefix; pl >= minPrefix; pl-- { + tc := n - pl + if tc >= minTargets && tc <= maxTargets { + validPrefixes = append(validPrefixes, pl) + } } - targetCount := n - prefixLen - if prefixLen < minPrefix || prefixLen > maxPrefix || targetCount < minTargets || targetCount > maxTargets { + if len(validPrefixes) == 0 { return nil, fmt.Errorf("invalid input %q: expected %d-%d segments, got %d", raw, minTotal, maxTotal, n) } + var prefixLen int + if len(validPrefixes) == 1 { + prefixLen = validPrefixes[0] + // When only a sub-maximum prefix is valid the full org+project + // prefix does not fit. If org is required and the target count + // is variable the input is ambiguous — the missing segment could + // be org or project — so reject rather than guess. + if prefixLen < maxPrefix && !opts.AllowImplicitOrg && minTargets != maxTargets { + return nil, fmt.Errorf("invalid input %q: expected %d-%d segments, got %d", raw, minTotal, maxTotal, n) + } + } else { + // Multiple valid prefix lengths — disambiguate. + high := validPrefixes[0] // largest prefix, fewest targets + low := validPrefixes[len(validPrefixes)-1] // smallest prefix, most targets + tHigh := n - high + tLow := n - low + + switch { + case tLow == maxTargets: + // Smallest prefix pushes targets to the maximum — prefer the + // largest prefix so target capacity is not exhausted. + prefixLen = high + case tHigh == minTargets: + // Largest prefix pushes targets to the minimum — prefer the + // smallest prefix to preserve target capacity. + prefixLen = low + case !opts.RequireProject: + // Project is optional — prefer the smallest prefix so the + // optional project segment is not consumed. + prefixLen = low + case opts.AllowImplicitOrg: + // Organization is optional — prefer the largest prefix so + // an explicitly provided organization is retained. + prefixLen = high + default: + prefixLen = high + } + } + + targetCount := n - prefixLen + p := &Path{} if targetCount > 0 { p.Targets = make([]string, targetCount) diff --git a/internal/cmd/util/scope_test.go b/internal/cmd/util/scope_test.go index fa90abcc..076e8094 100644 --- a/internal/cmd/util/scope_test.go +++ b/internal/cmd/util/scope_test.go @@ -432,6 +432,56 @@ func TestParse(t *testing.T) { Targets: []string{"target", "extra"}, }, }, + { + name: "variable target counts with required project keep explicit organization", + raw: "org/project/target/extra", + opts: util.ParseOptions{AllowImplicitOrg: true, RequireProject: true, MinTargets: 1, MaxTargets: 64}, + want: &util.Path{ + Organization: "org", + Project: "project", + Targets: []string{"target", "extra"}, + }, + }, + { + name: "variable target counts with required project allow implicit organization", + raw: "project/target/extra", + opts: util.ParseOptions{AllowImplicitOrg: true, RequireProject: true, MinTargets: 1, MaxTargets: 64}, + want: &util.Path{ + Organization: "default-org", + Project: "project", + Targets: []string{"target", "extra"}, + }, + }, + { + name: "variable target counts with required project allow implicit organization single target", + raw: "project/target", + opts: util.ParseOptions{AllowImplicitOrg: true, RequireProject: true, MinTargets: 1, MaxTargets: 64}, + want: &util.Path{ + Organization: "default-org", + Project: "project", + Targets: []string{"target"}, + }, + }, + { + name: "variable target counts with required project and required organization", + raw: "org/project/target/extra", + opts: util.ParseOptions{AllowImplicitOrg: false, RequireProject: true, MinTargets: 1, MaxTargets: 64}, + want: &util.Path{ + Organization: "org", + Project: "project", + Targets: []string{"target", "extra"}, + }, + }, + { + name: "variable target counts with optional project and required organization", + raw: "org/target1/target2/extra", + opts: util.ParseOptions{AllowImplicitOrg: false, RequireProject: false, MinTargets: 1, MaxTargets: 64}, + want: &util.Path{ + Organization: "org", + Project: "", + Targets: []string{"target1", "target2", "extra"}, + }, + }, { name: "variable target counts reject too few targets", raw: "org/project",