Skip to content

feat: Implement azdo boards work-item create command #203

Description

@tmeckel

Sub-issue of #138. Hardened spec — do not re-derive decisions. Sibling of #136 (work-item list).

Command Description

Create a single Azure Boards work item in a project. Mirrors the ergonomics of az boards work-item create but routes through the vendored Azure DevOps Go SDK.

The REST surface is Work Items - Create (REST 7.1):

POST https://dev.azure.com/{organization}/{project}/_apis/wit/workitems/${type}?api-version=7.1
Content-Type: application/json-patch+json

Body is a JSON Patch document (array of {op, path, value} ops). At minimum one add op on /fields/System.Title. Optional query params: validateOnly, bypassRules, suppressNotifications, $expand. Response is the full WorkItem (id, rev, fields, _links, url, optional relations, commentVersionRef).

System.Description is an HTML field — Markdown source is accepted and rendered on the web form. Mirrors the AzDO Extension's create_work_item (azure-devops/azext_devops/dev/boards/work_item.py:31-103) and the AzDO MCP Server's create_work_item tool (microsoft/azure-devops-mcp/src/tools/work-items.ts:521-558).

Locked Decisions (do not re-derive)

These choices are final. Do not explore alternatives; doing so risks scope creep and is the single largest source of one-shot failure.

# Decision Rationale
1 --assigned-to passes the user-provided value as a plain string into /fields/System.AssignedTo. Do not call resolveAssignedToFilter, extensions.Client.GetSelfID, or any identity client on the create path. Azure DevOps resolves the string server-side. resolveAssignedToFilter is for WIQL IN (...) filters and returns descriptors. Wrong shape for this call. Client-side resolution adds a dependency, a failure mode, and a network round-trip. The AzDO Extension's _resolve_identity_as_unique_user_id is appropriate for Python because the Python SDK doesn't return identity descriptors cleanly, but the Go SDK's identity client is heavier and the server-side resolution is reliable.
2 --fields parses as Ref.Name=value, split on the first = only. Value is the raw string. Reject if no = is present. Escape hatch; the Ref.Name notation is the standard Azure DevOps field reference format.
3 --tag joins all values with "; " into a single /fields/System.Tags op (web-UI behaviour). Reuse the existing validateTags from list.go after exporting it. Empty/whitespace tags rejected. Matches Azure DevOps web UI and avoids server-side tag splitting.
4 Canonical op order in the patch doc is fixed (see Code Skeleton §1). Tests assert this order. Predictability for tests and for downstream debugging.
5 No new helpers beyond the two shared files. Inline the patch-doc append. The existing 4-line pattern in security/group/update/update.go:103-124 is the template. Mandate: minimal code. A new helper duplicates the pattern.
6 Identity helper location: do not import resolveAssignedToFilter from list.go. For validateTags, do not duplicate it — export it by creating internal/cmd/boards/workitem/shared/tags.go with func ValidateTags(flag string, values []string) error and call from both list.go and create.go. Update list.go's call sites in the same patch. One new file, three lines moved. Long-term clarity, zero behaviour change.
7 JSON output passes the raw SDK WorkItem to opts.exporter.Write. Do not introduce a view struct. Symmetric with list.go's JSON output. Eliminates a decision point.
8 No confirmation prompt. Add a stderr warning if --bypass-rules or --suppress-notifications is set. Create is reversible via delete.
9 No --type existence pre-check. Defer to the REST 4xx response and surface it via util.FlagErrorWrap. Saves one round-trip; REST error is already clear.
10 Mock AddComment negative assertion is mandatory. TestRunCreate_DiscussionTriggersAddComment must include a literal wit.EXPECT().AddComment(gomock.Any(), gomock.Any()).Times(0) when --discussion is not set. Without Times(0), gomock silently allows the call. The test would pass vacuously.
11 The description accepts three input modes (Decision 12-14 cover the rules): inline via --description, file via --description-file (repeatable, - reads from stdin), or interactive editor via --description-editor. Neither the AzDO Extension nor the AzDO MCP Server has editor/file-import support — this is a new azdo capability. Long-form descriptions (bug repro steps, design docs, acceptance criteria) are awkward to pass on the command line. Editor + file import match kubectl edit and git commit conventions.
12 --description-file <path> is repeatable. Multiple invocations concatenate with \n. The special token - reads from os.Stdin. Lets the user assemble descriptions from existing Markdown files. Mirrors git add - and kubectl apply -f - patterns.
13 --description-editor opens $VISUAL (preferred) or $EDITOR, falling back to vi on POSIX / notepad on Windows. A .md temp file is pre-populated with a header comment (<!-- Provide a work-item description in Markdown. Lines starting with '#' are ignored. Save and exit when done. Delete all content to abort. -->). Lines starting with # are stripped on read-back. Empty result → error. Standard editor convention. Markdown extension enables syntax highlighting. Header convention matches git commit and kubectl edit (user can leave themselves notes that get stripped on read-back). Abort-by-deleting-all-content matches git rebase -i.
14 Description source precedence: editor > file > inline (most explicit wins). When the user supplies multiple sources, a warning to stderr names the source that was selected. The command does not error on multiple sources — it picks the highest-priority one and warns. Matches the principle of least surprise: an explicit interactive choice (editor) beats a passive one (file) beats a fully-inline one. Warnings rather than errors keep scripting flows working.
15 Shared description helper at internal/cmd/boards/workitem/shared/description.go. Public API: func ResolveDescription(ios *iostreams.IOStreams, opts DescriptionOptions) (string, error). Helper handles precedence, file reading (UTF-8 validation, 1 MB size cap, binary detection), editor invocation, and stripping. The package-level var execEditorCommand = exec.Command enables tests to inject a fake editor. Both create and update (#270) need the same logic. Centralizing it eliminates duplication and ensures both commands behave identically. The execEditorCommand var mirrors the standard Go pattern for testable os/exec calls.
16 No Markdown↔HTML conversion on the client. The azdo CLI passes the description text through to the server's /fields/System.Description op unchanged. The Azure DevOps server renders Markdown. The AzDO MCP Server's encodeFormattedValue (work-items.ts:521-558) is only relevant when the field explicitly takes a format parameter; for the WorkItem create/update endpoints, the server renders Markdown automatically. The Python az boards work-item create likewise passes the description string as-is.

Command Signature

azdo boards work-item 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
--type (required) CreateWorkItemArgs.Type e.g. Bug, Task, User Story
--title (required) /fields/System.Title
--description /fields/System.Description HTML/Markdown. Lower-priority than --description-file and --description-editor (Decision 14).
--description-file (repeatable) /fields/System.Description Read description from <path>. Repeatable; multiple files are concatenated with \n. The special token - reads from stdin. Higher-priority than --description (Decision 14).
--description-editor /fields/System.Description Open $VISUAL/$EDITOR (fallback vi/notepad) with a .md temp file. Pre-populated with a header comment. Lines starting with # are stripped. Highest-priority (Decision 14).
--assigned-to /fields/System.AssignedTo String pass-through (Decision 1)
--area /fields/System.AreaPath
--iteration /fields/System.IterationPath
--tag (repeatable) /fields/System.Tags Join with "; " (Decision 3)
--priority /fields/Microsoft.VSTS.Common.Priority int 1-4; FlagErrorf out of range
--severity /fields/Microsoft.VSTS.Common.Severity 1 - Critical..4 - Low
--parent (int) /fields/System.Parent
--reason /fields/System.Reason
--state /fields/System.State Initial state
--fields (repeatable Ref.Name=value) raw /fields/Ref.Name Split on first = (Decision 2)
--link (repeatable rel,url) /relations/- rel and url set, no attributes
--bypass-rules CreateWorkItemArgs.BypassRules
--suppress-notifications CreateWorkItemArgs.SuppressNotifications
--validate-only CreateWorkItemArgs.ValidateOnly
--expand (enum) CreateWorkItemArgs.Expand None/Relations/Fields/Links/All
--open open returned URL in default browser Mirrors az
--discussion appends a comment via wit.AddComment Side-effect, separate from the patch doc

JSON Output Contract

util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "rev", "fields", "url", "_links", "relations", "commentVersionRef"})

Pass the raw SDK WorkItem (no view struct — Decision 7).

Table default columns: ID, TYPE, STATE, TITLE, ASSIGNED TO, AREA, ITERATION (same as list).

Command Wiring

  • Package path: internal/cmd/boards/workitem/create
  • Factory: internal/cmd/boards/workitem/create/create.go with NewCmd(ctx util.CmdContext) *cobra.Command
  • Update internal/cmd/boards/workitem/workitem.go to add cmd.AddCommand(create.NewCmd(ctx)) and extend the Example block.
  • New shared helper package: internal/cmd/boards/workitem/shared/tags.go exposing ValidateTags (Decision 6) and internal/cmd/boards/workitem/shared/description.go exposing ResolveDescription and DescriptionOptions (Decision 15).
  • Existing higher-level parents that must already remain wired: boards -> work-item -> create.

Code Skeleton (canonical, copy verbatim)

§1. createOptions struct and canonical patch-doc order

type createOptions struct {
    scopeArg string

    workItemType string // --type
    title        string // --title

    description      string   // --description (inline)
    descriptionFiles []string // --description-file (repeatable; "-" reads stdin)
    descriptionEditor bool    // --description-editor (bool flag)
    assignedTo       string
    area             string
    iteration        string
    tags             []string
    priority         int
    severity         string
    parent           int
    reason           string
    state            string
    customFields     []fieldKV // --fields Ref.Name=value
    links            []linkKV  // --link rel,url

    bypassRules           bool
    suppressNotifications bool
    validateOnly          bool
    expand                string
    openInBrowser         bool
    discussion            string

    exporter util.Exporter
}

type fieldKV struct{ ref, value string }
type linkKV  struct{ rel, url string }

The patch doc must append ops in this exact order. Tests assert order:

  1. /fields/System.Title
  2. /fields/System.Description (only if ResolveDescription returns non-empty)
  3. /fields/System.AssignedTo
  4. /fields/System.AreaPath
  5. /fields/System.IterationPath
  6. /fields/System.Tags (single op, joined with "; )
  7. /fields/Microsoft.VSTS.Common.Priority
  8. /fields/Microsoft.VSTS.Common.Severity
  9. /fields/System.Parent
  10. /fields/System.Reason
  11. /fields/System.State
  12. raw /fields/ ops from --fields (in user-given order)
  13. /relations/- ops from --link (in user-given order)

§2. Patch-doc construction (4-line pattern, copy from security/group/update/update.go:103-124)

add := webapi.OperationValues.Add
doc := []webapi.JsonPatchOperation{}
patch := func(path string, value any) {
    p := path
    doc = append(doc, webapi.JsonPatchOperation{Op: &add, Path: &p, Value: value})
}
// ... append in the order above ...

§3. runCreate signature

func runCreate(ctx util.CmdContext, opts *createOptions) error

§4. NewCmd shape (no surprises)

func NewCmd(ctx util.CmdContext) *cobra.Command {
    opts := &createOptions{}
    cmd := &cobra.Command{
        Use:     "create [ORGANIZATION/]PROJECT",
        Short:   "Create a work item in a project.",
        Aliases: []string{"c", "cr"},
        Args:    util.ExactArgs(1, "project argument required"),
        Example: heredoc.Doc(`# create a bug in the default org's Fabrikam project
            azdo boards work-item create Fabrikam --type Bug --title "Login is broken"
            # create from a description file
            azdo boards work-item create Fabrikam --type Bug --title "Login broken" --description-file ./repro.md
            # open the description in the user's $EDITOR
            azdo boards work-item create Fabrikam --type Bug --title "Login broken" --description-editor`),
        RunE: func(cmd *cobra.Command, args []string) error {
            opts.scopeArg = args[0]
            return runCreate(ctx, opts)
        },
    }
    // --type, --title  : required (cmd.MarkFlagRequired)
    // --description    : StringVar
    // --description-file : StringSliceVar
    // --description-editor : BoolVar
    // --assigned-to    : StringVar
    // --area, --iteration, --reason, --state, --severity, --expand : StringVar
    // --tag            : StringSliceVar
    // --priority, --parent : IntVar
    // --fields         : StringSliceVar
    // --link           : StringSliceVar
    // --bypass-rules, --suppress-notifications, --validate-only, --open : BoolVar
    // --discussion     : StringVar
    util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "rev", "fields", "url", "_links", "relations", "commentVersionRef"})
    return cmd
}

§5. 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) }

    if err := shared.ValidateTags("--tag", opts.tags); err != nil {
        return util.FlagErrorWrap(err)
    }

    description, err := shared.ResolveDescription(ios, shared.DescriptionOptions{
        Inline: opts.description,
        Files:  opts.descriptionFiles,
        Editor: opts.descriptionEditor,
    })
    if err != nil { return util.FlagErrorWrap(err) }

    doc := buildPatchDocument(opts, description) // see §2; description is "" -> skip
    args := workitemtracking.CreateWorkItemArgs{
        Document:              &doc,
        Project:               types.ToPtr(scope.Project),
        Type:                  types.ToPtr(opts.workItemType),
        ValidateOnly:          types.ToPtr(opts.validateOnly),
        BypassRules:           types.ToPtr(opts.bypassRules),
        SuppressNotifications: types.ToPtr(opts.suppressNotifications),
    }
    if opts.expand != "" {
        e := workitemtracking.WorkItemExpand(opts.expand)
        args.Expand = &e
    }

    wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization)
    if err != nil { return err }

    res, err := wit.CreateWorkItem(ctx.Context(), args)
    if err != nil { return err }

    if opts.discussion != "" {
        if _, err := wit.AddComment(ctx.Context(), workitemtracking.AddCommentArgs{
            Project: types.ToPtr(scope.Project),
            WorkItemId: res.Id,
            Comment: &workitemtracking.Comment{Text: types.ToPtr(opts.discussion)},
        }); err != nil { return err }
    }

    if opts.bypassRules || opts.suppressNotifications {
        fmt.Fprintf(ios.ErrOut, "warning: --bypass-rules/--suppress-notifications bypass work item type rules and notifications\n")
    }

    ios.StopProgressIndicator() // before user-visible output

    if opts.exporter != nil {
        return opts.exporter.Write(ios, res) // raw SDK WorkItem, no view struct
    }
    tp, err := ctx.Printer("list")
    if err != nil { return err }
    tp.AddColumns("ID", "TYPE", "STATE", "TITLE", "ASSIGNED TO", "AREA", "ITERATION")
    tp.AddField(strconv.Itoa(types.GetValue(res.Id, 0)))
    tp.AddField(fieldString(res.Fields, "System.WorkItemType"))
    tp.AddField(fieldString(res.Fields, "System.State"))
    tp.AddField(fieldString(res.Fields, "System.Title"))
    tp.AddField(fieldIdentityDisplay(res.Fields, "System.AssignedTo"))
    tp.AddField(fieldString(res.Fields, "System.AreaPath"))
    tp.AddField(fieldString(res.Fields, "System.IterationPath"))
    tp.EndRow()
    return tp.Render()
}

fieldString and fieldIdentityDisplay already exist in list.go. Promote them to internal/cmd/boards/workitem/shared/fields.go and reuse from both commands.

§6. shared/description.go helper (Decision 15)

// internal/cmd/boards/workitem/shared/description.go
package shared

import (
    "fmt"
    "io"
    "os"
    "os/exec"
    "runtime"
    "strings"
    "unicode/utf8"

    "github.com/tmeckel/azdo-cli/internal/cmd/util"
    "github.com/tmeckel/azdo-cli/internal/iostreams"
)

const descriptionFileMaxBytes = 1 << 20 // 1 MB

// execEditorCommand is overridable in tests.
var execEditorCommand = exec.Command

// ResolveDescription picks the highest-priority source and returns the
// description text. Precedence: editor > files > inline. When multiple
// sources are supplied, a warning is written to stderr explaining which
// source was selected.
func ResolveDescription(ios *iostreams.IOStreams, opts DescriptionOptions) (string, error) {
    if opts.Editor {
        if len(opts.Files) > 0 {
            fmt.Fprintln(ios.ErrOut, "warning: --description-editor takes precedence over --description-file")
        }
        if opts.Inline != "" {
            fmt.Fprintln(ios.ErrOut, "warning: --description-editor takes precedence over --description")
        }
        return OpenEditor("")
    }
    if len(opts.Files) > 0 {
        if opts.Inline != "" {
            fmt.Fprintln(ios.ErrOut, "warning: --description-file takes precedence over --description")
        }
        return ReadDescriptionFiles(ios, opts.Files)
    }
    return opts.Inline, nil
}

func ReadDescriptionFiles(ios *iostreams.IOStreams, paths []string) (string, error) {
    var parts []string
    for _, p := range paths {
        if p == "-" {
            data, err := io.ReadAll(ios.In)
            if err != nil {
                return "", fmt.Errorf("failed to read description from stdin: %w", err)
            }
            parts = append(parts, string(data))
            continue
        }
        info, err := os.Stat(p)
        if err != nil {
            return "", util.FlagErrorf("description file %q: %w", p, err)
        }
        if info.Size() > descriptionFileMaxBytes {
            return "", util.FlagErrorf("description file %q exceeds 1 MB limit", p)
        }
        data, err := os.ReadFile(p)
        if err != nil {
            return "", util.FlagErrorf("failed to read description file %q: %w", p, err)
        }
        if !utf8.Valid(data) {
            return "", util.FlagErrorf("description file %q is not valid UTF-8", p)
        }
        if hasBinaryMarker(data) {
            return "", util.FlagErrorf("description file %q appears to be binary", p)
        }
        parts = append(parts, string(data))
    }
    return strings.TrimSpace(strings.Join(parts, "\n")), nil
}

func OpenEditor(initial string) (string, error) {
    editor := os.Getenv("VISUAL")
    if editor == "" {
        editor = os.Getenv("EDITOR")
    }
    if editor == "" {
        if runtime.GOOS == "windows" {
            editor = "notepad"
        } else {
            editor = "vi"
        }
    }

    tmp, err := os.CreateTemp("", "azdo-desc-*.md")
    if err != nil {
        return "", fmt.Errorf("failed to create temp file: %w", err)
    }
    defer os.Remove(tmp.Name())

    header := "<!-- Provide a work-item description in Markdown.\n" +
        "     Lines starting with '#' are ignored.\n" +
        "     Save and exit when done. Delete all content to abort. -->\n"
    if _, err := tmp.WriteString(header + initial); err != nil {
        return "", fmt.Errorf("failed to write temp file: %w", err)
    }
    if err := tmp.Close(); err != nil {
        return "", err
    }

    cmd := execEditorCommand(editor, tmp.Name())
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    if err := cmd.Run(); err != nil {
        return "", fmt.Errorf("editor exited with error: %w", err)
    }

    data, err := os.ReadFile(tmp.Name())
    if err != nil {
        return "", fmt.Errorf("failed to read temp file: %w", err)
    }
    stripped := stripDescriptionLines(data)
    if strings.TrimSpace(stripped) == "" {
        return "", util.FlagErrorf("editor produced empty description; abort")
    }
    return stripped, nil
}

// stripDescriptionLines removes any line whose first non-whitespace
// character is '#' (the header comment convention). Returns the trimmed
// remaining content.
func stripDescriptionLines(data []byte) string {
    lines := strings.Split(string(data), "\n")
    kept := make([]string, 0, len(lines))
    for _, line := range lines {
        if strings.HasPrefix(strings.TrimSpace(line), "#") {
            continue
        }
        kept = append(kept, line)
    }
    return strings.TrimSpace(strings.Join(kept, "\n"))
}

func hasBinaryMarker(data []byte) bool {
    n := len(data)
    if n > 8192 {
        n = 8192
    }
    for _, b := range data[:n] {
        if b == 0 {
            return true
        }
    }
    return false
}

API Surface

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

Mock for CreateWorkItem is already generated at internal/mocks/workitemtracking_client_mock.go:166-180. 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 the setupFakeDeps / stub* fixture 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. Use cmd.SetArgs + cmd.Execute pattern from golang-spf13-cobra::testing.
  • TestRunCreate_RequiredFlagsMissing - runs the command with no flags; asserts FlagError containing --type and --title (cobra's MarkFlagRequired already covers this; test guards against regression).
  • TestRunCreate_MinimalTitleAndType - stubs wit.EXPECT().CreateWorkItem(gomock.Any(), gomock.Any()).DoAndReturn(captureArgs), asserts the captured *args.Document contains exactly one add op on /fields/System.Title; asserts return table has the expected ID/TYPE/STATE/TITLE row.
  • TestRunCreate_AllOptionalFields_CanonicalOrder - sets every optional flag; asserts the captured patch doc has exactly the 13 op positions in the order listed in Code Skeleton §1. Use assert.Equal on the slice after dereferencing *args.Document.
  • TestRunCreate_CustomFields - asserts --fields Ref.Name=value produces raw /fields/ ops in user-given order at positions 12+.
  • TestRunCreate_Links - asserts --link rel,url produces /relations/- ops at positions 13+, each with Op == Add, Path == "/relations/-", Value containing rel and url.
  • TestRunCreate_DiscussionTriggersAddComment - literal gomock form:
    // when --discussion is set
    wit.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(&workitemtracking.Comment{}, nil)
    // when --discussion is NOT set (in a separate test case)
    wit.EXPECT().AddComment(gomock.Any(), gomock.Any()).Times(0)
  • TestRunCreate_ProjectScopeParsing - table-driven: [ORG/]PROJECT, PROJECT, and empty-scope error, via util.ParseProjectScope.
  • TestRunCreate_InvalidProjectScope - asserts util.FlagErrorWrap is returned.
  • TestRunCreate_JSONOutput - calls opts.exporter.Write; assert the resulting JSON contains the expected id, rev, fields.System.Title, url keys.
  • TestRunCreate_BypassRulesAndSuppressNotifications - asserts CreateWorkItemArgs.BypassRules and SuppressNotifications are propagated.
  • TestRunCreate_ValidateOnly - asserts CreateWorkItemArgs.ValidateOnly is true and the response is still rendered.
  • TestRunCreate_TagsValidation - asserts shared.ValidateTags rejects empty/whitespace values and accepts multi-tag input.
  • TestRunCreate_FieldsParseSplitOnFirstEquals - asserts --fields "Foo.Bar=key=value" parses as ref="Foo.Bar", value="key=value" (split on first =).
  • TestRunCreate_LinkParseSplitOnFirstComma - asserts --link "rel,url,extra" parses as rel="rel", url="url,extra" (split on first ,).
  • TestRunCreate_OpenBrowserFlag - asserts --open does not affect the create call and is a no-op when the response has no url (table path covers this; --open is best-effort and not unit-testable without DI).

Description tests (new — cover Decision 11-16):

  • TestRunCreate_DescriptionFromInline - --description "text". Captured args.Document has /fields/System.Description op with value "text".
  • TestRunCreate_DescriptionFromSingleFile - --description-file ./foo.md (test creates a temp file). Captured op has the file's content.
  • TestRunCreate_DescriptionFromStdin - --description-file - with ios.In set via iostreams.System().SetIn(strings.NewReader("stdin content")). Captured op has "stdin content".
  • TestRunCreate_DescriptionFromMultipleFiles_Concatenated - --description-file a.md --description-file b.md. Captured op has a + "\n" + b.
  • TestRunCreate_DescriptionFileNotFound - --description-file /nonexistent. Returns util.FlagErrorf with the path. SDK is not called.
  • TestRunCreate_DescriptionFileTooLarge - temp file > 1 MB. Returns util.FlagErrorf with "exceeds 1 MB".
  • TestRunCreate_DescriptionFileBinary - temp file with null byte in first 8KB. Returns util.FlagErrorf with "appears to be binary".
  • TestRunCreate_DescriptionFileNotUTF8 - temp file with invalid UTF-8 sequence. Returns util.FlagErrorf with "not valid UTF-8".
  • TestRunCreate_DescriptionEditor - inject execEditorCommand = fakeEditor("written by editor"). Captured op has "written by editor".
  • TestRunCreate_DescriptionEditorStripsCommentLines - inject execEditorCommand = fakeEditor("# comment\n# also comment\n# my notes\nactual content\n"). Captured op has "actual content".
  • TestRunCreate_DescriptionEditorEmptyAborts - inject execEditorCommand = fakeEditor(""). Returns util.FlagErrorf with "editor produced empty description".
  • TestRunCreate_DescriptionEditorNonZeroExit - inject execEditorCommand that returns exit code 1. Returns wrapped error.
  • TestRunCreate_DescriptionEditorEnvFallsBack - test sets $VISUAL=, $EDITOR=; helper falls back to vi (POSIX) / notepad (Windows). Asserted via spy.
  • TestRunCreate_DescriptionPrecedenceEditorOverFile - both --description-editor and --description-file set. Editor wins; warning to stderr contains "takes precedence over --description-file".
  • TestRunCreate_DescriptionPrecedenceEditorOverInline - both --description-editor and --description set. Editor wins; warning contains "takes precedence over --description".
  • TestRunCreate_DescriptionPrecedenceFileOverInline - both --description-file and --description set. File wins; warning contains "takes precedence over --description".
  • TestRunCreate_DescriptionAbsent_OmitsPatchOp - no description flags set. Captured args.Document has no /fields/System.Description op.

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

  • No new helpers beyond the three shared files (shared/tags.go, shared/fields.go, shared/description.go). Inline everything else.
  • Reuse util.ParseProjectScope, util.AddJSONFlags, util.FlagErrorf/FlagErrorWrap, util.ExactArgs, types.GetValue, types.ToPtr, ctx.Printer("list"), ios.StartProgressIndicator/StopProgressIndicator, iostreams.Test as-is.
  • Reuse shared.ValidateTags (promoted from list.go) and shared.FieldString / shared.FieldIdentityDisplay (promoted from list.go).
  • Reuse shared.ResolveDescription (new) for all description source resolution.
  • Patch-doc construction: copy the webapi.JsonPatchOperation append pattern from internal/cmd/security/group/update/update.go:103-124. Do not introduce a generic patch-doc builder.
  • Progress indicator: ios.StartProgressIndicator() + defer ios.StopProgressIndicator(); call ios.StopProgressIndicator() immediately before any user-visible print (mirrors internal/cmd/repo/create/create.go:62-65).
  • Output split: JSON via opts.exporter.Write(ios, res) passing the raw SDK WorkItem; table via ctx.Printer("list") with AddColumns/AddField/EndRow/Render.
  • No confirmation prompt. Stderr warning when --bypass-rules or --suppress-notifications is set.

Target delta: create.go <= ~200 LOC, create_test.go <= ~600 LOC (15 base + 17 description tests), shared/tags.go <= ~25 LOC, shared/fields.go <= ~40 LOC, shared/description.go <= ~120 LOC, shared/description_test.go <= ~300 LOC, parent workitem.go +3 LOC, docs/boards_work-item_create.md regenerated via make docs. list.go and list_test.go updated to call the shared helpers (mechanical find/replace).

Tooling and Verification Checklist

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

Reference Existing Patterns

  • internal/cmd/boards/workitem/list/list.go - project-scope parsing, JSON/table split, progress indicator
  • internal/cmd/boards/workitem/list/list_test.go:765-844 - setupFakeDeps / stub* fixture; copy structure
  • internal/cmd/repo/create/create.go - progress lifecycle and [ORG/]PROJECT[/NAME] parsing
  • internal/cmd/security/group/update/update.go:103-124 - existing JSON Patch document construction
  • internal/cmd/boards/workitem/shared/description.go (new, this issue) - ResolveDescription, ReadDescriptionFiles, OpenEditor helpers. Reused by feat: Implement azdo boards work-item update command #270 (update) unchanged.
  • internal/mocks/workitemtracking_client_mock.go:166-180 - mock for CreateWorkItem
  • internal/azdo/factory.go:149 - ClientFactory().WorkItemTracking(...) accessor

Reference implementations (for UX parity only — azdo is the implementation target):

  • AzDO Extension azure-devops/azext_devops/dev/boards/work_item.py:31-103 - create_work_item(work_item_type, title, description, ...) confirms the description parameter is a plain string. The AzDO Extension does not have editor or file-import support; this is new in azdo.
  • AzDO MCP Server microsoft/azure-devops-mcp/src/tools/work-items.ts:521-558 - create_work_item MCP tool with format: "Html" | "Markdown" enum. Confirms the server's System.Description field accepts Markdown directly; no client-side conversion is needed.

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