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