Skip to content

feat: Implement azdo boards iteration project create command #205

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

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions