Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# ignore-vcs = false
extend-exclude = [
"**/.mise.toml",
"vendor/**",
]

[default]
Expand Down
1 change: 1 addition & 0 deletions docs/azdo_boards_iteration_project.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Project-scoped iteration commands.

### Available commands

* [azdo boards iteration project create](./azdo_boards_iteration_project_create.md)
* [azdo boards iteration project list](./azdo_boards_iteration_project_list.md)

### ALIASES
Expand Down
78 changes: 78 additions & 0 deletions docs/azdo_boards_iteration_project_create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
## Command `azdo boards iteration project create`

Create an iteration (sprint) in a project.

```
azdo boards iteration project create [ORGANIZATION/]PROJECT [flags]
```

### Options


* `--attributes` `strings`

Custom attribute in key=value form. Repeatable. start-date/finish-date win on key conflict.

* `--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"


### ALIASES

- `c`
- `cr`

### JSON Fields

`_links`, `attributes`, `hasChildren`, `id`, `identifier`, `name`, `path`, `structureType`, `url`

### Examples

```bash
# 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
```

### See also

* [azdo boards iteration project](./azdo_boards_iteration_project.md)
21 changes: 21 additions & 0 deletions docs/azdo_help_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,27 @@ Aliases
prj, p
```

##### `azdo boards iteration project create [ORGANIZATION/]PROJECT [flags]`

Create an iteration (sprint) in a project.

```
--attributes strings Custom attribute in key=value form. Repeatable. start-date/finish-date win on key conflict.
--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"
```

Aliases

```
c, cr
```

##### `azdo boards iteration project list [ORGANIZATION/]PROJECT [flags]`

List iteration hierarchy for a project.
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ require (
github.com/zalando/go-keyring v0.2.8
go.uber.org/mock v0.6.0
go.uber.org/zap v1.28.0
golang.org/x/sys v0.45.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
golang.org/x/text v0.38.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,22 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
Expand Down
231 changes: 231 additions & 0 deletions internal/cmd/boards/iteration/project/create/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package create

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 createOptions struct {
scopeArg string
name string
path string
startDate string
finishDate string
attributes []string
exporter util.Exporter
}

func NewCmd(ctx util.CmdContext) *cobra.Command {
opts := &createOptions{}

cmd := &cobra.Command{
Use: "create [ORGANIZATION/]PROJECT",
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"

# 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
}

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

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)
}

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("list")
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()
}

// buildAttributes assembles iteration attributes. Start/finish flags win over --attributes.
func buildAttributes(opts *createOptions) (map[string]any, error) {
attrs := make(map[string]any)
var startTime *time.Time
var finishTime *time.Time

if raw := strings.TrimSpace(opts.startDate); raw != "" {
t, err := parseStrictDate(raw)
if err != nil {
return nil, util.FlagErrorf("invalid --start-date: %w", err)
}
start := t.UTC()
startTime = &start
}
if raw := strings.TrimSpace(opts.finishDate); raw != "" {
t, err := parseStrictDate(raw)
if err != nil {
return nil, util.FlagErrorf("invalid --finish-date: %w", err)
}
finish := t.UTC()
finishTime = &finish
}
if startTime != nil && finishTime != nil && finishTime.Before(*startTime) {
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
}
attrs[key] = kv[idx+1:]
}
if startTime != nil {
attrs["startDate"] = startTime.Format(time.RFC3339)
}
if finishTime != nil {
attrs["finishDate"] = finishTime.Format(time.RFC3339)
}

return attrs, 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)
}
Loading
Loading