azdo-cli 0.2.5 → 0.4.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 +152 -1
  2. package/dist/index.js +1002 -174
  3. package/package.json +7 -6
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 Command12 } from "commander";
5
5
 
6
6
  // src/version.ts
7
7
  import { readFileSync } from "fs";
@@ -55,16 +55,73 @@ async function readResponseMessage(response) {
55
55
  function normalizeFieldList(fields) {
56
56
  return Array.from(new Set(fields.map((f) => f.trim()).filter((f) => f.length > 0)));
57
57
  }
58
+ function stringifyFieldValue(value) {
59
+ if (typeof value === "object" && value !== null) {
60
+ return JSON.stringify(value);
61
+ }
62
+ return String(value);
63
+ }
58
64
  function buildExtraFields(fields, requested) {
59
65
  const result = {};
60
66
  for (const name of requested) {
61
- const val = fields[name];
67
+ let val = fields[name];
68
+ let resolvedName = name;
69
+ if (val === void 0) {
70
+ const nameSuffix = name.split(".").pop().toLowerCase();
71
+ const match = Object.keys(fields).find(
72
+ (k) => k.split(".").pop().toLowerCase() === nameSuffix
73
+ );
74
+ if (match !== void 0) {
75
+ val = fields[match];
76
+ resolvedName = match;
77
+ }
78
+ }
62
79
  if (val !== void 0 && val !== null) {
63
- result[name] = String(val);
80
+ result[resolvedName] = stringifyFieldValue(val);
64
81
  }
65
82
  }
66
83
  return Object.keys(result).length > 0 ? result : null;
67
84
  }
85
+ function writeHeaders(pat) {
86
+ return {
87
+ ...authHeaders(pat),
88
+ "Content-Type": "application/json-patch+json"
89
+ };
90
+ }
91
+ async function readWriteResponse(response, errorCode) {
92
+ if (response.status === 400) {
93
+ const serverMessage = await readResponseMessage(response) ?? "Unknown error";
94
+ throw new Error(`${errorCode}: ${serverMessage}`);
95
+ }
96
+ if (!response.ok) {
97
+ throw new Error(`HTTP_${response.status}`);
98
+ }
99
+ const data = await response.json();
100
+ return {
101
+ id: data.id,
102
+ rev: data.rev,
103
+ fields: data.fields
104
+ };
105
+ }
106
+ async function getWorkItemFields(context, id, pat) {
107
+ const url = new URL(
108
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
109
+ );
110
+ url.searchParams.set("api-version", "7.1");
111
+ url.searchParams.set("$expand", "all");
112
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
113
+ if (response.status === 400) {
114
+ const serverMessage = await readResponseMessage(response);
115
+ if (serverMessage) {
116
+ throw new Error(`BAD_REQUEST: ${serverMessage}`);
117
+ }
118
+ }
119
+ if (!response.ok) {
120
+ throw new Error(`HTTP_${response.status}`);
121
+ }
122
+ const data = await response.json();
123
+ return data.fields;
124
+ }
68
125
  async function getWorkItem(context, id, pat, extraFields) {
69
126
  const url = new URL(
70
127
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
@@ -98,7 +155,7 @@ async function getWorkItem(context, id, pat, extraFields) {
98
155
  }
99
156
  let combinedDescription = null;
100
157
  if (descriptionParts.length === 1) {
101
- combinedDescription = descriptionParts[0].value;
158
+ combinedDescription = descriptionParts.at(0)?.value ?? null;
102
159
  } else if (descriptionParts.length > 1) {
103
160
  combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
104
161
  }
@@ -137,38 +194,44 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
137
194
  if (value === void 0 || value === null || value === "") {
138
195
  return null;
139
196
  }
140
- return typeof value === "object" ? JSON.stringify(value) : `${value}`;
197
+ return stringifyFieldValue(value);
141
198
  }
142
199
  async function updateWorkItem(context, id, pat, fieldName, operations) {
200
+ const result = await applyWorkItemPatch(context, id, pat, operations);
201
+ const title = result.fields["System.Title"];
202
+ const lastOp = operations.at(-1);
203
+ const fieldValue = lastOp?.value ?? null;
204
+ return {
205
+ id: result.id,
206
+ rev: result.rev,
207
+ title: typeof title === "string" ? title : "",
208
+ fieldName,
209
+ fieldValue
210
+ };
211
+ }
212
+ async function createWorkItem(context, workItemType, pat, operations) {
213
+ const url = new URL(
214
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
215
+ );
216
+ url.searchParams.set("api-version", "7.1");
217
+ const response = await fetchWithErrors(url.toString(), {
218
+ method: "POST",
219
+ headers: writeHeaders(pat),
220
+ body: JSON.stringify(operations)
221
+ });
222
+ return readWriteResponse(response, "CREATE_REJECTED");
223
+ }
224
+ async function applyWorkItemPatch(context, id, pat, operations) {
143
225
  const url = new URL(
144
226
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
145
227
  );
146
228
  url.searchParams.set("api-version", "7.1");
147
229
  const response = await fetchWithErrors(url.toString(), {
148
230
  method: "PATCH",
149
- headers: {
150
- ...authHeaders(pat),
151
- "Content-Type": "application/json-patch+json"
152
- },
231
+ headers: writeHeaders(pat),
153
232
  body: JSON.stringify(operations)
154
233
  });
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
- };
234
+ return readWriteResponse(response, "UPDATE_REJECTED");
172
235
  }
173
236
 
174
237
  // src/services/auth.ts
@@ -274,19 +337,19 @@ async function resolvePat() {
274
337
  import { execSync } from "child_process";
275
338
  var patterns = [
276
339
  // HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
277
- /^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/.+$/,
340
+ /^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)$/,
278
341
  // HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
279
- /^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/.+$/,
342
+ /^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+)$/,
280
343
  // HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
281
- /^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/.+$/,
344
+ /^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)$/,
282
345
  // SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
283
- /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/.+$/,
346
+ /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/,
284
347
  // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
285
- /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/.+$/
348
+ /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/
286
349
  ];
287
350
  function parseAzdoRemote(url) {
288
351
  for (const pattern of patterns) {
289
- const match = url.match(pattern);
352
+ const match = pattern.exec(url);
290
353
  if (match) {
291
354
  const project = match[2];
292
355
  if (/^DefaultCollection$/i.test(project)) {
@@ -310,6 +373,35 @@ function detectAzdoContext() {
310
373
  }
311
374
  return context;
312
375
  }
376
+ function parseRepoName(url) {
377
+ for (const pattern of patterns) {
378
+ const match = pattern.exec(url);
379
+ if (match) {
380
+ return match[3];
381
+ }
382
+ }
383
+ return null;
384
+ }
385
+ function detectRepoName() {
386
+ let remoteUrl;
387
+ try {
388
+ remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
389
+ } catch {
390
+ throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
391
+ }
392
+ const repo = parseRepoName(remoteUrl);
393
+ if (!repo) {
394
+ throw new Error('Git remote "origin" is not an Azure DevOps URL. Check that origin points to Azure DevOps and try again.');
395
+ }
396
+ return repo;
397
+ }
398
+ function getCurrentBranch() {
399
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
400
+ if (branch === "HEAD") {
401
+ throw new Error("Not on a named branch. Check out a named branch and try again.");
402
+ }
403
+ return branch;
404
+ }
313
405
 
314
406
  // src/services/config-store.ts
315
407
  import fs from "fs";
@@ -336,6 +428,13 @@ var SETTINGS = [
336
428
  type: "string[]",
337
429
  example: "System.Tags,Custom.Priority",
338
430
  required: false
431
+ },
432
+ {
433
+ key: "markdown",
434
+ description: "Convert rich text fields to markdown on display",
435
+ type: "boolean",
436
+ example: "true",
437
+ required: false
339
438
  }
340
439
  ];
341
440
  var VALID_KEYS = SETTINGS.map((s) => s.key);
@@ -369,7 +468,7 @@ function saveConfig(config) {
369
468
  }
370
469
  function validateKey(key) {
371
470
  if (!VALID_KEYS.includes(key)) {
372
- throw new Error(`Unknown setting key "${key}". Valid keys: org, project, fields`);
471
+ throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
373
472
  }
374
473
  }
375
474
  function getConfigValue(key) {
@@ -382,6 +481,11 @@ function setConfigValue(key, value) {
382
481
  const config = loadConfig();
383
482
  if (value === "") {
384
483
  delete config[key];
484
+ } else if (key === "markdown") {
485
+ if (value !== "true" && value !== "false") {
486
+ throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
487
+ }
488
+ config.markdown = value === "true";
385
489
  } else if (key === "fields") {
386
490
  config.fields = value.split(",").map((s) => s.trim());
387
491
  } else {
@@ -420,6 +524,61 @@ function resolveContext(options) {
420
524
  );
421
525
  }
422
526
 
527
+ // src/services/md-convert.ts
528
+ import { NodeHtmlMarkdown } from "node-html-markdown";
529
+
530
+ // src/services/html-detect.ts
531
+ var HTML_TAG_REGEX = /<\/?([a-z][a-z0-9]*)\b/gi;
532
+ var HTML_TAGS = /* @__PURE__ */ new Set([
533
+ "p",
534
+ "br",
535
+ "div",
536
+ "span",
537
+ "strong",
538
+ "em",
539
+ "b",
540
+ "i",
541
+ "u",
542
+ "a",
543
+ "ul",
544
+ "ol",
545
+ "li",
546
+ "h1",
547
+ "h2",
548
+ "h3",
549
+ "h4",
550
+ "h5",
551
+ "h6",
552
+ "table",
553
+ "tr",
554
+ "td",
555
+ "th",
556
+ "img",
557
+ "pre",
558
+ "code"
559
+ ]);
560
+ function isHtml(content) {
561
+ let match;
562
+ HTML_TAG_REGEX.lastIndex = 0;
563
+ while ((match = HTML_TAG_REGEX.exec(content)) !== null) {
564
+ if (HTML_TAGS.has(match[1].toLowerCase())) {
565
+ return true;
566
+ }
567
+ }
568
+ return false;
569
+ }
570
+
571
+ // src/services/md-convert.ts
572
+ function htmlToMarkdown(html) {
573
+ return NodeHtmlMarkdown.translate(html);
574
+ }
575
+ function toMarkdown(content) {
576
+ if (isHtml(content)) {
577
+ return htmlToMarkdown(content);
578
+ }
579
+ return content;
580
+ }
581
+
423
582
  // src/services/command-helpers.ts
424
583
  function parseWorkItemId(idStr) {
425
584
  const id = Number.parseInt(idStr, 10);
@@ -442,6 +601,24 @@ function validateOrgProjectPair(options) {
442
601
  process.exit(1);
443
602
  }
444
603
  }
604
+ function validateSource(options) {
605
+ const hasContent = options.content !== void 0;
606
+ const hasFile = options.file !== void 0;
607
+ if (hasContent === hasFile) {
608
+ process.stderr.write("Error: provide exactly one of --content or --file\n");
609
+ process.exit(1);
610
+ }
611
+ }
612
+ function formatCreateError(err) {
613
+ const error = err instanceof Error ? err : new Error(String(err));
614
+ const message = error.message.startsWith("CREATE_REJECTED:") ? error.message.replace("CREATE_REJECTED:", "").trim() : error.message;
615
+ const requiredMatches = [...message.matchAll(/field ['"]([^'"]+)['"]/gi)];
616
+ if (requiredMatches.length > 0) {
617
+ const fields = Array.from(new Set(requiredMatches.map((match) => match[1])));
618
+ return `Create rejected: ${message} (fields: ${fields.join(", ")})`;
619
+ }
620
+ return `Create rejected: ${message}`;
621
+ }
445
622
  function handleCommandError(err, id, context, scope = "write", exit = true) {
446
623
  const error = err instanceof Error ? err : new Error(String(err));
447
624
  const msg = error.message;
@@ -468,6 +645,9 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
468
645
  } else if (msg.startsWith("BAD_REQUEST:")) {
469
646
  const serverMsg = msg.replace("BAD_REQUEST: ", "");
470
647
  process.stderr.write(`Error: Request rejected: ${serverMsg}
648
+ `);
649
+ } else if (msg.startsWith("CREATE_REJECTED:")) {
650
+ process.stderr.write(`Error: ${formatCreateError(error)}
471
651
  `);
472
652
  } else if (msg.startsWith("UPDATE_REJECTED:")) {
473
653
  const serverMsg = msg.replace("UPDATE_REJECTED: ", "");
@@ -494,56 +674,68 @@ function parseRequestedFields(raw) {
494
674
  }
495
675
  function stripHtml(html) {
496
676
  let text = html;
497
- text = text.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
498
- text = text.replace(/<br\s*\/?>/gi, "\n");
499
- text = text.replace(/<\/?(p|div)>/gi, "\n");
500
- text = text.replace(/<li>/gi, "\n");
501
- text = text.replace(/<[^>]*>/g, "");
502
- text = text.replace(/&amp;/g, "&");
503
- text = text.replace(/&lt;/g, "<");
504
- text = text.replace(/&gt;/g, ">");
505
- text = text.replace(/&quot;/g, '"');
506
- text = text.replace(/&#39;/g, "'");
507
- text = text.replace(/&nbsp;/g, " ");
508
- text = text.replace(/\n{3,}/g, "\n\n");
677
+ text = text.replaceAll(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
678
+ text = text.replaceAll(/<br\s*\/?>/gi, "\n");
679
+ text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
680
+ text = text.replaceAll(/<li>/gi, "\n");
681
+ text = text.replaceAll(/<[^>]*>/g, "");
682
+ text = text.replaceAll("&amp;", "&");
683
+ text = text.replaceAll("&lt;", "<");
684
+ text = text.replaceAll("&gt;", ">");
685
+ text = text.replaceAll("&quot;", '"');
686
+ text = text.replaceAll("&#39;", "'");
687
+ text = text.replaceAll("&nbsp;", " ");
688
+ text = text.replaceAll(/\n{3,}/g, "\n\n");
509
689
  return text.trim();
510
690
  }
511
- function formatWorkItem(workItem, short) {
512
- const lines = [];
691
+ function convertRichText(html, markdown) {
692
+ if (!html) return "";
693
+ return markdown ? toMarkdown(html) : stripHtml(html);
694
+ }
695
+ function formatExtraFields(extraFields, markdown) {
696
+ return Object.entries(extraFields).map(([refName, value]) => {
697
+ const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
698
+ const displayValue = markdown ? toMarkdown(value) : value;
699
+ return `${fieldLabel.padEnd(13)}${displayValue}`;
700
+ });
701
+ }
702
+ function summarizeDescription(text, label) {
703
+ const descLines = text.split("\n").filter((l) => l.trim() !== "");
704
+ const firstThree = descLines.slice(0, 3);
705
+ const suffix = descLines.length > 3 ? "\n..." : "";
706
+ return [`${label("Description:")}${firstThree.join("\n")}${suffix}`];
707
+ }
708
+ function formatWorkItem(workItem, short, markdown = false) {
513
709
  const label = (name) => name.padEnd(13);
514
- lines.push(`${label("ID:")}${workItem.id}`);
515
- lines.push(`${label("Type:")}${workItem.type}`);
516
- lines.push(`${label("Title:")}${workItem.title}`);
517
- lines.push(`${label("State:")}${workItem.state}`);
518
- lines.push(`${label("Assigned To:")}${workItem.assignedTo ?? "Unassigned"}`);
710
+ const lines = [
711
+ `${label("ID:")}${workItem.id}`,
712
+ `${label("Type:")}${workItem.type}`,
713
+ `${label("Title:")}${workItem.title}`,
714
+ `${label("State:")}${workItem.state}`,
715
+ `${label("Assigned To:")}${workItem.assignedTo ?? "Unassigned"}`
716
+ ];
519
717
  if (!short) {
520
- lines.push(`${label("Area:")}${workItem.areaPath}`);
521
- lines.push(`${label("Iteration:")}${workItem.iterationPath}`);
718
+ lines.push(
719
+ `${label("Area:")}${workItem.areaPath}`,
720
+ `${label("Iteration:")}${workItem.iterationPath}`
721
+ );
522
722
  }
523
723
  lines.push(`${label("URL:")}${workItem.url}`);
524
724
  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
- }
725
+ lines.push(...formatExtraFields(workItem.extraFields, markdown));
529
726
  }
530
727
  lines.push("");
531
- const descriptionText = workItem.description ? stripHtml(workItem.description) : "";
728
+ const descriptionText = convertRichText(workItem.description, markdown);
532
729
  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}`);
730
+ lines.push(...summarizeDescription(descriptionText, label));
538
731
  } else {
539
- lines.push("Description:");
540
- lines.push(descriptionText);
732
+ lines.push("Description:", descriptionText);
541
733
  }
542
734
  return lines.join("\n");
543
735
  }
544
736
  function createGetItemCommand() {
545
737
  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(
738
+ 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
739
  async (idStr, options) => {
548
740
  const id = parseWorkItemId(idStr);
549
741
  validateOrgProjectPair(options);
@@ -551,9 +743,10 @@ function createGetItemCommand() {
551
743
  try {
552
744
  context = resolveContext(options);
553
745
  const credential = await resolvePat();
554
- const fieldsList = options.fields !== void 0 ? parseRequestedFields(options.fields) : parseRequestedFields(loadConfig().fields);
746
+ const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
555
747
  const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
556
- const output = formatWorkItem(workItem, options.short ?? false);
748
+ const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
749
+ const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
557
750
  process.stdout.write(output + "\n");
558
751
  } catch (err) {
559
752
  handleCommandError(err, id, context, "read", false);
@@ -581,6 +774,63 @@ function createClearPatCommand() {
581
774
  // src/commands/config.ts
582
775
  import { Command as Command3 } from "commander";
583
776
  import { createInterface as createInterface2 } from "readline";
777
+ function formatConfigValue(value, unsetFallback = "") {
778
+ if (value === void 0) {
779
+ return unsetFallback;
780
+ }
781
+ return Array.isArray(value) ? value.join(",") : value;
782
+ }
783
+ function writeConfigList(cfg) {
784
+ const keyWidth = 10;
785
+ const valueWidth = 30;
786
+ for (const setting of SETTINGS) {
787
+ const raw = cfg[setting.key];
788
+ const value = formatConfigValue(raw, "(not set)");
789
+ const marker = raw === void 0 && setting.required ? " *" : "";
790
+ process.stdout.write(
791
+ `${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
792
+ `
793
+ );
794
+ }
795
+ const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
796
+ if (hasUnset) {
797
+ process.stdout.write(
798
+ '\n* = required but not configured. Run "azdo config wizard" to set up.\n'
799
+ );
800
+ }
801
+ }
802
+ function createAsk(rl) {
803
+ return (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
804
+ }
805
+ async function promptForSetting(cfg, setting, ask) {
806
+ const currentDisplay = String(formatConfigValue(cfg[setting.key], ""));
807
+ const requiredTag = setting.required ? " (required)" : " (optional)";
808
+ process.stderr.write(`${setting.description}${requiredTag}
809
+ `);
810
+ if (setting.example) {
811
+ process.stderr.write(` Example: ${setting.example}
812
+ `);
813
+ }
814
+ const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
815
+ const answer = await ask(` ${setting.key}${defaultHint}: `);
816
+ const trimmed = answer.trim();
817
+ if (trimmed) {
818
+ setConfigValue(setting.key, trimmed);
819
+ process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
820
+
821
+ `);
822
+ return;
823
+ }
824
+ if (currentDisplay) {
825
+ process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
826
+
827
+ `);
828
+ return;
829
+ }
830
+ process.stderr.write(` -> Skipped "${setting.key}"
831
+
832
+ `);
833
+ }
584
834
  function createConfigCommand() {
585
835
  const config = new Command3("config");
586
836
  config.description("Manage CLI settings");
@@ -633,27 +883,9 @@ function createConfigCommand() {
633
883
  const cfg = loadConfig();
634
884
  if (options.json) {
635
885
  process.stdout.write(JSON.stringify(cfg) + "\n");
636
- } else {
637
- const keyWidth = 10;
638
- const valueWidth = 30;
639
- for (const setting of SETTINGS) {
640
- const raw = cfg[setting.key];
641
- const value = raw === void 0 ? "(not set)" : Array.isArray(raw) ? raw.join(",") : raw;
642
- const marker = raw === void 0 && setting.required ? " *" : "";
643
- process.stdout.write(
644
- `${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
645
- `
646
- );
647
- }
648
- const hasUnset = SETTINGS.some(
649
- (s) => s.required && cfg[s.key] === void 0
650
- );
651
- if (hasUnset) {
652
- process.stdout.write(
653
- '\n* = required but not configured. Run "azdo config wizard" to set up.\n'
654
- );
655
- }
886
+ return;
656
887
  }
888
+ writeConfigList(cfg);
657
889
  });
658
890
  const unset = new Command3("unset");
659
891
  unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
@@ -685,36 +917,11 @@ function createConfigCommand() {
685
917
  input: process.stdin,
686
918
  output: process.stderr
687
919
  });
688
- const ask = (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
920
+ const ask = createAsk(rl);
689
921
  process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
690
922
  process.stderr.write("=======================================\n\n");
691
923
  for (const setting of SETTINGS) {
692
- const current = cfg[setting.key];
693
- const currentDisplay = current === void 0 ? "" : Array.isArray(current) ? current.join(",") : current;
694
- const requiredTag = setting.required ? " (required)" : " (optional)";
695
- process.stderr.write(`${setting.description}${requiredTag}
696
- `);
697
- if (setting.example) {
698
- process.stderr.write(` Example: ${setting.example}
699
- `);
700
- }
701
- const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
702
- const answer = await ask(` ${setting.key}${defaultHint}: `);
703
- const trimmed = answer.trim();
704
- if (trimmed) {
705
- setConfigValue(setting.key, trimmed);
706
- process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
707
-
708
- `);
709
- } else if (currentDisplay) {
710
- process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
711
-
712
- `);
713
- } else {
714
- process.stderr.write(` -> Skipped "${setting.key}"
715
-
716
- `);
717
- }
924
+ await promptForSetting(cfg, setting, ask);
718
925
  }
719
926
  rl.close();
720
927
  process.stderr.write("Configuration complete!\n");
@@ -857,63 +1064,6 @@ function createSetFieldCommand() {
857
1064
 
858
1065
  // src/commands/get-md-field.ts
859
1066
  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
1067
  function createGetMdFieldCommand() {
918
1068
  const command = new Command7("get-md-field");
919
1069
  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 +1176,683 @@ function createSetMdFieldCommand() {
1026
1176
  return command;
1027
1177
  }
1028
1178
 
1179
+ // src/commands/upsert.ts
1180
+ import { existsSync as existsSync2, readFileSync as readFileSync3, unlinkSync } from "fs";
1181
+ import { Command as Command9 } from "commander";
1182
+
1183
+ // src/services/task-document.ts
1184
+ var FIELD_ALIASES = /* @__PURE__ */ new Map([
1185
+ ["title", "System.Title"],
1186
+ ["assignedto", "System.AssignedTo"],
1187
+ ["assigned to", "System.AssignedTo"],
1188
+ ["state", "System.State"],
1189
+ ["description", "System.Description"],
1190
+ ["acceptancecriteria", "Microsoft.VSTS.Common.AcceptanceCriteria"],
1191
+ ["acceptance criteria", "Microsoft.VSTS.Common.AcceptanceCriteria"],
1192
+ ["tags", "System.Tags"],
1193
+ ["priority", "Microsoft.VSTS.Common.Priority"]
1194
+ ]);
1195
+ var RICH_TEXT_FIELDS = /* @__PURE__ */ new Set([
1196
+ "System.Description",
1197
+ "Microsoft.VSTS.Common.AcceptanceCriteria"
1198
+ ]);
1199
+ var REFERENCE_NAME_PATTERN = /^[A-Z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$/;
1200
+ function normalizeAlias(name) {
1201
+ return name.trim().replaceAll(/\s+/g, " ").toLowerCase();
1202
+ }
1203
+ function parseScalarValue(rawValue, fieldName) {
1204
+ if (rawValue === void 0) {
1205
+ throw new Error(`Malformed YAML front matter: missing value for "${fieldName}"`);
1206
+ }
1207
+ const trimmed = rawValue.trim();
1208
+ if (trimmed === "" || trimmed === "null" || trimmed === "~") {
1209
+ return null;
1210
+ }
1211
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
1212
+ return trimmed.slice(1, -1);
1213
+ }
1214
+ if (/^[[{]|^[>|]-?$/.test(trimmed)) {
1215
+ throw new Error(`Malformed YAML front matter: unsupported value for "${fieldName}"`);
1216
+ }
1217
+ return trimmed;
1218
+ }
1219
+ function parseFrontMatter(content) {
1220
+ if (!content.startsWith("---")) {
1221
+ return { frontMatter: "", remainder: content };
1222
+ }
1223
+ const frontMatterPattern = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
1224
+ const match = frontMatterPattern.exec(content);
1225
+ if (!match) {
1226
+ throw new Error('Malformed YAML front matter: missing closing "---"');
1227
+ }
1228
+ return {
1229
+ frontMatter: match[1],
1230
+ remainder: content.slice(match[0].length)
1231
+ };
1232
+ }
1233
+ function assertKnownField(name, kind) {
1234
+ const resolved = resolveFieldName(name);
1235
+ if (!resolved) {
1236
+ const prefix = kind === "rich-text" ? "Unknown rich-text field" : "Unknown field";
1237
+ throw new Error(`${prefix}: ${name}`);
1238
+ }
1239
+ if (kind === "rich-text" && !RICH_TEXT_FIELDS.has(resolved)) {
1240
+ throw new Error(`Unknown rich-text field: ${name}`);
1241
+ }
1242
+ return resolved;
1243
+ }
1244
+ function pushField(fields, seen, refName, value, kind) {
1245
+ if (seen.has(refName)) {
1246
+ throw new Error(`Duplicate field: ${refName}`);
1247
+ }
1248
+ seen.add(refName);
1249
+ fields.push({
1250
+ refName,
1251
+ value,
1252
+ op: value === null ? "clear" : "set",
1253
+ kind
1254
+ });
1255
+ }
1256
+ function parseScalarFields(frontMatter, fields, seen) {
1257
+ if (frontMatter.trim() === "") {
1258
+ return;
1259
+ }
1260
+ for (const rawLine of frontMatter.split(/\r?\n/)) {
1261
+ const line = rawLine.trim();
1262
+ if (line === "") {
1263
+ continue;
1264
+ }
1265
+ const separatorIndex = rawLine.indexOf(":");
1266
+ if (separatorIndex <= 0) {
1267
+ throw new Error(`Malformed YAML front matter: ${rawLine.trim()}`);
1268
+ }
1269
+ const rawName = rawLine.slice(0, separatorIndex).trim();
1270
+ const rawValue = rawLine.slice(separatorIndex + 1);
1271
+ const refName = assertKnownField(rawName, "scalar");
1272
+ const value = parseScalarValue(rawValue, rawName);
1273
+ pushField(fields, seen, refName, value, "scalar");
1274
+ }
1275
+ }
1276
+ function parseRichTextSections(content, fields, seen) {
1277
+ const normalizedContent = content.replaceAll("\r\n", "\n");
1278
+ const lines = normalizedContent.split("\n");
1279
+ const headings = [];
1280
+ for (let index = 0; index < lines.length; index += 1) {
1281
+ const line = lines[index];
1282
+ if (!line.startsWith("##")) {
1283
+ continue;
1284
+ }
1285
+ const headingBody = line.slice(2);
1286
+ if (headingBody.trim() === "" || !headingBody.startsWith(" ") && !headingBody.startsWith(" ")) {
1287
+ continue;
1288
+ }
1289
+ headings.push({
1290
+ lineIndex: index,
1291
+ rawName: headingBody.trim()
1292
+ });
1293
+ }
1294
+ if (headings.length === 0) {
1295
+ return;
1296
+ }
1297
+ for (let index = 0; index < headings[0].lineIndex; index += 1) {
1298
+ if (lines[index].trim() !== "") {
1299
+ throw new Error("Unexpected content before the first markdown heading section");
1300
+ }
1301
+ }
1302
+ for (let index = 0; index < headings.length; index += 1) {
1303
+ const { lineIndex, rawName } = headings[index];
1304
+ const refName = assertKnownField(rawName, "rich-text");
1305
+ const bodyStart = lineIndex + 1;
1306
+ const bodyEnd = index + 1 < headings.length ? headings[index + 1].lineIndex : lines.length;
1307
+ const rawBody = lines.slice(bodyStart, bodyEnd).join("\n");
1308
+ const value = rawBody.trim() === "" ? null : rawBody.trimEnd();
1309
+ pushField(fields, seen, refName, value, "rich-text");
1310
+ }
1311
+ }
1312
+ function resolveFieldName(name) {
1313
+ const trimmed = name.trim();
1314
+ if (trimmed === "") {
1315
+ return null;
1316
+ }
1317
+ const alias = FIELD_ALIASES.get(normalizeAlias(trimmed));
1318
+ if (alias) {
1319
+ return alias;
1320
+ }
1321
+ return REFERENCE_NAME_PATTERN.test(trimmed) ? trimmed : null;
1322
+ }
1323
+ function parseTaskDocument(content) {
1324
+ const { frontMatter, remainder } = parseFrontMatter(content);
1325
+ const fields = [];
1326
+ const seen = /* @__PURE__ */ new Set();
1327
+ parseScalarFields(frontMatter, fields, seen);
1328
+ parseRichTextSections(remainder, fields, seen);
1329
+ return { fields };
1330
+ }
1331
+
1332
+ // src/commands/upsert.ts
1333
+ function fail2(message) {
1334
+ process.stderr.write(`Error: ${message}
1335
+ `);
1336
+ process.exit(1);
1337
+ }
1338
+ function loadSourceContent(options) {
1339
+ validateSource(options);
1340
+ if (options.content !== void 0) {
1341
+ return { content: options.content };
1342
+ }
1343
+ const filePath = options.file;
1344
+ if (!existsSync2(filePath)) {
1345
+ fail2(`File not found: ${filePath}`);
1346
+ }
1347
+ try {
1348
+ return {
1349
+ content: readFileSync3(filePath, "utf-8"),
1350
+ sourceFile: filePath
1351
+ };
1352
+ } catch {
1353
+ fail2(`Cannot read file: ${filePath}`);
1354
+ }
1355
+ }
1356
+ function toPatchOperations(fields, action) {
1357
+ const operations = [];
1358
+ for (const field of fields) {
1359
+ if (field.op === "clear") {
1360
+ if (action === "updated") {
1361
+ operations.push({ op: "remove", path: `/fields/${field.refName}` });
1362
+ }
1363
+ continue;
1364
+ }
1365
+ operations.push({ op: "add", path: `/fields/${field.refName}`, value: field.value ?? "" });
1366
+ if (field.kind === "rich-text") {
1367
+ operations.push({
1368
+ op: "add",
1369
+ path: `/multilineFieldsFormat/${field.refName}`,
1370
+ value: "Markdown"
1371
+ });
1372
+ }
1373
+ }
1374
+ return operations;
1375
+ }
1376
+ function buildAppliedFields(fields) {
1377
+ const applied = {};
1378
+ for (const field of fields) {
1379
+ applied[field.refName] = field.value;
1380
+ }
1381
+ return applied;
1382
+ }
1383
+ function ensureTitleForCreate(fields) {
1384
+ const titleField = fields.find((field) => field.refName === "System.Title");
1385
+ if (!titleField || titleField.op === "clear" || titleField.value === null || titleField.value.trim() === "") {
1386
+ fail2("Title is required when creating a task.");
1387
+ }
1388
+ }
1389
+ function writeSuccess(result, options) {
1390
+ if (options.json) {
1391
+ process.stdout.write(`${JSON.stringify(result)}
1392
+ `);
1393
+ return;
1394
+ }
1395
+ const verb = result.action === "created" ? "Created" : "Updated";
1396
+ const fields = Object.keys(result.fields).join(", ");
1397
+ const suffix = fields ? ` (${fields})` : "";
1398
+ process.stdout.write(`${verb} task #${result.id}${suffix}
1399
+ `);
1400
+ }
1401
+ function cleanupSourceFile(sourceFile) {
1402
+ if (!sourceFile) {
1403
+ return;
1404
+ }
1405
+ try {
1406
+ unlinkSync(sourceFile);
1407
+ } catch {
1408
+ process.stderr.write(`Warning: upsert succeeded but could not delete source file: ${sourceFile}
1409
+ `);
1410
+ }
1411
+ }
1412
+ function buildUpsertResult(action, writeResult, fields) {
1413
+ const appliedFields = buildAppliedFields(fields);
1414
+ return {
1415
+ action,
1416
+ id: writeResult.id,
1417
+ fields: appliedFields
1418
+ };
1419
+ }
1420
+ function isUpdateWriteError(err) {
1421
+ 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:");
1422
+ }
1423
+ function isCreateWriteError(err) {
1424
+ return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("HTTP_");
1425
+ }
1426
+ function handleUpsertError(err, id, context) {
1427
+ if (!(err instanceof Error)) {
1428
+ process.stderr.write(`Error: ${String(err)}
1429
+ `);
1430
+ process.exit(1);
1431
+ }
1432
+ if (id === void 0 && err.message.startsWith("CREATE_REJECTED:")) {
1433
+ process.stderr.write(`Error: ${formatCreateError(err)}
1434
+ `);
1435
+ process.exit(1);
1436
+ }
1437
+ if (id !== void 0 && isUpdateWriteError(err)) {
1438
+ handleCommandError(err, id, context, "write");
1439
+ return;
1440
+ }
1441
+ if (id === void 0 && isCreateWriteError(err)) {
1442
+ handleCommandError(err, 0, context, "write");
1443
+ return;
1444
+ }
1445
+ process.stderr.write(`Error: ${err.message}
1446
+ `);
1447
+ process.exit(1);
1448
+ }
1449
+ function createUpsertCommand() {
1450
+ const command = new Command9("upsert");
1451
+ 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) => {
1452
+ validateOrgProjectPair(options);
1453
+ const id = idStr === void 0 ? void 0 : parseWorkItemId(idStr);
1454
+ const { content, sourceFile } = loadSourceContent(options);
1455
+ let context;
1456
+ try {
1457
+ context = resolveContext(options);
1458
+ const document = parseTaskDocument(content);
1459
+ const action = id === void 0 ? "created" : "updated";
1460
+ if (action === "created") {
1461
+ ensureTitleForCreate(document.fields);
1462
+ }
1463
+ const operations = toPatchOperations(document.fields, action);
1464
+ const credential = await resolvePat();
1465
+ let writeResult;
1466
+ if (action === "created") {
1467
+ writeResult = await createWorkItem(context, "Task", credential.pat, operations);
1468
+ } else {
1469
+ if (id === void 0) {
1470
+ fail2("Work item ID is required for updates.");
1471
+ }
1472
+ writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
1473
+ }
1474
+ const result = buildUpsertResult(action, writeResult, document.fields);
1475
+ writeSuccess(result, options);
1476
+ cleanupSourceFile(sourceFile);
1477
+ } catch (err) {
1478
+ handleUpsertError(err, id, context);
1479
+ }
1480
+ });
1481
+ return command;
1482
+ }
1483
+
1484
+ // src/commands/list-fields.ts
1485
+ import { Command as Command10 } from "commander";
1486
+ function stringifyValue(value) {
1487
+ if (value === null || value === void 0) return "";
1488
+ if (typeof value === "object") return JSON.stringify(value);
1489
+ return String(value);
1490
+ }
1491
+ function formatRichValue(raw) {
1492
+ const md = htmlToMarkdown(raw);
1493
+ const lines = md.split("\n").filter((l) => l.trim() !== "");
1494
+ const preview = lines.slice(0, 5);
1495
+ const suffix = lines.length > 5 ? `
1496
+ \u2026 (${lines.length - 5} more lines)` : "";
1497
+ return preview.join("\n ") + suffix;
1498
+ }
1499
+ function formatFieldList(fields) {
1500
+ const entries = Object.entries(fields).sort(([a], [b]) => a.localeCompare(b));
1501
+ const maxKeyLen = Math.min(
1502
+ Math.max(...entries.map(([k]) => k.length)),
1503
+ 50
1504
+ );
1505
+ return entries.map(([key, value]) => {
1506
+ const raw = stringifyValue(value);
1507
+ if (raw === "") return `${key.padEnd(maxKeyLen + 2)}(empty)`;
1508
+ if (typeof value === "string" && isHtml(value)) {
1509
+ const preview = formatRichValue(value);
1510
+ return `${key.padEnd(maxKeyLen + 2)}[rich text]
1511
+ ${preview}`;
1512
+ }
1513
+ return `${key.padEnd(maxKeyLen + 2)}${raw}`;
1514
+ }).join("\n");
1515
+ }
1516
+ function createListFieldsCommand() {
1517
+ const command = new Command10("list-fields");
1518
+ command.description("List all fields of an Azure DevOps work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
1519
+ async (idStr, options) => {
1520
+ const id = parseWorkItemId(idStr);
1521
+ validateOrgProjectPair(options);
1522
+ let context;
1523
+ try {
1524
+ context = resolveContext(options);
1525
+ const credential = await resolvePat();
1526
+ const fields = await getWorkItemFields(context, id, credential.pat);
1527
+ if (options.json) {
1528
+ process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
1529
+ } else {
1530
+ process.stdout.write(`Work Item ${id} \u2014 ${Object.keys(fields).length} fields
1531
+
1532
+ `);
1533
+ process.stdout.write(formatFieldList(fields) + "\n");
1534
+ }
1535
+ } catch (err) {
1536
+ handleCommandError(err, id, context, "read");
1537
+ }
1538
+ }
1539
+ );
1540
+ return command;
1541
+ }
1542
+
1543
+ // src/commands/pr.ts
1544
+ import { Command as Command11 } from "commander";
1545
+
1546
+ // src/services/pr-client.ts
1547
+ function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
1548
+ const url = new URL(
1549
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
1550
+ );
1551
+ url.searchParams.set("api-version", "7.1");
1552
+ url.searchParams.set("searchCriteria.sourceRefName", `refs/heads/${sourceBranch}`);
1553
+ if (opts?.status) {
1554
+ url.searchParams.set("searchCriteria.status", opts.status);
1555
+ }
1556
+ if (opts?.targetBranch) {
1557
+ url.searchParams.set("searchCriteria.targetRefName", `refs/heads/${opts.targetBranch}`);
1558
+ }
1559
+ return url;
1560
+ }
1561
+ function mapPullRequest(repo, pullRequest) {
1562
+ return {
1563
+ id: pullRequest.pullRequestId,
1564
+ title: pullRequest.title,
1565
+ repository: repo,
1566
+ sourceRefName: pullRequest.sourceRefName,
1567
+ targetRefName: pullRequest.targetRefName,
1568
+ status: pullRequest.status,
1569
+ createdBy: pullRequest.createdBy?.displayName ?? null,
1570
+ url: pullRequest._links.web.href
1571
+ };
1572
+ }
1573
+ function mapComment(comment) {
1574
+ const content = comment.content?.trim();
1575
+ if (comment.isDeleted || !content) {
1576
+ return null;
1577
+ }
1578
+ return {
1579
+ id: comment.id,
1580
+ author: comment.author?.displayName ?? null,
1581
+ content,
1582
+ publishedAt: comment.publishedDate ?? null
1583
+ };
1584
+ }
1585
+ function mapThread(thread) {
1586
+ if (thread.status !== "active" && thread.status !== "pending") {
1587
+ return null;
1588
+ }
1589
+ const comments = thread.comments.map(mapComment).filter((comment) => comment !== null);
1590
+ if (comments.length === 0) {
1591
+ return null;
1592
+ }
1593
+ return {
1594
+ id: thread.id,
1595
+ status: thread.status,
1596
+ threadContext: thread.threadContext?.filePath ?? null,
1597
+ comments
1598
+ };
1599
+ }
1600
+ async function readJsonResponse(response) {
1601
+ if (!response.ok) {
1602
+ throw new Error(`HTTP_${response.status}`);
1603
+ }
1604
+ return response.json();
1605
+ }
1606
+ async function listPullRequests(context, repo, pat, sourceBranch, opts) {
1607
+ const response = await fetchWithErrors(
1608
+ buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
1609
+ { headers: authHeaders(pat) }
1610
+ );
1611
+ const data = await readJsonResponse(response);
1612
+ return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
1613
+ }
1614
+ async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
1615
+ const existing = await listPullRequests(context, repo, pat, sourceBranch, {
1616
+ status: "active",
1617
+ targetBranch: "develop"
1618
+ });
1619
+ if (existing.length === 1) {
1620
+ return {
1621
+ branch: sourceBranch,
1622
+ targetBranch: "develop",
1623
+ created: false,
1624
+ pullRequest: existing[0]
1625
+ };
1626
+ }
1627
+ if (existing.length > 1) {
1628
+ throw new Error(`AMBIGUOUS_PRS:${existing.map((pullRequest) => pullRequest.id).join(",")}`);
1629
+ }
1630
+ const payload = {
1631
+ sourceRefName: `refs/heads/${sourceBranch}`,
1632
+ targetRefName: "refs/heads/develop",
1633
+ title,
1634
+ description
1635
+ };
1636
+ const url = new URL(
1637
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
1638
+ );
1639
+ url.searchParams.set("api-version", "7.1");
1640
+ const response = await fetchWithErrors(url.toString(), {
1641
+ method: "POST",
1642
+ headers: {
1643
+ ...authHeaders(pat),
1644
+ "Content-Type": "application/json"
1645
+ },
1646
+ body: JSON.stringify(payload)
1647
+ });
1648
+ const data = await readJsonResponse(response);
1649
+ return {
1650
+ branch: sourceBranch,
1651
+ targetBranch: "develop",
1652
+ created: true,
1653
+ pullRequest: mapPullRequest(repo, data)
1654
+ };
1655
+ }
1656
+ async function getPullRequestThreads(context, repo, pat, prId) {
1657
+ const url = new URL(
1658
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
1659
+ );
1660
+ url.searchParams.set("api-version", "7.1");
1661
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
1662
+ const data = await readJsonResponse(response);
1663
+ return data.value.map(mapThread).filter((thread) => thread !== null);
1664
+ }
1665
+
1666
+ // src/commands/pr.ts
1667
+ function formatBranchName(refName) {
1668
+ return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
1669
+ }
1670
+ function writeError(message) {
1671
+ process.stderr.write(`Error: ${message}
1672
+ `);
1673
+ process.exit(1);
1674
+ }
1675
+ function handlePrCommandError(err, context, mode = "read") {
1676
+ const error = err instanceof Error ? err : new Error(String(err));
1677
+ if (error.message === "AUTH_FAILED") {
1678
+ const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
1679
+ writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`);
1680
+ }
1681
+ if (error.message === "PERMISSION_DENIED") {
1682
+ writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`);
1683
+ }
1684
+ if (error.message === "NETWORK_ERROR") {
1685
+ writeError("Could not connect to Azure DevOps. Check your network connection.");
1686
+ }
1687
+ if (error.message === "NOT_FOUND") {
1688
+ writeError(`Azure DevOps repository not found in ${context?.org}/${context?.project}.`);
1689
+ }
1690
+ if (error.message.startsWith("HTTP_")) {
1691
+ writeError(`Azure DevOps request failed with ${error.message}.`);
1692
+ }
1693
+ writeError(error.message);
1694
+ }
1695
+ function formatPullRequestBlock(pullRequest) {
1696
+ return [
1697
+ `#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
1698
+ `${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
1699
+ pullRequest.url
1700
+ ].join("\n");
1701
+ }
1702
+ function formatThreads(prId, title, threads) {
1703
+ const lines = [`Active comments for pull request #${prId}: ${title}`];
1704
+ for (const thread of threads) {
1705
+ lines.push("", `Thread #${thread.id} [${thread.status}] ${thread.threadContext ?? "(general)"}`);
1706
+ for (const comment of thread.comments) {
1707
+ lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
1708
+ }
1709
+ }
1710
+ return lines.join("\n");
1711
+ }
1712
+ async function resolvePrCommandContext(options) {
1713
+ const context = resolveContext(options);
1714
+ const repo = detectRepoName();
1715
+ const branch = getCurrentBranch();
1716
+ const credential = await resolvePat();
1717
+ return {
1718
+ context,
1719
+ repo,
1720
+ branch,
1721
+ pat: credential.pat
1722
+ };
1723
+ }
1724
+ function createPrStatusCommand() {
1725
+ const command = new Command11("status");
1726
+ command.description("Check pull requests for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
1727
+ validateOrgProjectPair(options);
1728
+ let context;
1729
+ try {
1730
+ const resolved = await resolvePrCommandContext(options);
1731
+ context = resolved.context;
1732
+ const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch);
1733
+ const { branch, repo } = resolved;
1734
+ const result = { branch, repository: repo, pullRequests };
1735
+ if (options.json) {
1736
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1737
+ `);
1738
+ return;
1739
+ }
1740
+ if (pullRequests.length === 0) {
1741
+ process.stdout.write(`No pull requests found for branch ${branch}.
1742
+ `);
1743
+ return;
1744
+ }
1745
+ process.stdout.write(`${pullRequests.map(formatPullRequestBlock).join("\n\n")}
1746
+ `);
1747
+ } catch (err) {
1748
+ handlePrCommandError(err, context, "read");
1749
+ }
1750
+ });
1751
+ return command;
1752
+ }
1753
+ function createPrOpenCommand() {
1754
+ const command = new Command11("open");
1755
+ command.description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option("--description <description>", "pull request description").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
1756
+ validateOrgProjectPair(options);
1757
+ const title = options.title?.trim();
1758
+ if (!title) {
1759
+ writeError("--title is required for pull request creation.");
1760
+ }
1761
+ const description = options.description?.trim();
1762
+ if (!description) {
1763
+ writeError("--description is required for pull request creation.");
1764
+ }
1765
+ let context;
1766
+ try {
1767
+ const resolved = await resolvePrCommandContext(options);
1768
+ context = resolved.context;
1769
+ if (resolved.branch === "develop") {
1770
+ writeError("Pull request creation requires a source branch other than develop.");
1771
+ }
1772
+ const result = await openPullRequest(
1773
+ resolved.context,
1774
+ resolved.repo,
1775
+ resolved.pat,
1776
+ resolved.branch,
1777
+ title,
1778
+ description
1779
+ );
1780
+ if (options.json) {
1781
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1782
+ `);
1783
+ return;
1784
+ }
1785
+ if (result.created) {
1786
+ process.stdout.write(`Created pull request #${result.pullRequest.id}: ${result.pullRequest.title}
1787
+ ${result.pullRequest.url}
1788
+ `);
1789
+ return;
1790
+ }
1791
+ process.stdout.write(
1792
+ `Active pull request already exists for ${resolved.branch} -> develop: #${result.pullRequest.id}
1793
+ ${result.pullRequest.url}
1794
+ `
1795
+ );
1796
+ } catch (err) {
1797
+ if (err instanceof Error && err.message.startsWith("AMBIGUOUS_PRS:")) {
1798
+ const ids = err.message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
1799
+ writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
1800
+ }
1801
+ handlePrCommandError(err, context, "write");
1802
+ }
1803
+ });
1804
+ return command;
1805
+ }
1806
+ function createPrCommentsCommand() {
1807
+ const command = new Command11("comments");
1808
+ command.description("List active pull request comments for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
1809
+ validateOrgProjectPair(options);
1810
+ let context;
1811
+ try {
1812
+ const resolved = await resolvePrCommandContext(options);
1813
+ context = resolved.context;
1814
+ const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
1815
+ status: "active"
1816
+ });
1817
+ if (pullRequests.length === 0) {
1818
+ writeError(`No active pull request found for branch ${resolved.branch}.`);
1819
+ }
1820
+ if (pullRequests.length > 1) {
1821
+ const ids = pullRequests.map((pullRequest2) => `#${pullRequest2.id}`).join(", ");
1822
+ writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
1823
+ }
1824
+ const pullRequest = pullRequests[0];
1825
+ const threads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
1826
+ const result = { branch: resolved.branch, pullRequest, threads };
1827
+ if (options.json) {
1828
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
1829
+ `);
1830
+ return;
1831
+ }
1832
+ if (threads.length === 0) {
1833
+ process.stdout.write(`Pull request #${pullRequest.id} has no active comments.
1834
+ `);
1835
+ return;
1836
+ }
1837
+ process.stdout.write(`${formatThreads(pullRequest.id, pullRequest.title, threads)}
1838
+ `);
1839
+ } catch (err) {
1840
+ handlePrCommandError(err, context, "read");
1841
+ }
1842
+ });
1843
+ return command;
1844
+ }
1845
+ function createPrCommand() {
1846
+ const command = new Command11("pr");
1847
+ command.description("Manage Azure DevOps pull requests");
1848
+ command.addCommand(createPrStatusCommand());
1849
+ command.addCommand(createPrOpenCommand());
1850
+ command.addCommand(createPrCommentsCommand());
1851
+ return command;
1852
+ }
1853
+
1029
1854
  // src/index.ts
1030
- var program = new Command9();
1855
+ var program = new Command12();
1031
1856
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
1032
1857
  program.addCommand(createGetItemCommand());
1033
1858
  program.addCommand(createClearPatCommand());
@@ -1037,6 +1862,9 @@ program.addCommand(createAssignCommand());
1037
1862
  program.addCommand(createSetFieldCommand());
1038
1863
  program.addCommand(createGetMdFieldCommand());
1039
1864
  program.addCommand(createSetMdFieldCommand());
1865
+ program.addCommand(createUpsertCommand());
1866
+ program.addCommand(createListFieldsCommand());
1867
+ program.addCommand(createPrCommand());
1040
1868
  program.showHelpAfterError();
1041
1869
  program.parse();
1042
1870
  if (process.argv.length <= 2) {