Skip to content

feat: Implement azdo boards iteration project create command #205

@tmeckel

Description

@tmeckel

Sub-issue of #142. Sibling of #144 (iteration project list), #207 (iteration project delete), and #209 (iteration project update). Hardened spec — do not re-derive decisions. Mirrors #204 (area project create) with iteration-specific additions for dates and attributes.

Command Description

Create a new iteration (sprint) under a project's iteration tree. The body is {name: "...", attributes: {startDate, finishDate, ...}} posted to the Classification Nodes REST 7.1 endpoint.

POST https://dev.azure.com/{organization}/{project}/_apis/wit/classificationnodes/Iterations/{parentPath}?api-version=7.1
Content-Type: application/json

For v1 the structureGroup is hardcoded to Iterations (lowercase literal "iterations"). The parentPath segment is optional; omitting it creates a node at the project root (directly under <Project>/Iteration). The body always carries name, and optionally attributes (carrying startDate, finishDate, or arbitrary key=value pairs). No id — move semantics deferred to #209 (update).

Reference sample (REST 7.1 docs):

{
  "name": "Final Iteration",
  "attributes": {
    "startDate": "2014-10-27T00:00:00Z",
    "finishDate": "2014-10-31T00:00:00Z"
  }
}

Locked Decisions (do not re-derive)

# Decision Rationale
1 Use the vendored SDK workitemtracking.Client.CreateOrUpdateClassificationNode (not raw HTTP). Mock is already generated. Consistent with #204, #209; the SDK is what iteration project list already uses.
2 --name is the only required flag. Empty/whitespace rejected with util.FlagErrorf. Names are the only mutable surface for create.
3 --path is optional. It specifies the parent iteration path under <Project>/Iteration. Empty → new node at project root. Symmetric with --path in #144 and #204.
4 Path normalization via shared.BuildClassificationPath(scope.Project, true, "Iteration", opts.path) — same helper as #144. Strips leading <Project> and Iteration segments; URL-escapes remaining; returns ("", nil) for empty input. Symmetric round-trip; one helper, one source of truth. (Note: scope name is "Iteration" singular, matching the list command's convention.)
5 StructureGroup is hardcoded to &workitemtracking.TreeStructureGroupValues.Iterations (literal "iterations"). No --structure-group flag. Scope discipline; area lives in its own command.
6 No move-by-id semantics in v1. Always send Name in PostedNode; never Id. Defer to a follow-up; #204 took the same stance.
7 --start-date and --finish-date are first-class flags (string), parsed strictly as time.RFC3339 (or YYYY-MM-DD → midnight UTC). Stored in PostedNode.Attributes as RFC 3339 strings to match the REST sample exactly. Iteration dates are the canonical reason this command exists.
8 finishDate < startDate is rejected with util.FlagErrorf. Catches a class of user errors before the network call.
9 --attributes key=value is a repeatable flag for arbitrary attributes. Merged into PostedNode.Attributes after start/finish dates; --start-date / --finish-date win on key conflict. Escape hatch; covers fields like custom Budget or Goal.
10 No date operators (>=, today, etc.) in create. Strict RFC 3339 or YYYY-MM-DD only. List has filter semantics; create must not. Avoid re-implementing the list's parseDateConstraint.
11 JSON output passes the raw SDK *WorkItemClassificationNode to opts.exporter.Write. No view struct. Symmetric with #204, #209, #144.
12 Table output: columns ID, NAME, PATH, START DATE, FINISH DATE, HAS CHILDREN (single row). Mirrors the create surface; date columns surface the freshly-set attributes.
13 No confirmation prompt. Create is reversible via iteration project delete (#207). Matches az boards iteration project update.
14 No pre-existence check. Defer to REST 4xx. Saves one round-trip; error is already clear.
15 No new SDK client, no new helper, no new package. Only the new create package + 3 LOC in project.go to wire it. Date parsing inlined (≈8 lines) — do not promote parseFlexibleDate from list.go; it handles today/operators that create must reject. Mandate: minimal code.
16 Mock for CreateOrUpdateClassificationNode is already generated at internal/mocks/workitemtracking_client_mock.go:106-118. Do not regenerate. Verified; same mock serves #204, #209, and this issue.

Command Signature

azdo boards iteration project create [ORGANIZATION/]PROJECT
  • Aliases: c, cr
  • Positional parsing via util.ParseProjectScope(ctx, scopeArg) (defined in internal/cmd/util/scope.go:78-108); errors wrapped with util.FlagErrorWrap.

Flags (mapped to SDK/REST)

Flag Maps to Notes
--name (required) WorkItemClassificationNode.Name Trimmed; non-empty required
--path (optional) CreateOrUpdateClassificationNodeArgs.Path (URL segment, parent) Empty → project root
--start-date (optional) PostedNode.Attributes["startDate"] (RFC 3339 string) YYYY-MM-DD accepted → midnight UTC
--finish-date (optional) PostedNode.Attributes["finishDate"] (RFC 3339 string) Must be &gt;= startDate if both set
--attributes (repeatable key=value) PostedNode.Attributes[key] --start-date / --finish-date win on conflict

JSON Output Contract

util.AddJSONFlags(cmd, &opts.exporter, []string{
    "id", "identifier", "name", "path", "structureType", "hasChildren", "attributes", "url", "_links",
})

Pass the raw *workitemtracking.WorkItemClassificationNode returned by the SDK (Decision 11). Date strings live inside attributes.

Table default columns: ID, NAME, PATH, START DATE, FINISH DATE, HAS CHILDREN (Decision 12). When neither date flag is set, the date columns render empty (not omitted).

Command Wiring

  • Package path: internal/cmd/boards/iteration/project/create
  • Files:
    • create.goNewCmd(ctx util.CmdContext) *cobra.Command + createOptions + runCreate
    • create_test.go — table-driven gomock tests
  • Update internal/cmd/boards/iteration/project/project.go:
    import (
        createcmd "github.com/tmeckel/azdo-cli/internal/cmd/boards/iteration/project/create"
    )
    cmd.AddCommand(createcmd.NewCmd(ctx))
  • Existing higher-level parents must already remain wired: boardsiterationprojectcreate.

Code Skeleton (canonical, copy verbatim)

§1. createOptions struct

type createOptions struct {
    scopeArg   string
    name       string   // --name
    path       string   // --path (parent)
    startDate  string   // --start-date (RFC 3339 or YYYY-MM-DD)
    finishDate string   // --finish-date (RFC 3339 or YYYY-MM-DD)
    attributes []string // --attributes key=value (repeatable)
    exporter   util.Exporter
}

§2. NewCmd shape (no surprises)

func NewCmd(ctx util.CmdContext) *cobra.Command {
    opts := &createOptions{}
    cmd := &cobra.Command{
        Use:     "create [ORGANIZATION/]PROJECT",
        Short:   "Create an iteration (sprint) in a project.",
        Long: heredoc.Doc(`
            Create a new iteration (sprint) under a project. By default the new node is
            created at the project root. Use --path to create a node under a specific
            parent iteration, --start-date/--finish-date to schedule it, and
            --attributes for any extra node attributes.
        `),
        Example: heredoc.Doc(`
            # Create a top-level iteration
            azdo boards iteration project create Fabrikam --name "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

            # Create a nested iteration under an existing release
            azdo boards iteration project create myorg/Fabrikam --name "Sprint 2" --path "Release 2025"

            # 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 \
                --attributes goal="Ship login"

            # Emit JSON
            azdo boards iteration project create Fabrikam --name "Sprint 1" --json
        `),
        Aliases: []string{"c", "cr"},
        Args:    util.ExactArgs(1, "project 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",
    })
    return cmd
}

§3. runCreate skeleton

func runCreate(ctx util.CmdContext, opts *createOptions) error {
    ios, err := ctx.IOStreams()
    if err != nil {
        return err
    }

    ios.StartProgressIndicator()
    defer ios.StopProgressIndicator()

    scope, err := util.ParseProjectScope(ctx, opts.scopeArg)
    if err != nil {
        return util.FlagErrorWrap(err)
    }

    name := strings.TrimSpace(opts.name)
    if name == "" {
        return util.FlagErrorf("--name must not be empty")
    }

    parentPath, err := shared.BuildClassificationPath(scope.Project, true, "Iteration", opts.path)
    if err != nil {
        return util.FlagErrorf("invalid --path: %w", err)
    }

    attrs, err := buildAttributes(opts)
    if err != nil {
        return err
    }

    postedNode := &workitemtracking.WorkItemClassificationNode{
        Name: types.ToPtr(name),
    }
    if len(attrs) > 0 {
        postedNode.Attributes = &attrs
    }

    args := workitemtracking.CreateOrUpdateClassificationNodeArgs{
        PostedNode:     postedNode,
        Project:        types.ToPtr(scope.Project),
        StructureGroup: types.ToPtr(workitemtracking.TreeStructureGroupValues.Iterations),
    }
    if parentPath != "" {
        args.Path = types.ToPtr(parentPath)
    }

    zap.L().Debug("creating iteration",
        zap.String("organization", scope.Organization),
        zap.String("project", scope.Project),
        zap.String("name", name),
        zap.String("parentPath", parentPath),
        zap.Int("attributeCount", len(attrs)),
    )

    wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization)
    if err != nil {
        return fmt.Errorf("failed to get classification client: %w", err)
    }

    res, err := wit.CreateOrUpdateClassificationNode(ctx.Context(), args)
    if err != nil {
        return fmt.Errorf("failed to create 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"))
    if types.GetValue(res.HasChildren, false) {
        tp.AddField("true")
    } else {
        tp.AddField("false")
    }
    tp.EndRow()
    return tp.Render()
}

// buildAttributes assembles the Attributes map. Order:
//  1. parse --start-date / --finish-date (errors first, win on conflict)
//  2. merge --attributes key=value pairs (start/finish override)
func buildAttributes(opts *createOptions) (map[string]any, error) {
    attrs := make(map[string]any)

    if raw := strings.TrimSpace(opts.startDate); raw != "" {
        t, err := parseStrictDate(raw)
        if err != nil {
            return nil, util.FlagErrorf("invalid --start-date: %w", err)
        }
        attrs["startDate"] = t.UTC().Format(time.RFC3339)
    }
    if raw := strings.TrimSpace(opts.finishDate); raw != "" {
        t, err := parseStrictDate(raw)
        if err != nil {
            return nil, util.FlagErrorf("invalid --finish-date: %w", err)
        }
        attrs["finishDate"] = t.UTC().Format(time.RFC3339)
    }
    if start, ok := attrs["startDate"]; ok {
        if finish, ok := attrs["finishDate"]; ok {
            s, _ := time.Parse(time.RFC3339, start.(string))
            f, _ := time.Parse(time.RFC3339, finish.(string))
            if f.Before(s) {
                return nil, util.FlagErrorf("--finish-date must be on or after --start-date")
            }
        }
    }
    for _, kv := range opts.attributes {
        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)
        }
        if _, reserved := attrs[key]; reserved {
            continue // start-date/finish-date win
        }
        attrs[key] = kv[idx+1:]
    }
    return attrs, nil
}

// parseStrictDate accepts RFC 3339 (with T) or YYYY-MM-DD (midnight UTC).
// Rejects the relaxed operators and "today" keyword that list's parseFlexibleDate accepts.
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)
}

§4. Test fixture (copy from workitem/list/list_test.go:765-844)

type fakeCreateDeps struct {
    cmd        *mocks.MockCmdContext
    clientFact *mocks.MockClientFactory
    wit        *mocks.MockWorkItemTrackingClient
    stdout     *bytes.Buffer
}

func setupFakeDeps(t *testing.T, organization string) *fakeCreateDeps {
    t.Helper()
    ctrl := gomock.NewController(t)
    t.Cleanup(ctrl.Finish)

    io, _, out, _ := iostreams.Test()
    io.SetStdoutTTY(false)
    io.SetStderrTTY(false)

    deps := &fakeCreateDeps{
        cmd:        mocks.NewMockCmdContext(ctrl),
        clientFact: mocks.NewMockClientFactory(ctrl),
        wit:        mocks.NewMockWorkItemTrackingClient(ctrl),
        stdout:     out,
    }

    deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes()
    deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes()
    deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes()
    deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), organization).Return(deps.wit, nil).AnyTimes()

    tp, err := printer.NewTablePrinter(out, false, 200)
    require.NoError(t, err)
    deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes()

    return deps
}

API Surface

Reuse the already-vendored client. No new SDK clients required.

Mock for CreateOrUpdateClassificationNode is already generated at internal/mocks/workitemtracking_client_mock.go:106-118. No mock regeneration needed.

Implementation Approach (TDD, reuse-first, minimal)

Use the golang-spf13-cobra, golang-cli, golang-testing, and golang-stretchr-testify skills as the source of truth for structure, flag wiring, args validators, table-driven tests, and mock verification.

Phase 1 — RED (tests first). Mirror setupFakeDeps from internal/cmd/boards/workitem/list/list_test.go:765-844. Add create_test.go with the following table-driven / behaviour tests, all using t.Parallel() and gomock (require for preconditions, assert for verifications):

  • TestNewCmd_RegistersAsCreateLeaf — asserts cmd.Name() == "create", cmd.Aliases contains c and cr, cmd.Use starts with create [ORGANIZATION/]PROJECT.
  • TestNewCmd_NameFlagRequired — runs cmd.SetArgs([]string{"Fabrikam"}) + cmd.Execute(); asserts cobra MarkFlagRequired error mentioning name.
  • TestRunCreate_EmptyNameFlag — sets --name " "; asserts util.FlagErrorf returned with --name in message.
  • TestRunCreate_RootLevelCreate — sets only --name "Sprint 1"; stubs wit.EXPECT().CreateOrUpdateClassificationNode(gomock.Any(), gomock.Any()); asserts captured args.Path == nil, *args.StructureGroup == TreeStructureGroupValues.Iterations (literal "iterations"), *args.Project == "Fabrikam", *args.PostedNode.Name == "Sprint 1", args.PostedNode.Attributes == nil.
  • TestRunCreate_NestedPathCreate — sets --name "Sprint 2" --path "Release 2025"; asserts args.Path != nil and equals "Release%202025" (URL-escaped).
  • TestRunCreate_PathNormalizationStripsProjectAndIteration — sets --path "Fabrikam/Iteration/Release 2025/Sprint 1"; asserts args.Path == "Release%202025/Sprint%201".
  • TestRunCreate_PathURLEscaping — sets --path "My Sprint/Sub Sprint"; asserts args.Path == "My%20Sprint/Sub%20Sprint".
  • TestRunCreate_StructureGroupIsIterations — asserts *args.StructureGroup == "iterations".
  • TestRunCreate_PostedNodeHasName — asserts args.PostedNode != nil, *args.PostedNode.Name == "X", args.PostedNode.Id == nil (no move semantics).
  • TestRunCreate_StartDateOnly — sets --start-date 2025-01-06; asserts (*args.PostedNode.Attributes)["startDate"] == "2025-01-06T00:00:00Z" and finishDate key absent.
  • TestRunCreate_FinishDateOnly — sets --finish-date 2025-01-19T00:00:00Z; asserts attributes["finishDate"] present, startDate key absent.
  • TestRunCreate_BothDates_RFC3339 — sets --start-date 2025-01-06T00:00:00Z --finish-date 2025-01-19T00:00:00Z; asserts both present in attributes as strings.
  • TestRunCreate_DateFlags_InvalidFormat — sets --start-date "yesterday"; asserts util.FlagErrorf with invalid --start-date.
  • TestRunCreate_DateFlags_FinishBeforeStart — sets --start-date 2025-01-19 --finish-date 2025-01-06; asserts util.FlagErrorf with finish-date must be on or after start-date.
  • TestRunCreate_AttributesFlag_Merged — sets --attributes goal=Ship --attributes team=Alpha; asserts attributes["goal"] == "Ship" and attributes["team"] == "Alpha".
  • TestRunCreate_AttributesFlag_StartDateWins — sets both --start-date 2025-01-06 and --attributes startDate=2024-12-01; asserts the start-date flag value is used.
  • TestRunCreate_AttributesFlag_InvalidFormat — sets --attributes "=value" (no key) and --attributes "novalue" (no =); asserts util.FlagErrorf.
  • TestRunCreate_ProjectScopeParsing — table-driven: [ORG/]PROJECT, PROJECT, invalid ("org/proj/extra"), empty.
  • TestRunCreate_InvalidProjectScope — asserts util.FlagErrorWrap returned.
  • TestRunCreate_ClientFactoryError — stubs factory to return error; asserts wrapped error.
  • TestRunCreate_SDKError — stubs SDK to return error; asserts wrapped error.
  • TestRunCreate_TableOutput_AllColumns — mocks return *WorkItemClassificationNode{Id, Name, Path, HasChildren, Attributes{startDate,finishDate}}; parses stdout; asserts row matches all 6 columns.
  • TestRunCreate_JSONOutput — sets --json; mocks return node; asserts JSON contains id, name, path, _links, hasChildren, url, identifier, structureType, attributes.
  • TestRunCreate_OrganizationFromConfigDefault — when scopeArg is PROJECT (no org), asserts clientFact.WorkItemTracking(ctx, defaultOrg) is called with the configured default.

Phase 2 — GREEN (minimal implementation). Strict reuse rules:

  • No new helpers beyond the inline buildAttributes / parseStrictDate / formatAttributeDate in §3. Do not promote parseFlexibleDate from list.go — its relaxed semantics (operators, today) are wrong for create.
  • Reuse util.ParseProjectScope, util.AddJSONFlags, util.FlagErrorf/FlagErrorWrap, util.ExactArgs, types.GetValue, types.ToPtr, ctx.Printer("table"), ios.StartProgressIndicator/StopProgressIndicator, iostreams.Test as-is.
  • Reuse shared.BuildClassificationPath(scope.Project, true, "Iteration", opts.path) for parent-path normalization; the helper already URL-escapes segments.
  • Reuse shared.NormalizeClassificationPath for the response table Path column (REST returns backslashes; convert to forward slashes).
  • SDK call only: wit.CreateOrUpdateClassificationNode(ctx.Context(), args) with args.PostedNode{Name, Attributes}, args.Project, args.StructureGroup = &workitemtracking.TreeStructureGroupValues.Iterations, and conditionally args.Path.
  • Progress indicator: ios.StartProgressIndicator() + defer ios.StopProgressIndicator(); call ios.StopProgressIndicator() immediately before the table render (mirrors internal/cmd/repo/create/create.go).
  • Output split: JSON via opts.exporter.Write(ios, res) passing the raw SDK *WorkItemClassificationNode; table via ctx.Printer("table") with AddColumns/AddField/EndRow/Render.
  • Debug log at the point of the SDK call: organization, project, name, parentPath, attributeCount.

Target delta: create.go ≤ ~170 LOC, create_test.go ≤ ~450 LOC (22 tests), parent project.go +3 LOC, docs/boards_iteration_project_create.md regenerated via make docs. No changes to list.go, delete.go, update.go, or iteration.go.

Tooling and Verification Checklist

  • gofmt / gofumpt on touched files
  • go test ./internal/cmd/boards/iteration/...
  • go test ./...
  • make lint
  • make docs

Reference Existing Patterns

  • internal/cmd/boards/iteration/project/list/list.gouse this as the structural template (already uses the SDK; same BuildClassificationPath(project, true, "Iteration", ...) and TreeStructureGroupValues.Iterations).
  • internal/cmd/boards/iteration/project/create/create.go (this issue) — primary deliverable.
  • internal/cmd/boards/iteration/project/delete/delete.go (feat: Implement azdo boards iteration project delete command #207) — destructive-command sibling; mirror the SDK call shape and progress lifecycle.
  • internal/cmd/boards/iteration/project/update/update.go (feat: Implement azdo boards iteration project update command #209) — update sibling; mirror the date/attribute parsing style.
  • internal/cmd/boards/area/project/create/create.go (feat: Implement azdo boards area project create command #204) — closest mirror; copy progress lifecycle, JSON/table split, raw-SDK JSON output.
  • internal/cmd/boards/workitem/list/list_test.go:765-844setupFakeDeps / stub* fixture; copy structure (Decision fixture §4).
  • internal/cmd/boards/shared/path.goBuildClassificationPath + NormalizeClassificationPath (reuse, do not reimplement).
  • internal/mocks/workitemtracking_client_mock.go:106-118 — mock for CreateOrUpdateClassificationNode (already generated, do not regenerate).
  • internal/azdo/factory.goClientFactory().WorkItemTracking(...) accessor (reuse).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions