azdo-cli 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +100 -1
  2. package/dist/index.js +469 -94
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -12,6 +12,7 @@ Azure DevOps CLI focused on work item read/write workflows.
12
12
  - Update work item state (`set-state`)
13
13
  - Assign and unassign work items (`assign`)
14
14
  - Set any work item field by reference name (`set-field`)
15
+ - Create or update Tasks from markdown documents (`upsert`)
15
16
  - Read rich-text fields as markdown (`get-md-field`)
16
17
  - Set rich-text fields as markdown from inline text, file, or stdin (`set-md-field`)
17
18
  - Persist org/project/default fields in local config (`config`)
@@ -47,16 +48,20 @@ azdo get-item 12345
47
48
 
48
49
  # 3) Update state
49
50
  azdo set-state 12345 "Active"
51
+
52
+ # 4) Create or update a Task from markdown
53
+ azdo upsert --content $'---\nTitle: Improve markdown import UX\nState: New\n---'
50
54
  ```
51
55
 
52
56
  ## Command Cheat Sheet
53
57
 
54
58
  | Command | Purpose | Common Flags |
55
59
  | --- | --- | --- |
56
- | `azdo get-item <id>` | Read a work item | `--short`, `--fields`, `--org`, `--project` |
60
+ | `azdo get-item <id>` | Read a work item | `--short`, `--fields`, `--markdown`, `--org`, `--project` |
57
61
  | `azdo set-state <id> <state>` | Change work item state | `--json`, `--org`, `--project` |
58
62
  | `azdo assign <id> [name]` | Assign or unassign owner | `--unassign`, `--json`, `--org`, `--project` |
59
63
  | `azdo set-field <id> <field> <value>` | Update any field | `--json`, `--org`, `--project` |
64
+ | `azdo upsert [id]` | Create or update a Task from markdown | `--content`, `--file`, `--json`, `--org`, `--project` |
60
65
  | `azdo get-md-field <id> <field>` | Get field as markdown | `--org`, `--project` |
61
66
  | `azdo set-md-field <id> <field> [content]` | Set markdown field | `--file`, `--json`, `--org`, `--project` |
62
67
  | `azdo config <subcommand>` | Manage saved settings | `set`, `get`, `list`, `unset`, `wizard`, `--json` |
@@ -75,6 +80,12 @@ azdo get-item 12345 --short
75
80
 
76
81
  # Include extra fields for this call
77
82
  azdo get-item 12345 --fields "System.Tags,Microsoft.VSTS.Common.Priority"
83
+
84
+ # Convert rich text fields to markdown
85
+ azdo get-item 12345 --markdown
86
+
87
+ # Disable markdown even if config is on
88
+ azdo get-item 12345 --no-markdown
78
89
  ```
79
90
 
80
91
  ```bash
@@ -89,6 +100,14 @@ azdo assign 12345 --unassign
89
100
  azdo set-field 12345 System.Title "Updated title"
90
101
  ```
91
102
 
103
+ ### Markdown Display
104
+
105
+ The `get-item` command can convert HTML rich-text fields to readable markdown. Resolution order:
106
+
107
+ 1. `--markdown` / `--no-markdown` flag (highest priority)
108
+ 2. Config setting: `azdo config set markdown true`
109
+ 3. Default: off (HTML stripped to plain text)
110
+
92
111
  ### Markdown Field Commands
93
112
 
94
113
  ```bash
@@ -105,6 +124,83 @@ azdo set-md-field 12345 System.Description --file ./description.md
105
124
  cat description.md | azdo set-md-field 12345 System.Description
106
125
  ```
107
126
 
127
+ ## azdo upsert
128
+
129
+ `azdo upsert` accepts a single markdown task document and either creates a new Azure DevOps Task or updates an existing one. Omit `[id]` to create; pass `[id]` to update that work item in place.
130
+
131
+ ```bash
132
+ # Create from inline content
133
+ azdo upsert --content $'---\nTitle: Improve markdown import UX\nAssigned To: user@example.com\nState: New\n---'
134
+
135
+ # Update from a file
136
+ azdo upsert 12345 --file ./task-import.md
137
+
138
+ # JSON output
139
+ azdo upsert 12345 --content $'---\nSystem.Title: Improve markdown import UX\n---' --json
140
+ ```
141
+
142
+ The command requires exactly one source flag:
143
+
144
+ - `azdo upsert [id] --content <markdown>`
145
+ - `azdo upsert [id] --file <path>`
146
+
147
+ If `--file` succeeds, the source file is deleted after the Azure DevOps write completes. If parsing, validation, or the API call fails, the file is preserved. If deletion fails after a successful write, the command still succeeds and prints a warning.
148
+
149
+ ### Task Document Format
150
+
151
+ The document starts with YAML front matter for scalar fields, followed by optional `##` heading sections for markdown rich-text fields.
152
+
153
+ ```md
154
+ ---
155
+ Title: Improve markdown import UX
156
+ Assigned To: user@example.com
157
+ State: New
158
+ Tags: cli; markdown
159
+ Priority: null
160
+ ---
161
+
162
+ ## Description
163
+
164
+ Implement a single-command task import flow.
165
+
166
+ ## Acceptance Criteria
167
+
168
+ - Supports create when no ID is passed
169
+ - Supports update when an ID is passed
170
+ - Deletes imported files only after success
171
+ ```
172
+
173
+ Supported friendly field names:
174
+
175
+ - `Title`
176
+ - `Assigned To` / `assignedTo`
177
+ - `State`
178
+ - `Description`
179
+ - `Acceptance Criteria` / `acceptanceCriteria`
180
+ - `Tags`
181
+ - `Priority`
182
+
183
+ Raw Azure DevOps reference names are also accepted anywhere a field name is expected, for example `System.Title` or `Microsoft.VSTS.Common.AcceptanceCriteria`.
184
+
185
+ Clear semantics:
186
+
187
+ - Scalar YAML fields with `null` or an empty value are treated as clears on update.
188
+ - Rich-text heading sections with an empty body are treated as clears on update.
189
+ - Omitted fields are untouched on update.
190
+
191
+ `--json` output shape:
192
+
193
+ ```json
194
+ {
195
+ "action": "created",
196
+ "id": 12345,
197
+ "fields": {
198
+ "System.Title": "Improve markdown import UX",
199
+ "System.Description": "Implement a single-command task import flow."
200
+ }
201
+ }
202
+ ```
203
+
108
204
  ### Configuration
109
205
 
110
206
  ```bash
@@ -114,6 +210,9 @@ azdo config list
114
210
  # Interactive setup
115
211
  azdo config wizard
116
212
 
213
+ # Enable markdown display for all get-item calls
214
+ azdo config set markdown true
215
+
117
216
  # Set/get/unset values
118
217
  azdo config set fields "System.Tags,Custom.Priority"
119
218
  azdo config get fields
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command9 } from "commander";
4
+ import { Command as Command10 } from "commander";
5
5
 
6
6
  // src/version.ts
7
7
  import { readFileSync } from "fs";
@@ -65,6 +65,27 @@ function buildExtraFields(fields, requested) {
65
65
  }
66
66
  return Object.keys(result).length > 0 ? result : null;
67
67
  }
68
+ function writeHeaders(pat) {
69
+ return {
70
+ ...authHeaders(pat),
71
+ "Content-Type": "application/json-patch+json"
72
+ };
73
+ }
74
+ async function readWriteResponse(response, errorCode) {
75
+ if (response.status === 400) {
76
+ const serverMessage = await readResponseMessage(response) ?? "Unknown error";
77
+ throw new Error(`${errorCode}: ${serverMessage}`);
78
+ }
79
+ if (!response.ok) {
80
+ throw new Error(`HTTP_${response.status}`);
81
+ }
82
+ const data = await response.json();
83
+ return {
84
+ id: data.id,
85
+ rev: data.rev,
86
+ fields: data.fields
87
+ };
88
+ }
68
89
  async function getWorkItem(context, id, pat, extraFields) {
69
90
  const url = new URL(
70
91
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
@@ -140,35 +161,41 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
140
161
  return typeof value === "object" ? JSON.stringify(value) : `${value}`;
141
162
  }
142
163
  async function updateWorkItem(context, id, pat, fieldName, operations) {
164
+ const result = await applyWorkItemPatch(context, id, pat, operations);
165
+ const title = result.fields["System.Title"];
166
+ const lastOp = operations[operations.length - 1];
167
+ const fieldValue = lastOp.value ?? null;
168
+ return {
169
+ id: result.id,
170
+ rev: result.rev,
171
+ title: typeof title === "string" ? title : "",
172
+ fieldName,
173
+ fieldValue
174
+ };
175
+ }
176
+ async function createWorkItem(context, workItemType, pat, operations) {
177
+ const url = new URL(
178
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
179
+ );
180
+ url.searchParams.set("api-version", "7.1");
181
+ const response = await fetchWithErrors(url.toString(), {
182
+ method: "POST",
183
+ headers: writeHeaders(pat),
184
+ body: JSON.stringify(operations)
185
+ });
186
+ return readWriteResponse(response, "CREATE_REJECTED");
187
+ }
188
+ async function applyWorkItemPatch(context, id, pat, operations) {
143
189
  const url = new URL(
144
190
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
145
191
  );
146
192
  url.searchParams.set("api-version", "7.1");
147
193
  const response = await fetchWithErrors(url.toString(), {
148
194
  method: "PATCH",
149
- headers: {
150
- ...authHeaders(pat),
151
- "Content-Type": "application/json-patch+json"
152
- },
195
+ headers: writeHeaders(pat),
153
196
  body: JSON.stringify(operations)
154
197
  });
155
- if (response.status === 400) {
156
- const serverMessage = await readResponseMessage(response) ?? "Unknown error";
157
- throw new Error(`UPDATE_REJECTED: ${serverMessage}`);
158
- }
159
- if (!response.ok) {
160
- throw new Error(`HTTP_${response.status}`);
161
- }
162
- const data = await response.json();
163
- const lastOp = operations[operations.length - 1];
164
- const fieldValue = lastOp.value ?? null;
165
- return {
166
- id: data.id,
167
- rev: data.rev,
168
- title: data.fields["System.Title"],
169
- fieldName,
170
- fieldValue
171
- };
198
+ return readWriteResponse(response, "UPDATE_REJECTED");
172
199
  }
173
200
 
174
201
  // src/services/auth.ts
@@ -336,6 +363,13 @@ var SETTINGS = [
336
363
  type: "string[]",
337
364
  example: "System.Tags,Custom.Priority",
338
365
  required: false
366
+ },
367
+ {
368
+ key: "markdown",
369
+ description: "Convert rich text fields to markdown on display",
370
+ type: "boolean",
371
+ example: "true",
372
+ required: false
339
373
  }
340
374
  ];
341
375
  var VALID_KEYS = SETTINGS.map((s) => s.key);
@@ -369,7 +403,7 @@ function saveConfig(config) {
369
403
  }
370
404
  function validateKey(key) {
371
405
  if (!VALID_KEYS.includes(key)) {
372
- throw new Error(`Unknown setting key "${key}". Valid keys: org, project, fields`);
406
+ throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
373
407
  }
374
408
  }
375
409
  function getConfigValue(key) {
@@ -382,6 +416,11 @@ function setConfigValue(key, value) {
382
416
  const config = loadConfig();
383
417
  if (value === "") {
384
418
  delete config[key];
419
+ } else if (key === "markdown") {
420
+ if (value !== "true" && value !== "false") {
421
+ throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
422
+ }
423
+ config.markdown = value === "true";
385
424
  } else if (key === "fields") {
386
425
  config.fields = value.split(",").map((s) => s.trim());
387
426
  } else {
@@ -420,6 +459,61 @@ function resolveContext(options) {
420
459
  );
421
460
  }
422
461
 
462
+ // src/services/md-convert.ts
463
+ import { NodeHtmlMarkdown } from "node-html-markdown";
464
+
465
+ // src/services/html-detect.ts
466
+ var HTML_TAG_REGEX = /<\/?([a-z][a-z0-9]*)\b/gi;
467
+ var HTML_TAGS = /* @__PURE__ */ new Set([
468
+ "p",
469
+ "br",
470
+ "div",
471
+ "span",
472
+ "strong",
473
+ "em",
474
+ "b",
475
+ "i",
476
+ "u",
477
+ "a",
478
+ "ul",
479
+ "ol",
480
+ "li",
481
+ "h1",
482
+ "h2",
483
+ "h3",
484
+ "h4",
485
+ "h5",
486
+ "h6",
487
+ "table",
488
+ "tr",
489
+ "td",
490
+ "th",
491
+ "img",
492
+ "pre",
493
+ "code"
494
+ ]);
495
+ function isHtml(content) {
496
+ let match;
497
+ HTML_TAG_REGEX.lastIndex = 0;
498
+ while ((match = HTML_TAG_REGEX.exec(content)) !== null) {
499
+ if (HTML_TAGS.has(match[1].toLowerCase())) {
500
+ return true;
501
+ }
502
+ }
503
+ return false;
504
+ }
505
+
506
+ // src/services/md-convert.ts
507
+ function htmlToMarkdown(html) {
508
+ return NodeHtmlMarkdown.translate(html);
509
+ }
510
+ function toMarkdown(content) {
511
+ if (isHtml(content)) {
512
+ return htmlToMarkdown(content);
513
+ }
514
+ return content;
515
+ }
516
+
423
517
  // src/services/command-helpers.ts
424
518
  function parseWorkItemId(idStr) {
425
519
  const id = Number.parseInt(idStr, 10);
@@ -442,6 +536,24 @@ function validateOrgProjectPair(options) {
442
536
  process.exit(1);
443
537
  }
444
538
  }
539
+ function validateSource(options) {
540
+ const hasContent = options.content !== void 0;
541
+ const hasFile = options.file !== void 0;
542
+ if (hasContent === hasFile) {
543
+ process.stderr.write("Error: provide exactly one of --content or --file\n");
544
+ process.exit(1);
545
+ }
546
+ }
547
+ function formatCreateError(err) {
548
+ const error = err instanceof Error ? err : new Error(String(err));
549
+ const message = error.message.startsWith("CREATE_REJECTED:") ? error.message.replace("CREATE_REJECTED:", "").trim() : error.message;
550
+ const requiredMatches = [...message.matchAll(/field ['"]([^'"]+)['"]/gi)];
551
+ if (requiredMatches.length > 0) {
552
+ const fields = Array.from(new Set(requiredMatches.map((match) => match[1])));
553
+ return `Create rejected: ${message} (fields: ${fields.join(", ")})`;
554
+ }
555
+ return `Create rejected: ${message}`;
556
+ }
445
557
  function handleCommandError(err, id, context, scope = "write", exit = true) {
446
558
  const error = err instanceof Error ? err : new Error(String(err));
447
559
  const msg = error.message;
@@ -468,6 +580,9 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
468
580
  } else if (msg.startsWith("BAD_REQUEST:")) {
469
581
  const serverMsg = msg.replace("BAD_REQUEST: ", "");
470
582
  process.stderr.write(`Error: Request rejected: ${serverMsg}
583
+ `);
584
+ } else if (msg.startsWith("CREATE_REJECTED:")) {
585
+ process.stderr.write(`Error: ${formatCreateError(error)}
471
586
  `);
472
587
  } else if (msg.startsWith("UPDATE_REJECTED:")) {
473
588
  const serverMsg = msg.replace("UPDATE_REJECTED: ", "");
@@ -508,7 +623,24 @@ function stripHtml(html) {
508
623
  text = text.replace(/\n{3,}/g, "\n\n");
509
624
  return text.trim();
510
625
  }
511
- function formatWorkItem(workItem, short) {
626
+ function convertRichText(html, markdown) {
627
+ if (!html) return "";
628
+ return markdown ? toMarkdown(html) : stripHtml(html);
629
+ }
630
+ function formatExtraFields(extraFields, markdown) {
631
+ return Object.entries(extraFields).map(([refName, value]) => {
632
+ const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
633
+ const displayValue = markdown ? toMarkdown(value) : value;
634
+ return `${fieldLabel.padEnd(13)}${displayValue}`;
635
+ });
636
+ }
637
+ function summarizeDescription(text, label) {
638
+ const descLines = text.split("\n").filter((l) => l.trim() !== "");
639
+ const firstThree = descLines.slice(0, 3);
640
+ const suffix = descLines.length > 3 ? "\n..." : "";
641
+ return [`${label("Description:")}${firstThree.join("\n")}${suffix}`];
642
+ }
643
+ function formatWorkItem(workItem, short, markdown = false) {
512
644
  const lines = [];
513
645
  const label = (name) => name.padEnd(13);
514
646
  lines.push(`${label("ID:")}${workItem.id}`);
@@ -522,19 +654,12 @@ function formatWorkItem(workItem, short) {
522
654
  }
523
655
  lines.push(`${label("URL:")}${workItem.url}`);
524
656
  if (workItem.extraFields) {
525
- for (const [refName, value] of Object.entries(workItem.extraFields)) {
526
- const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
527
- lines.push(`${fieldLabel.padEnd(13)}${value}`);
528
- }
657
+ lines.push(...formatExtraFields(workItem.extraFields, markdown));
529
658
  }
530
659
  lines.push("");
531
- const descriptionText = workItem.description ? stripHtml(workItem.description) : "";
660
+ const descriptionText = convertRichText(workItem.description, markdown);
532
661
  if (short) {
533
- const descLines = descriptionText.split("\n").filter((l) => l.trim() !== "");
534
- const firstThree = descLines.slice(0, 3);
535
- const truncated = descLines.length > 3;
536
- const descSummary = firstThree.join("\n") + (truncated ? "\n..." : "");
537
- lines.push(`${label("Description:")}${descSummary}`);
662
+ lines.push(...summarizeDescription(descriptionText, label));
538
663
  } else {
539
664
  lines.push("Description:");
540
665
  lines.push(descriptionText);
@@ -543,7 +668,7 @@ function formatWorkItem(workItem, short) {
543
668
  }
544
669
  function createGetItemCommand() {
545
670
  const command = new Command("get-item");
546
- command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").action(
671
+ command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown").action(
547
672
  async (idStr, options) => {
548
673
  const id = parseWorkItemId(idStr);
549
674
  validateOrgProjectPair(options);
@@ -553,7 +678,8 @@ function createGetItemCommand() {
553
678
  const credential = await resolvePat();
554
679
  const fieldsList = options.fields !== void 0 ? parseRequestedFields(options.fields) : parseRequestedFields(loadConfig().fields);
555
680
  const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
556
- const output = formatWorkItem(workItem, options.short ?? false);
681
+ const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
682
+ const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
557
683
  process.stdout.write(output + "\n");
558
684
  } catch (err) {
559
685
  handleCommandError(err, id, context, "read", false);
@@ -857,63 +983,6 @@ function createSetFieldCommand() {
857
983
 
858
984
  // src/commands/get-md-field.ts
859
985
  import { Command as Command7 } from "commander";
860
-
861
- // src/services/md-convert.ts
862
- import { NodeHtmlMarkdown } from "node-html-markdown";
863
-
864
- // src/services/html-detect.ts
865
- var HTML_TAG_REGEX = /<\/?([a-z][a-z0-9]*)\b/gi;
866
- var HTML_TAGS = /* @__PURE__ */ new Set([
867
- "p",
868
- "br",
869
- "div",
870
- "span",
871
- "strong",
872
- "em",
873
- "b",
874
- "i",
875
- "u",
876
- "a",
877
- "ul",
878
- "ol",
879
- "li",
880
- "h1",
881
- "h2",
882
- "h3",
883
- "h4",
884
- "h5",
885
- "h6",
886
- "table",
887
- "tr",
888
- "td",
889
- "th",
890
- "img",
891
- "pre",
892
- "code"
893
- ]);
894
- function isHtml(content) {
895
- let match;
896
- HTML_TAG_REGEX.lastIndex = 0;
897
- while ((match = HTML_TAG_REGEX.exec(content)) !== null) {
898
- if (HTML_TAGS.has(match[1].toLowerCase())) {
899
- return true;
900
- }
901
- }
902
- return false;
903
- }
904
-
905
- // src/services/md-convert.ts
906
- function htmlToMarkdown(html) {
907
- return NodeHtmlMarkdown.translate(html);
908
- }
909
- function toMarkdown(content) {
910
- if (isHtml(content)) {
911
- return htmlToMarkdown(content);
912
- }
913
- return content;
914
- }
915
-
916
- // src/commands/get-md-field.ts
917
986
  function createGetMdFieldCommand() {
918
987
  const command = new Command7("get-md-field");
919
988
  command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
@@ -1026,8 +1095,313 @@ function createSetMdFieldCommand() {
1026
1095
  return command;
1027
1096
  }
1028
1097
 
1098
+ // src/commands/upsert.ts
1099
+ import { existsSync as existsSync2, readFileSync as readFileSync3, unlinkSync } from "fs";
1100
+ import { Command as Command9 } from "commander";
1101
+
1102
+ // src/services/task-document.ts
1103
+ var FIELD_ALIASES = /* @__PURE__ */ new Map([
1104
+ ["title", "System.Title"],
1105
+ ["assignedto", "System.AssignedTo"],
1106
+ ["assigned to", "System.AssignedTo"],
1107
+ ["state", "System.State"],
1108
+ ["description", "System.Description"],
1109
+ ["acceptancecriteria", "Microsoft.VSTS.Common.AcceptanceCriteria"],
1110
+ ["acceptance criteria", "Microsoft.VSTS.Common.AcceptanceCriteria"],
1111
+ ["tags", "System.Tags"],
1112
+ ["priority", "Microsoft.VSTS.Common.Priority"]
1113
+ ]);
1114
+ var RICH_TEXT_FIELDS = /* @__PURE__ */ new Set([
1115
+ "System.Description",
1116
+ "Microsoft.VSTS.Common.AcceptanceCriteria"
1117
+ ]);
1118
+ var REFERENCE_NAME_PATTERN = /^[A-Z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$/;
1119
+ function normalizeAlias(name) {
1120
+ return name.trim().replaceAll(/\s+/g, " ").toLowerCase();
1121
+ }
1122
+ function parseScalarValue(rawValue, fieldName) {
1123
+ if (rawValue === void 0) {
1124
+ throw new Error(`Malformed YAML front matter: missing value for "${fieldName}"`);
1125
+ }
1126
+ const trimmed = rawValue.trim();
1127
+ if (trimmed === "" || trimmed === "null" || trimmed === "~") {
1128
+ return null;
1129
+ }
1130
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
1131
+ return trimmed.slice(1, -1);
1132
+ }
1133
+ if (/^[[{]|^[>|]-?$/.test(trimmed)) {
1134
+ throw new Error(`Malformed YAML front matter: unsupported value for "${fieldName}"`);
1135
+ }
1136
+ return trimmed;
1137
+ }
1138
+ function parseFrontMatter(content) {
1139
+ if (!content.startsWith("---")) {
1140
+ return { frontMatter: "", remainder: content };
1141
+ }
1142
+ const frontMatterPattern = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
1143
+ const match = frontMatterPattern.exec(content);
1144
+ if (!match) {
1145
+ throw new Error('Malformed YAML front matter: missing closing "---"');
1146
+ }
1147
+ return {
1148
+ frontMatter: match[1],
1149
+ remainder: content.slice(match[0].length)
1150
+ };
1151
+ }
1152
+ function assertKnownField(name, kind) {
1153
+ const resolved = resolveFieldName(name);
1154
+ if (!resolved) {
1155
+ const prefix = kind === "rich-text" ? "Unknown rich-text field" : "Unknown field";
1156
+ throw new Error(`${prefix}: ${name}`);
1157
+ }
1158
+ if (kind === "rich-text" && !RICH_TEXT_FIELDS.has(resolved)) {
1159
+ throw new Error(`Unknown rich-text field: ${name}`);
1160
+ }
1161
+ return resolved;
1162
+ }
1163
+ function pushField(fields, seen, refName, value, kind) {
1164
+ if (seen.has(refName)) {
1165
+ throw new Error(`Duplicate field: ${refName}`);
1166
+ }
1167
+ seen.add(refName);
1168
+ fields.push({
1169
+ refName,
1170
+ value,
1171
+ op: value === null ? "clear" : "set",
1172
+ kind
1173
+ });
1174
+ }
1175
+ function parseScalarFields(frontMatter, fields, seen) {
1176
+ if (frontMatter.trim() === "") {
1177
+ return;
1178
+ }
1179
+ for (const rawLine of frontMatter.split(/\r?\n/)) {
1180
+ const line = rawLine.trim();
1181
+ if (line === "") {
1182
+ continue;
1183
+ }
1184
+ const separatorIndex = rawLine.indexOf(":");
1185
+ if (separatorIndex <= 0) {
1186
+ throw new Error(`Malformed YAML front matter: ${rawLine.trim()}`);
1187
+ }
1188
+ const rawName = rawLine.slice(0, separatorIndex).trim();
1189
+ const rawValue = rawLine.slice(separatorIndex + 1);
1190
+ const refName = assertKnownField(rawName, "scalar");
1191
+ const value = parseScalarValue(rawValue, rawName);
1192
+ pushField(fields, seen, refName, value, "scalar");
1193
+ }
1194
+ }
1195
+ function parseRichTextSections(content, fields, seen) {
1196
+ const normalizedContent = content.replaceAll("\r\n", "\n");
1197
+ const lines = normalizedContent.split("\n");
1198
+ const headings = [];
1199
+ for (let index = 0; index < lines.length; index += 1) {
1200
+ const line = lines[index];
1201
+ if (!line.startsWith("##")) {
1202
+ continue;
1203
+ }
1204
+ const headingBody = line.slice(2);
1205
+ if (headingBody.trim() === "" || !headingBody.startsWith(" ") && !headingBody.startsWith(" ")) {
1206
+ continue;
1207
+ }
1208
+ headings.push({
1209
+ lineIndex: index,
1210
+ rawName: headingBody.trim()
1211
+ });
1212
+ }
1213
+ if (headings.length === 0) {
1214
+ return;
1215
+ }
1216
+ for (let index = 0; index < headings[0].lineIndex; index += 1) {
1217
+ if (lines[index].trim() !== "") {
1218
+ throw new Error("Unexpected content before the first markdown heading section");
1219
+ }
1220
+ }
1221
+ for (let index = 0; index < headings.length; index += 1) {
1222
+ const { lineIndex, rawName } = headings[index];
1223
+ const refName = assertKnownField(rawName, "rich-text");
1224
+ const bodyStart = lineIndex + 1;
1225
+ const bodyEnd = index + 1 < headings.length ? headings[index + 1].lineIndex : lines.length;
1226
+ const rawBody = lines.slice(bodyStart, bodyEnd).join("\n");
1227
+ const value = rawBody.trim() === "" ? null : rawBody.trimEnd();
1228
+ pushField(fields, seen, refName, value, "rich-text");
1229
+ }
1230
+ }
1231
+ function resolveFieldName(name) {
1232
+ const trimmed = name.trim();
1233
+ if (trimmed === "") {
1234
+ return null;
1235
+ }
1236
+ const alias = FIELD_ALIASES.get(normalizeAlias(trimmed));
1237
+ if (alias) {
1238
+ return alias;
1239
+ }
1240
+ return REFERENCE_NAME_PATTERN.test(trimmed) ? trimmed : null;
1241
+ }
1242
+ function parseTaskDocument(content) {
1243
+ const { frontMatter, remainder } = parseFrontMatter(content);
1244
+ const fields = [];
1245
+ const seen = /* @__PURE__ */ new Set();
1246
+ parseScalarFields(frontMatter, fields, seen);
1247
+ parseRichTextSections(remainder, fields, seen);
1248
+ return { fields };
1249
+ }
1250
+
1251
+ // src/commands/upsert.ts
1252
+ function fail2(message) {
1253
+ process.stderr.write(`Error: ${message}
1254
+ `);
1255
+ process.exit(1);
1256
+ }
1257
+ function loadSourceContent(options) {
1258
+ validateSource(options);
1259
+ if (options.content !== void 0) {
1260
+ return { content: options.content };
1261
+ }
1262
+ const filePath = options.file;
1263
+ if (!existsSync2(filePath)) {
1264
+ fail2(`File not found: ${filePath}`);
1265
+ }
1266
+ try {
1267
+ return {
1268
+ content: readFileSync3(filePath, "utf-8"),
1269
+ sourceFile: filePath
1270
+ };
1271
+ } catch {
1272
+ fail2(`Cannot read file: ${filePath}`);
1273
+ }
1274
+ }
1275
+ function toPatchOperations(fields, action) {
1276
+ const operations = [];
1277
+ for (const field of fields) {
1278
+ if (field.op === "clear") {
1279
+ if (action === "updated") {
1280
+ operations.push({ op: "remove", path: `/fields/${field.refName}` });
1281
+ }
1282
+ continue;
1283
+ }
1284
+ operations.push({ op: "add", path: `/fields/${field.refName}`, value: field.value ?? "" });
1285
+ if (field.kind === "rich-text") {
1286
+ operations.push({
1287
+ op: "add",
1288
+ path: `/multilineFieldsFormat/${field.refName}`,
1289
+ value: "Markdown"
1290
+ });
1291
+ }
1292
+ }
1293
+ return operations;
1294
+ }
1295
+ function buildAppliedFields(fields) {
1296
+ const applied = {};
1297
+ for (const field of fields) {
1298
+ applied[field.refName] = field.value;
1299
+ }
1300
+ return applied;
1301
+ }
1302
+ function ensureTitleForCreate(fields) {
1303
+ const titleField = fields.find((field) => field.refName === "System.Title");
1304
+ if (!titleField || titleField.op === "clear" || titleField.value === null || titleField.value.trim() === "") {
1305
+ fail2("Title is required when creating a task.");
1306
+ }
1307
+ }
1308
+ function writeSuccess(result, options) {
1309
+ if (options.json) {
1310
+ process.stdout.write(`${JSON.stringify(result)}
1311
+ `);
1312
+ return;
1313
+ }
1314
+ const verb = result.action === "created" ? "Created" : "Updated";
1315
+ const fields = Object.keys(result.fields).join(", ");
1316
+ const suffix = fields ? ` (${fields})` : "";
1317
+ process.stdout.write(`${verb} task #${result.id}${suffix}
1318
+ `);
1319
+ }
1320
+ function cleanupSourceFile(sourceFile) {
1321
+ if (!sourceFile) {
1322
+ return;
1323
+ }
1324
+ try {
1325
+ unlinkSync(sourceFile);
1326
+ } catch {
1327
+ process.stderr.write(`Warning: upsert succeeded but could not delete source file: ${sourceFile}
1328
+ `);
1329
+ }
1330
+ }
1331
+ function buildUpsertResult(action, writeResult, fields) {
1332
+ const appliedFields = buildAppliedFields(fields);
1333
+ return {
1334
+ action,
1335
+ id: writeResult.id,
1336
+ fields: appliedFields
1337
+ };
1338
+ }
1339
+ function isUpdateWriteError(err) {
1340
+ return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message === "NOT_FOUND" || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("UPDATE_REJECTED:");
1341
+ }
1342
+ function isCreateWriteError(err) {
1343
+ return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("HTTP_");
1344
+ }
1345
+ function handleUpsertError(err, id, context) {
1346
+ if (!(err instanceof Error)) {
1347
+ process.stderr.write(`Error: ${String(err)}
1348
+ `);
1349
+ process.exit(1);
1350
+ }
1351
+ if (id === void 0 && err.message.startsWith("CREATE_REJECTED:")) {
1352
+ process.stderr.write(`Error: ${formatCreateError(err)}
1353
+ `);
1354
+ process.exit(1);
1355
+ }
1356
+ if (id !== void 0 && isUpdateWriteError(err)) {
1357
+ handleCommandError(err, id, context, "write");
1358
+ return;
1359
+ }
1360
+ if (id === void 0 && isCreateWriteError(err)) {
1361
+ handleCommandError(err, 0, context, "write");
1362
+ return;
1363
+ }
1364
+ process.stderr.write(`Error: ${err.message}
1365
+ `);
1366
+ process.exit(1);
1367
+ }
1368
+ function createUpsertCommand() {
1369
+ const command = new Command9("upsert");
1370
+ command.description("Create or update a Task from a markdown document").argument("[id]", "work item ID to update; omit to create a new Task").option("--content <markdown>", "task document content").option("--file <path>", "read task document from file").option("--json", "output result as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(async (idStr, options) => {
1371
+ validateOrgProjectPair(options);
1372
+ const id = idStr === void 0 ? void 0 : parseWorkItemId(idStr);
1373
+ const { content, sourceFile } = loadSourceContent(options);
1374
+ let context;
1375
+ try {
1376
+ context = resolveContext(options);
1377
+ const document = parseTaskDocument(content);
1378
+ const action = id === void 0 ? "created" : "updated";
1379
+ if (action === "created") {
1380
+ ensureTitleForCreate(document.fields);
1381
+ }
1382
+ const operations = toPatchOperations(document.fields, action);
1383
+ const credential = await resolvePat();
1384
+ let writeResult;
1385
+ if (action === "created") {
1386
+ writeResult = await createWorkItem(context, "Task", credential.pat, operations);
1387
+ } else {
1388
+ if (id === void 0) {
1389
+ fail2("Work item ID is required for updates.");
1390
+ }
1391
+ writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
1392
+ }
1393
+ const result = buildUpsertResult(action, writeResult, document.fields);
1394
+ writeSuccess(result, options);
1395
+ cleanupSourceFile(sourceFile);
1396
+ } catch (err) {
1397
+ handleUpsertError(err, id, context);
1398
+ }
1399
+ });
1400
+ return command;
1401
+ }
1402
+
1029
1403
  // src/index.ts
1030
- var program = new Command9();
1404
+ var program = new Command10();
1031
1405
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
1032
1406
  program.addCommand(createGetItemCommand());
1033
1407
  program.addCommand(createClearPatCommand());
@@ -1037,6 +1411,7 @@ program.addCommand(createAssignCommand());
1037
1411
  program.addCommand(createSetFieldCommand());
1038
1412
  program.addCommand(createGetMdFieldCommand());
1039
1413
  program.addCommand(createSetMdFieldCommand());
1414
+ program.addCommand(createUpsertCommand());
1040
1415
  program.showHelpAfterError();
1041
1416
  program.parse();
1042
1417
  if (process.argv.length <= 2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,12 +28,12 @@
28
28
  },
29
29
  "devDependencies": {
30
30
  "@eslint/js": "^10.0.1",
31
- "@types/node": "^25.3.3",
32
- "eslint": "^10.0.2",
31
+ "@types/node": "^25.5.0",
32
+ "eslint": "^10.1.0",
33
33
  "prettier": "^3.8.1",
34
34
  "tsup": "^8.5.1",
35
35
  "typescript": "^5.9.3",
36
- "typescript-eslint": "^8.56.1",
37
- "vitest": "^4.0.18"
36
+ "typescript-eslint": "^8.57.2",
37
+ "vitest": "^4.1.2"
38
38
  }
39
39
  }