You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.1Content-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.
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).
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.gopackage 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"
)
constdescriptionFileMaxBytes=1<<20// 1 MB// execEditorCommand is overridable in tests.varexecEditorCommand=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.funcResolveDescription(ios*iostreams.IOStreams, optsDescriptionOptions) (string, error) {
ifopts.Editor {
iflen(opts.Files) >0 {
fmt.Fprintln(ios.ErrOut, "warning: --description-editor takes precedence over --description-file")
}
ifopts.Inline!="" {
fmt.Fprintln(ios.ErrOut, "warning: --description-editor takes precedence over --description")
}
returnOpenEditor("")
}
iflen(opts.Files) >0 {
ifopts.Inline!="" {
fmt.Fprintln(ios.ErrOut, "warning: --description-file takes precedence over --description")
}
returnReadDescriptionFiles(ios, opts.Files)
}
returnopts.Inline, nil
}
funcReadDescriptionFiles(ios*iostreams.IOStreams, paths []string) (string, error) {
varparts []stringfor_, p:=rangepaths {
ifp=="-" {
data, err:=io.ReadAll(ios.In)
iferr!=nil {
return"", fmt.Errorf("failed to read description from stdin: %w", err)
}
parts=append(parts, string(data))
continue
}
info, err:=os.Stat(p)
iferr!=nil {
return"", util.FlagErrorf("description file %q: %w", p, err)
}
ifinfo.Size() >descriptionFileMaxBytes {
return"", util.FlagErrorf("description file %q exceeds 1 MB limit", p)
}
data, err:=os.ReadFile(p)
iferr!=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)
}
ifhasBinaryMarker(data) {
return"", util.FlagErrorf("description file %q appears to be binary", p)
}
parts=append(parts, string(data))
}
returnstrings.TrimSpace(strings.Join(parts, "\n")), nil
}
funcOpenEditor(initialstring) (string, error) {
editor:=os.Getenv("VISUAL")
ifeditor=="" {
editor=os.Getenv("EDITOR")
}
ifeditor=="" {
ifruntime.GOOS=="windows" {
editor="notepad"
} else {
editor="vi"
}
}
tmp, err:=os.CreateTemp("", "azdo-desc-*.md")
iferr!=nil {
return"", fmt.Errorf("failed to create temp file: %w", err)
}
deferos.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)
}
iferr:=tmp.Close(); err!=nil {
return"", err
}
cmd:=execEditorCommand(editor, tmp.Name())
cmd.Stdin=os.Stdincmd.Stdout=os.Stdoutcmd.Stderr=os.Stderriferr:=cmd.Run(); err!=nil {
return"", fmt.Errorf("editor exited with error: %w", err)
}
data, err:=os.ReadFile(tmp.Name())
iferr!=nil {
return"", fmt.Errorf("failed to read temp file: %w", err)
}
stripped:=stripDescriptionLines(data)
ifstrings.TrimSpace(stripped) =="" {
return"", util.FlagErrorf("editor produced empty description; abort")
}
returnstripped, nil
}
// stripDescriptionLines removes any line whose first non-whitespace// character is '#' (the header comment convention). Returns the trimmed// remaining content.funcstripDescriptionLines(data []byte) string {
lines:=strings.Split(string(data), "\n")
kept:=make([]string, 0, len(lines))
for_, line:=rangelines {
ifstrings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
kept=append(kept, line)
}
returnstrings.TrimSpace(strings.Join(kept, "\n"))
}
funchasBinaryMarker(data []byte) bool {
n:=len(data)
ifn>8192 {
n=8192
}
for_, b:=rangedata[:n] {
ifb==0 {
returntrue
}
}
returnfalse
}
API Surface
Reuse the already-vendored client. No new SDK clients required.
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.
// when --discussion is setwit.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_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_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_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 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).
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.
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 createbut routes through the vendored Azure DevOps Go SDK.The REST surface is Work Items - Create (REST 7.1):
Body is a JSON Patch document (array of
{op, path, value}ops). At minimum oneaddop on/fields/System.Title. Optional query params:validateOnly,bypassRules,suppressNotifications,$expand. Response is the fullWorkItem(id,rev,fields,_links,url, optionalrelations,commentVersionRef).System.Descriptionis an HTML field — Markdown source is accepted and rendered on the web form. Mirrors the AzDO Extension'screate_work_item(azure-devops/azext_devops/dev/boards/work_item.py:31-103) and the AzDO MCP Server'screate_work_itemtool (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.
--assigned-topasses the user-provided value as a plain string into/fields/System.AssignedTo. Do not callresolveAssignedToFilter,extensions.Client.GetSelfID, or any identity client on the create path. Azure DevOps resolves the string server-side.resolveAssignedToFilteris for WIQLIN (...)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_idis 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.--fieldsparses asRef.Name=value, split on the first=only. Value is the raw string. Reject if no=is present.Ref.Namenotation is the standard Azure DevOps field reference format.--tagjoins all values with"; "into a single/fields/System.Tagsop (web-UI behaviour). Reuse the existingvalidateTagsfromlist.goafter exporting it. Empty/whitespace tags rejected.security/group/update/update.go:103-124is the template.resolveAssignedToFilterfromlist.go. ForvalidateTags, do not duplicate it — export it by creatinginternal/cmd/boards/workitem/shared/tags.gowithfunc ValidateTags(flag string, values []string) errorand call from bothlist.goandcreate.go. Updatelist.go's call sites in the same patch.WorkItemtoopts.exporter.Write. Do not introduce a view struct.list.go's JSON output. Eliminates a decision point.--bypass-rulesor--suppress-notificationsis set.delete.--typeexistence pre-check. Defer to the REST 4xx response and surface it viautil.FlagErrorWrap.AddCommentnegative assertion is mandatory.TestRunCreate_DiscussionTriggersAddCommentmust include a literalwit.EXPECT().AddComment(gomock.Any(), gomock.Any()).Times(0)when--discussionis not set.Times(0), gomock silently allows the call. The test would pass vacuously.--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 newazdocapability.kubectl editandgit commitconventions.--description-file <path>is repeatable. Multiple invocations concatenate with\n. The special token-reads fromos.Stdin.git add -andkubectl apply -f -patterns.--description-editoropens$VISUAL(preferred) or$EDITOR, falling back tovion POSIX /notepadon Windows. A.mdtemp 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.git commitandkubectl edit(user can leave themselves notes that get stripped on read-back). Abort-by-deleting-all-content matchesgit rebase -i.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-levelvar execEditorCommand = exec.Commandenables tests to inject a fake editor.createandupdate(#270) need the same logic. Centralizing it eliminates duplication and ensures both commands behave identically. TheexecEditorCommandvar mirrors the standard Go pattern for testableos/execcalls.azdoCLI passes the description text through to the server's/fields/System.Descriptionop unchanged. The Azure DevOps server renders Markdown.encodeFormattedValue(work-items.ts:521-558) is only relevant when the field explicitly takes aformatparameter; for the WorkItem create/update endpoints, the server renders Markdown automatically. The Pythonaz boards work-item createlikewise passes the description string as-is.Command Signature
c,crutil.ParseProjectScope(ctx, scopeArg)(defined ininternal/cmd/util/scope.go:78-108); errors wrapped withutil.FlagErrorWrap.Flags (mapped to SDK/REST)
--type(required)CreateWorkItemArgs.TypeBug,Task,User Story--title(required)/fields/System.Title--description/fields/System.Description--description-fileand--description-editor(Decision 14).--description-file(repeatable)/fields/System.Description<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$VISUAL/$EDITOR(fallbackvi/notepad) with a.mdtemp file. Pre-populated with a header comment. Lines starting with#are stripped. Highest-priority (Decision 14).--assigned-to/fields/System.AssignedTo--area/fields/System.AreaPath--iteration/fields/System.IterationPath--tag(repeatable)/fields/System.Tags"; "(Decision 3)--priority/fields/Microsoft.VSTS.Common.PriorityFlagErrorfout of range--severity/fields/Microsoft.VSTS.Common.Severity1 - Critical..4 - Low--parent(int)/fields/System.Parent--reason/fields/System.Reason--state/fields/System.State--fields(repeatableRef.Name=value)/fields/Ref.Name=(Decision 2)--link(repeatablerel,url)/relations/-relandurlset, noattributes--bypass-rulesCreateWorkItemArgs.BypassRules--suppress-notificationsCreateWorkItemArgs.SuppressNotifications--validate-onlyCreateWorkItemArgs.ValidateOnly--expand(enum)CreateWorkItemArgs.ExpandNone/Relations/Fields/Links/All--openaz--discussionwit.AddCommentJSON Output Contract
Pass the raw SDK
WorkItem(no view struct — Decision 7).Table default columns:
ID, TYPE, STATE, TITLE, ASSIGNED TO, AREA, ITERATION(same aslist).Command Wiring
internal/cmd/boards/workitem/createinternal/cmd/boards/workitem/create/create.gowithNewCmd(ctx util.CmdContext) *cobra.Commandinternal/cmd/boards/workitem/workitem.goto addcmd.AddCommand(create.NewCmd(ctx))and extend theExampleblock.internal/cmd/boards/workitem/shared/tags.goexposingValidateTags(Decision 6) andinternal/cmd/boards/workitem/shared/description.goexposingResolveDescriptionandDescriptionOptions(Decision 15).boards->work-item->create.Code Skeleton (canonical, copy verbatim)
§1.
createOptionsstruct and canonical patch-doc orderThe patch doc must append ops in this exact order. Tests assert order:
/fields/System.Title/fields/System.Description(only ifResolveDescriptionreturns non-empty)/fields/System.AssignedTo/fields/System.AreaPath/fields/System.IterationPath/fields/System.Tags(single op, joined with";)/fields/Microsoft.VSTS.Common.Priority/fields/Microsoft.VSTS.Common.Severity/fields/System.Parent/fields/System.Reason/fields/System.State/fields/ops from--fields(in user-given order)/relations/-ops from--link(in user-given order)§2. Patch-doc construction (4-line pattern, copy from
security/group/update/update.go:103-124)§3.
runCreatesignature§4.
NewCmdshape (no surprises)§5.
runCreateskeletonfieldStringandfieldIdentityDisplayalready exist inlist.go. Promote them tointernal/cmd/boards/workitem/shared/fields.goand reuse from both commands.§6.
shared/description.gohelper (Decision 15)API Surface
Reuse the already-vendored client. No new SDK clients required.
workitemtracking.Client.CreateWorkItem-> Work Items - Create (REST 7.1)workitemtracking.Client.AddComment(only when--discussionis set) -> Comments - Create (REST 7.1)Mock for
CreateWorkItemis already generated atinternal/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, andgolang-stretchr-testifyskills 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 frominternal/cmd/boards/workitem/list/list_test.go:765-844. Addcreate_test.gowith the following table-driven / behaviour tests, all usingt.Parallel()andgomock(requirefor preconditions,assertfor verifications):TestNewCmd_RegistersAsCreateLeaf- assertscmd.Name() == "create",cmd.Aliasescontainscandcr,cmd.Usestarts withcreate [ORGANIZATION/]PROJECT. Usecmd.SetArgs+cmd.Executepattern fromgolang-spf13-cobra::testing.TestRunCreate_RequiredFlagsMissing- runs the command with no flags; assertsFlagErrorcontaining--typeand--title(cobra'sMarkFlagRequiredalready covers this; test guards against regression).TestRunCreate_MinimalTitleAndType- stubswit.EXPECT().CreateWorkItem(gomock.Any(), gomock.Any()).DoAndReturn(captureArgs), asserts the captured*args.Documentcontains exactly oneaddop on/fields/System.Title; asserts return table has the expectedID/TYPE/STATE/TITLErow.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. Useassert.Equalon the slice after dereferencing*args.Document.TestRunCreate_CustomFields- asserts--fields Ref.Name=valueproduces raw/fields/ops in user-given order at positions 12+.TestRunCreate_Links- asserts--link rel,urlproduces/relations/-ops at positions 13+, each withOp == Add,Path == "/relations/-",Valuecontainingrelandurl.TestRunCreate_DiscussionTriggersAddComment- literal gomock form:TestRunCreate_ProjectScopeParsing- table-driven:[ORG/]PROJECT,PROJECT, and empty-scope error, viautil.ParseProjectScope.TestRunCreate_InvalidProjectScope- assertsutil.FlagErrorWrapis returned.TestRunCreate_JSONOutput- callsopts.exporter.Write; assert the resulting JSON contains the expectedid,rev,fields.System.Title,urlkeys.TestRunCreate_BypassRulesAndSuppressNotifications- assertsCreateWorkItemArgs.BypassRulesandSuppressNotificationsare propagated.TestRunCreate_ValidateOnly- assertsCreateWorkItemArgs.ValidateOnlyistrueand the response is still rendered.TestRunCreate_TagsValidation- assertsshared.ValidateTagsrejects empty/whitespace values and accepts multi-tag input.TestRunCreate_FieldsParseSplitOnFirstEquals- asserts--fields "Foo.Bar=key=value"parses asref="Foo.Bar",value="key=value"(split on first=).TestRunCreate_LinkParseSplitOnFirstComma- asserts--link "rel,url,extra"parses asrel="rel",url="url,extra"(split on first,).TestRunCreate_OpenBrowserFlag- asserts--opendoes not affect the create call and is a no-op when the response has nourl(table path covers this;--openis best-effort and not unit-testable without DI).Description tests (new — cover Decision 11-16):
TestRunCreate_DescriptionFromInline---description "text". Capturedargs.Documenthas/fields/System.Descriptionop 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 -withios.Inset viaiostreams.System().SetIn(strings.NewReader("stdin content")). Captured op has"stdin content".TestRunCreate_DescriptionFromMultipleFiles_Concatenated---description-file a.md --description-file b.md. Captured op hasa + "\n" + b.TestRunCreate_DescriptionFileNotFound---description-file /nonexistent. Returnsutil.FlagErrorfwith the path. SDK is not called.TestRunCreate_DescriptionFileTooLarge- temp file > 1 MB. Returnsutil.FlagErrorfwith "exceeds 1 MB".TestRunCreate_DescriptionFileBinary- temp file with null byte in first 8KB. Returnsutil.FlagErrorfwith "appears to be binary".TestRunCreate_DescriptionFileNotUTF8- temp file with invalid UTF-8 sequence. Returnsutil.FlagErrorfwith "not valid UTF-8".TestRunCreate_DescriptionEditor- injectexecEditorCommand = fakeEditor("written by editor"). Captured op has"written by editor".TestRunCreate_DescriptionEditorStripsCommentLines- injectexecEditorCommand = fakeEditor("# comment\n# also comment\n# my notes\nactual content\n"). Captured op has"actual content".TestRunCreate_DescriptionEditorEmptyAborts- injectexecEditorCommand = fakeEditor(""). Returnsutil.FlagErrorfwith "editor produced empty description".TestRunCreate_DescriptionEditorNonZeroExit- injectexecEditorCommandthat returns exit code 1. Returns wrapped error.TestRunCreate_DescriptionEditorEnvFallsBack- test sets$VISUAL=,$EDITOR=; helper falls back tovi(POSIX) /notepad(Windows). Asserted via spy.TestRunCreate_DescriptionPrecedenceEditorOverFile- both--description-editorand--description-fileset. Editor wins; warning to stderr contains "takes precedence over --description-file".TestRunCreate_DescriptionPrecedenceEditorOverInline- both--description-editorand--descriptionset. Editor wins; warning contains "takes precedence over --description".TestRunCreate_DescriptionPrecedenceFileOverInline- both--description-fileand--descriptionset. File wins; warning contains "takes precedence over --description".TestRunCreate_DescriptionAbsent_OmitsPatchOp- no description flags set. Capturedargs.Documenthas no/fields/System.Descriptionop.Phase 2 - GREEN (minimal implementation). Strict reuse rules:
shared/tags.go,shared/fields.go,shared/description.go). Inline everything else.util.ParseProjectScope,util.AddJSONFlags,util.FlagErrorf/FlagErrorWrap,util.ExactArgs,types.GetValue,types.ToPtr,ctx.Printer("list"),ios.StartProgressIndicator/StopProgressIndicator,iostreams.Testas-is.shared.ValidateTags(promoted fromlist.go) andshared.FieldString/shared.FieldIdentityDisplay(promoted fromlist.go).shared.ResolveDescription(new) for all description source resolution.webapi.JsonPatchOperationappend pattern frominternal/cmd/security/group/update/update.go:103-124. Do not introduce a generic patch-doc builder.ios.StartProgressIndicator()+defer ios.StopProgressIndicator(); callios.StopProgressIndicator()immediately before any user-visible print (mirrorsinternal/cmd/repo/create/create.go:62-65).opts.exporter.Write(ios, res)passing the raw SDKWorkItem; table viactx.Printer("list")withAddColumns/AddField/EndRow/Render.--bypass-rulesor--suppress-notificationsis 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, parentworkitem.go+3 LOC,docs/boards_work-item_create.mdregenerated viamake docs.list.goandlist_test.goupdated to call the shared helpers (mechanical find/replace).Tooling and Verification Checklist
gofmt/gofumpton touched filesgo test ./internal/cmd/boards/workitem/...go test ./...make lintmake docsReference Existing Patterns
internal/cmd/boards/workitem/list/list.go- project-scope parsing, JSON/table split, progress indicatorinternal/cmd/boards/workitem/list/list_test.go:765-844-setupFakeDeps/stub*fixture; copy structureinternal/cmd/repo/create/create.go- progress lifecycle and[ORG/]PROJECT[/NAME]parsinginternal/cmd/security/group/update/update.go:103-124- existing JSON Patch document constructioninternal/cmd/boards/workitem/shared/description.go(new, this issue) -ResolveDescription,ReadDescriptionFiles,OpenEditorhelpers. Reused by feat: Implementazdo boards work-item updatecommand #270 (update) unchanged.internal/mocks/workitemtracking_client_mock.go:166-180- mock forCreateWorkIteminternal/azdo/factory.go:149-ClientFactory().WorkItemTracking(...)accessorReference implementations (for UX parity only —
azdois the implementation target):azure-devops/azext_devops/dev/boards/work_item.py:31-103-create_work_item(work_item_type, title, description, ...)confirms thedescriptionparameter is a plain string. The AzDO Extension does not have editor or file-import support; this is new inazdo.microsoft/azure-devops-mcp/src/tools/work-items.ts:521-558-create_work_itemMCP tool withformat: "Html" | "Markdown"enum. Confirms the server'sSystem.Descriptionfield accepts Markdown directly; no client-side conversion is needed.References