azdo-cli 0.5.0-develop.156 → 0.5.0-hotfix-0-10-1.266

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 +22 -340
  2. package/dist/index.js +1238 -265
  3. package/package.json +3 -2
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 Command13 } from "commander";
4
+ import { Command as Command15 } from "commander";
5
5
 
6
6
  // src/version.ts
7
7
  import { readFileSync } from "fs";
@@ -166,17 +166,33 @@ async function getWorkItemFields(context, id, pat) {
166
166
  const data = await response.json();
167
167
  return data.fields;
168
168
  }
169
- async function getWorkItem(context, id, pat, extraFields) {
169
+ function extractAttachments(relations) {
170
+ if (!relations) return null;
171
+ const attachments = relations.filter((r) => r.rel === "AttachedFile").map((r) => ({
172
+ name: r.attributes.name ?? "unknown",
173
+ size: r.attributes.resourceSize ?? 0,
174
+ url: r.url
175
+ }));
176
+ return attachments.length > 0 ? attachments : null;
177
+ }
178
+ function buildWorkItemUrl(context, id, options = {}) {
170
179
  const url = new URL(
171
180
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
172
181
  );
173
182
  url.searchParams.set("api-version", "7.1");
174
- const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
175
- if (normalizedExtraFields.length > 0) {
176
- const allFields = normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields]);
177
- url.searchParams.set("fields", allFields.join(","));
183
+ if (options.includeRelations) {
184
+ url.searchParams.set("$expand", "relations");
178
185
  }
179
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
186
+ if (options.fields && options.fields.length > 0) {
187
+ url.searchParams.set("fields", options.fields.join(","));
188
+ }
189
+ return url;
190
+ }
191
+ async function fetchWorkItemResponse(context, id, pat, options = {}) {
192
+ const response = await fetchWithErrors(
193
+ buildWorkItemUrl(context, id, options).toString(),
194
+ { headers: authHeaders(pat) }
195
+ );
180
196
  if (response.status === 400) {
181
197
  const serverMessage = await readResponseMessage(response);
182
198
  if (serverMessage) {
@@ -186,7 +202,14 @@ async function getWorkItem(context, id, pat, extraFields) {
186
202
  if (!response.ok) {
187
203
  throw new Error(`HTTP_${response.status}`);
188
204
  }
189
- const data = await response.json();
205
+ return await response.json();
206
+ }
207
+ async function getWorkItem(context, id, pat, extraFields) {
208
+ const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
209
+ const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, {
210
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
211
+ }) : await fetchWorkItemResponse(context, id, pat, { includeRelations: true });
212
+ const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, { includeRelations: true }) : data;
190
213
  const descriptionParts = [];
191
214
  if (data.fields["System.Description"]) {
192
215
  descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
@@ -214,7 +237,8 @@ async function getWorkItem(context, id, pat, extraFields) {
214
237
  areaPath: data.fields["System.AreaPath"],
215
238
  iterationPath: data.fields["System.IterationPath"],
216
239
  url: data._links.html.href,
217
- extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null
240
+ extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null,
241
+ attachments: extractAttachments(relationsData.relations)
218
242
  };
219
243
  }
220
244
  async function getWorkItemFieldValue(context, id, pat, fieldName) {
@@ -263,8 +287,10 @@ async function listWorkItemComments(context, id, pat) {
263
287
  comments
264
288
  };
265
289
  }
266
- async function addWorkItemComment(context, id, pat, text) {
267
- const response = await fetchWithErrors(buildWorkItemCommentsUrl(context, id).toString(), {
290
+ async function addWorkItemComment(context, id, pat, text, format = "html") {
291
+ const url = buildWorkItemCommentsUrl(context, id);
292
+ url.searchParams.set("format", format);
293
+ const response = await fetchWithErrors(url.toString(), {
268
294
  method: "POST",
269
295
  headers: {
270
296
  ...authHeaders(pat),
@@ -326,44 +352,340 @@ async function applyWorkItemPatch(context, id, pat, operations) {
326
352
  });
327
353
  return readWriteResponse(response, "UPDATE_REJECTED");
328
354
  }
355
+ async function downloadAttachment(url, pat) {
356
+ const response = await fetchWithErrors(url, { headers: authHeaders(pat) });
357
+ if (!response.ok) {
358
+ throw new Error(`HTTP_${response.status}`);
359
+ }
360
+ return response.arrayBuffer();
361
+ }
329
362
 
330
363
  // src/services/auth.ts
331
364
  import { createInterface } from "readline";
365
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
366
+ import { dirname as dirname2, join } from "path";
332
367
 
333
368
  // src/services/credential-store.ts
334
369
  import { Entry } from "@napi-rs/keyring";
335
- var SERVICE = "azdo-cli";
336
- var ACCOUNT = "pat";
337
- async function getPat() {
370
+
371
+ // src/types/credential.ts
372
+ var CredentialStoreUnavailableError = class extends Error {
373
+ backend;
374
+ constructor(backend, cause) {
375
+ super(`OS secret backend unavailable (${backend}). Install the platform's credential service and try again.`);
376
+ this.name = "CredentialStoreUnavailableError";
377
+ this.backend = backend;
378
+ if (cause instanceof Error) {
379
+ this.cause = cause;
380
+ }
381
+ }
382
+ };
383
+
384
+ // src/services/audit-log.ts
385
+ import fs from "fs";
386
+ import os from "os";
387
+ import path from "path";
388
+ function getAuditLogPath() {
389
+ return path.join(os.homedir(), ".azdo", "audit.log");
390
+ }
391
+ function ensureDirWithPerms(dir) {
392
+ if (!fs.existsSync(dir)) {
393
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
394
+ return;
395
+ }
338
396
  try {
339
- const entry = new Entry(SERVICE, ACCOUNT);
340
- return entry.getPassword();
397
+ fs.chmodSync(dir, 448);
341
398
  } catch {
342
- return null;
343
399
  }
344
400
  }
345
- async function storePat(pat) {
401
+ function ensureFileWithPerms(file) {
402
+ if (!fs.existsSync(file)) {
403
+ fs.writeFileSync(file, "", { mode: 384 });
404
+ return;
405
+ }
346
406
  try {
347
- const entry = new Entry(SERVICE, ACCOUNT);
348
- entry.setPassword(pat);
407
+ fs.chmodSync(file, 384);
349
408
  } catch {
350
409
  }
351
410
  }
352
- async function deletePat() {
411
+ function appendAuthAuditEvent(input) {
412
+ const auditLog = getAuditLogPath();
413
+ const dir = path.dirname(auditLog);
414
+ ensureDirWithPerms(dir);
415
+ ensureFileWithPerms(auditLog);
416
+ const record = {
417
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
418
+ event: input.event,
419
+ org: input.org,
420
+ backend: input.backend,
421
+ ...input.masked_pat !== void 0 ? { masked_pat: input.masked_pat } : {}
422
+ };
423
+ fs.appendFileSync(auditLog, `${JSON.stringify(record)}
424
+ `);
425
+ }
426
+ function readAuditEvents() {
427
+ const auditLog = getAuditLogPath();
428
+ if (!fs.existsSync(auditLog)) {
429
+ return [];
430
+ }
431
+ const contents = fs.readFileSync(auditLog, "utf8");
432
+ const out = [];
433
+ for (const line of contents.split("\n")) {
434
+ const trimmed = line.trim();
435
+ if (!trimmed) continue;
436
+ try {
437
+ const parsed = JSON.parse(trimmed);
438
+ if (parsed && typeof parsed === "object" && typeof parsed.event === "string") {
439
+ out.push(parsed);
440
+ }
441
+ } catch {
442
+ }
443
+ }
444
+ return out;
445
+ }
446
+
447
+ // src/services/config-store.ts
448
+ import fs2 from "fs";
449
+ import path2 from "path";
450
+ import os2 from "os";
451
+ var SETTINGS = [
452
+ {
453
+ key: "org",
454
+ description: "Azure DevOps organization name",
455
+ type: "string",
456
+ example: "mycompany",
457
+ required: true
458
+ },
459
+ {
460
+ key: "project",
461
+ description: "Azure DevOps project name",
462
+ type: "string",
463
+ example: "MyProject",
464
+ required: true
465
+ },
466
+ {
467
+ key: "fields",
468
+ description: "Extra work item fields to include (comma-separated reference names)",
469
+ type: "string[]",
470
+ example: "System.Tags,Custom.Priority",
471
+ required: false
472
+ },
473
+ {
474
+ key: "markdown",
475
+ description: "Convert rich text fields to markdown on display",
476
+ type: "boolean",
477
+ example: "true",
478
+ required: false
479
+ }
480
+ ];
481
+ var VALID_KEYS = SETTINGS.map((s) => s.key);
482
+ function getConfigPath() {
483
+ return path2.join(os2.homedir(), ".azdo", "config.json");
484
+ }
485
+ function loadConfig() {
486
+ const configPath = getConfigPath();
487
+ let raw;
353
488
  try {
354
- const entry = new Entry(SERVICE, ACCOUNT);
355
- entry.deletePassword();
356
- return true;
489
+ raw = fs2.readFileSync(configPath, "utf-8");
490
+ } catch (err) {
491
+ if (err.code === "ENOENT") {
492
+ return {};
493
+ }
494
+ throw err;
495
+ }
496
+ try {
497
+ return JSON.parse(raw);
357
498
  } catch {
358
- return false;
499
+ process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
500
+ `);
501
+ return {};
359
502
  }
360
503
  }
504
+ function saveConfig(config) {
505
+ const configPath = getConfigPath();
506
+ const dir = path2.dirname(configPath);
507
+ fs2.mkdirSync(dir, { recursive: true });
508
+ fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
509
+ }
510
+ function validateKey(key) {
511
+ if (!VALID_KEYS.includes(key)) {
512
+ throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
513
+ }
514
+ }
515
+ function getConfigValue(key) {
516
+ validateKey(key);
517
+ const config = loadConfig();
518
+ return config[key];
519
+ }
520
+ function setConfigValue(key, value) {
521
+ validateKey(key);
522
+ const config = loadConfig();
523
+ if (value === "") {
524
+ delete config[key];
525
+ } else if (key === "markdown") {
526
+ if (value !== "true" && value !== "false") {
527
+ throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
528
+ }
529
+ config.markdown = value === "true";
530
+ } else if (key === "fields") {
531
+ config.fields = value.split(",").map((s) => s.trim());
532
+ } else {
533
+ config[key] = value;
534
+ }
535
+ saveConfig(config);
536
+ }
537
+ function unsetConfigValue(key) {
538
+ validateKey(key);
539
+ const config = loadConfig();
540
+ delete config[key];
541
+ saveConfig(config);
542
+ }
361
543
 
362
- // src/services/auth.ts
544
+ // src/services/auth-masking.ts
545
+ var VISIBLE_CHARS = 5;
546
+ function maskedDisplay(pat) {
547
+ if (pat.length <= VISIBLE_CHARS * 2) {
548
+ return pat;
549
+ }
550
+ const hiddenCount = pat.length - VISIBLE_CHARS * 2;
551
+ return pat.slice(0, VISIBLE_CHARS) + "*".repeat(hiddenCount) + pat.slice(-VISIBLE_CHARS);
552
+ }
363
553
  function normalizePat(rawPat) {
364
554
  const trimmedPat = rawPat.trim();
365
555
  return trimmedPat.length > 0 ? trimmedPat : null;
366
556
  }
557
+
558
+ // src/services/credential-store.ts
559
+ var SERVICE = "azdo-cli";
560
+ var LEGACY_ACCOUNT = "pat";
561
+ function accountFor(org) {
562
+ return `pat:${org}`;
563
+ }
564
+ function probeBackend() {
565
+ switch (process.platform) {
566
+ case "win32":
567
+ return "windows-credential-manager";
568
+ case "darwin":
569
+ return "macos-keychain";
570
+ case "linux":
571
+ return "linux-libsecret";
572
+ default:
573
+ return "unknown";
574
+ }
575
+ }
576
+ function wrapUnavailable(fn) {
577
+ try {
578
+ return fn();
579
+ } catch (err) {
580
+ throw new CredentialStoreUnavailableError(probeBackend(), err);
581
+ }
582
+ }
583
+ var legacyUnsetNoticeEmitted = false;
584
+ function emitLegacyUnsetNoticeOnce() {
585
+ if (legacyUnsetNoticeEmitted) return;
586
+ legacyUnsetNoticeEmitted = true;
587
+ process.stderr.write(
588
+ 'A legacy PAT exists in the OS vault from a previous azdo-cli version, but no "org" is set in config. Run `azdo auth --org <name>` to re-store it under the per-org key, then `azdo clear-pat` to remove the legacy slot.\n'
589
+ );
590
+ }
591
+ async function maybeMigrateLegacy(targetOrg) {
592
+ const config = loadConfig();
593
+ if (!config.org || config.org !== targetOrg) {
594
+ if (!config.org) {
595
+ let legacyExists;
596
+ try {
597
+ const legacyEntry2 = new Entry(SERVICE, LEGACY_ACCOUNT);
598
+ legacyExists = legacyEntry2.getPassword() !== null;
599
+ } catch {
600
+ legacyExists = false;
601
+ }
602
+ if (legacyExists) {
603
+ emitLegacyUnsetNoticeOnce();
604
+ }
605
+ }
606
+ return null;
607
+ }
608
+ const newEntry = new Entry(SERVICE, accountFor(targetOrg));
609
+ const existingNew = wrapUnavailable(() => newEntry.getPassword());
610
+ if (existingNew !== null) {
611
+ return null;
612
+ }
613
+ const legacyEntry = new Entry(SERVICE, LEGACY_ACCOUNT);
614
+ const legacy = wrapUnavailable(() => legacyEntry.getPassword());
615
+ if (legacy === null) {
616
+ return null;
617
+ }
618
+ wrapUnavailable(() => {
619
+ newEntry.setPassword(legacy);
620
+ legacyEntry.deletePassword();
621
+ });
622
+ appendAuthAuditEvent({
623
+ event: "auth.store",
624
+ org: targetOrg,
625
+ backend: probeBackend(),
626
+ masked_pat: maskedDisplay(legacy)
627
+ });
628
+ process.stderr.write(`Migrated legacy PAT to org ${targetOrg}.
629
+ `);
630
+ return legacy;
631
+ }
632
+ async function getPat(org) {
633
+ const entry = new Entry(SERVICE, accountFor(org));
634
+ const value = wrapUnavailable(() => entry.getPassword());
635
+ if (value !== null) {
636
+ return value;
637
+ }
638
+ const migrated = await maybeMigrateLegacy(org);
639
+ return migrated;
640
+ }
641
+ async function storePat(org, pat) {
642
+ const entry = new Entry(SERVICE, accountFor(org));
643
+ wrapUnavailable(() => entry.setPassword(pat));
644
+ appendAuthAuditEvent({
645
+ event: "auth.store",
646
+ org,
647
+ backend: probeBackend(),
648
+ masked_pat: maskedDisplay(pat)
649
+ });
650
+ }
651
+ async function deletePat(org) {
652
+ const entry = new Entry(SERVICE, accountFor(org));
653
+ const existing = wrapUnavailable(() => entry.getPassword());
654
+ if (existing === null) {
655
+ return false;
656
+ }
657
+ wrapUnavailable(() => entry.deletePassword());
658
+ appendAuthAuditEvent({
659
+ event: "auth.delete",
660
+ org,
661
+ backend: probeBackend(),
662
+ masked_pat: maskedDisplay(existing)
663
+ });
664
+ return true;
665
+ }
666
+ async function listOrgsWithStoredPat() {
667
+ const seen = /* @__PURE__ */ new Set();
668
+ for (const ev of readAuditEvents()) {
669
+ if (ev.event === "auth.store") {
670
+ seen.add(ev.org);
671
+ } else if (ev.event === "auth.delete") {
672
+ seen.delete(ev.org);
673
+ }
674
+ }
675
+ const present = [];
676
+ for (const org of seen) {
677
+ const entry = new Entry(SERVICE, accountFor(org));
678
+ const value = wrapUnavailable(() => entry.getPassword());
679
+ if (value !== null) {
680
+ present.push(org);
681
+ }
682
+ }
683
+ present.sort((a, b) => a.localeCompare(b));
684
+ return present;
685
+ }
686
+
687
+ // src/services/auth.ts
688
+ var PAT_PROMPT = "Enter your Azure DevOps PAT: ";
367
689
  async function promptForPat() {
368
690
  if (!process.stdin.isTTY) {
369
691
  return null;
@@ -371,12 +693,16 @@ async function promptForPat() {
371
693
  return new Promise((resolve2) => {
372
694
  const rl = createInterface({
373
695
  input: process.stdin,
374
- output: process.stderr
696
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
697
+ output: null
375
698
  });
376
- process.stderr.write("Enter your Azure DevOps PAT: ");
699
+ process.stderr.write(PAT_PROMPT);
377
700
  process.stdin.setRawMode(true);
378
701
  process.stdin.resume();
379
702
  let pat = "";
703
+ const redraw = () => {
704
+ process.stderr.write(`\r${PAT_PROMPT}${maskedDisplay(pat)}\x1B[K`);
705
+ };
380
706
  const onData = (key) => {
381
707
  const ch = key.toString("utf8");
382
708
  if (ch === "") {
@@ -394,37 +720,77 @@ async function promptForPat() {
394
720
  } else if (ch === "\x7F" || ch === "\b") {
395
721
  if (pat.length > 0) {
396
722
  pat = pat.slice(0, -1);
397
- process.stderr.write("\b \b");
723
+ redraw();
398
724
  }
399
725
  } else {
400
726
  pat += ch;
401
- process.stderr.write("*".repeat(ch.length));
727
+ redraw();
402
728
  }
403
729
  };
404
730
  process.stdin.on("data", onData);
405
731
  });
406
732
  }
407
- async function resolvePat() {
733
+ function findDotEnvPat(startDir = process.cwd()) {
734
+ let current = startDir;
735
+ while (true) {
736
+ const envFile = join(current, ".env");
737
+ if (existsSync(envFile)) {
738
+ const contents = readFileSync2(envFile, "utf8");
739
+ for (const line of contents.split("\n")) {
740
+ const match = line.match(/^AZDO_PAT\s*=\s*(.+)$/);
741
+ if (match) {
742
+ const value = match[1].trim().replace(/^["']|["']$/g, "");
743
+ if (value.length > 0) return value;
744
+ }
745
+ }
746
+ }
747
+ const parent = dirname2(current);
748
+ if (parent === current) break;
749
+ current = parent;
750
+ }
751
+ return null;
752
+ }
753
+ async function resolvePat(org) {
408
754
  const envPat = process.env.AZDO_PAT;
409
- if (envPat) {
755
+ if (envPat && envPat.length > 0) {
410
756
  return { pat: envPat, source: "env" };
411
757
  }
412
- const storedPat = await getPat();
758
+ const storedPat = await getPat(org);
413
759
  if (storedPat !== null) {
414
760
  return { pat: storedPat, source: "credential-store" };
415
761
  }
416
- const promptedPat = await promptForPat();
417
- if (promptedPat !== null) {
418
- const normalizedPat = normalizePat(promptedPat);
419
- if (normalizedPat !== null) {
420
- await storePat(normalizedPat);
421
- return { pat: normalizedPat, source: "prompt" };
422
- }
762
+ const dotEnvPat = findDotEnvPat();
763
+ if (dotEnvPat !== null) {
764
+ return { pat: dotEnvPat, source: "env" };
765
+ }
766
+ return null;
767
+ }
768
+ async function requirePat(org) {
769
+ const cred = await resolvePat(org);
770
+ if (cred !== null) {
771
+ return cred;
423
772
  }
424
773
  throw new Error(
425
- "Authentication cancelled. Set AZDO_PAT environment variable or run again to enter a PAT."
774
+ `No PAT available for org "${org}". Set AZDO_PAT environment variable or run \`azdo auth --org ${org}\`.`
426
775
  );
427
776
  }
777
+ async function validatePatAgainstAzdo(pat, org) {
778
+ const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?$top=1&api-version=7.1`;
779
+ const auth = Buffer.from(`:${pat}`).toString("base64");
780
+ const response = await fetch(url, {
781
+ headers: {
782
+ Authorization: `Basic ${auth}`,
783
+ Accept: "application/json"
784
+ }
785
+ });
786
+ if (response.status === 200) {
787
+ return { ok: true, status: 200 };
788
+ }
789
+ if (response.status === 401 || response.status === 403) {
790
+ return { ok: false, status: response.status };
791
+ }
792
+ throw new Error(`Azure DevOps returned HTTP ${response.status} while validating PAT for org "${org}".`);
793
+ }
428
794
 
429
795
  // src/services/git-remote.ts
430
796
  import { execSync } from "child_process";
@@ -496,122 +862,57 @@ function getCurrentBranch() {
496
862
  return branch;
497
863
  }
498
864
 
499
- // src/services/config-store.ts
500
- import fs from "fs";
501
- import path from "path";
502
- import os from "os";
503
- var SETTINGS = [
504
- {
505
- key: "org",
506
- description: "Azure DevOps organization name",
507
- type: "string",
508
- example: "mycompany",
509
- required: true
510
- },
511
- {
512
- key: "project",
513
- description: "Azure DevOps project name",
514
- type: "string",
515
- example: "MyProject",
516
- required: true
517
- },
518
- {
519
- key: "fields",
520
- description: "Extra work item fields to include (comma-separated reference names)",
521
- type: "string[]",
522
- example: "System.Tags,Custom.Priority",
523
- required: false
524
- },
525
- {
526
- key: "markdown",
527
- description: "Convert rich text fields to markdown on display",
528
- type: "boolean",
529
- example: "true",
530
- required: false
531
- }
532
- ];
533
- var VALID_KEYS = SETTINGS.map((s) => s.key);
534
- function getConfigPath() {
535
- return path.join(os.homedir(), ".azdo", "config.json");
536
- }
537
- function loadConfig() {
538
- const configPath = getConfigPath();
539
- let raw;
865
+ // src/services/org-resolver.ts
866
+ function defaultDetectFromGit() {
540
867
  try {
541
- raw = fs.readFileSync(configPath, "utf-8");
542
- } catch (err) {
543
- if (err.code === "ENOENT") {
544
- return {};
545
- }
546
- throw err;
547
- }
548
- try {
549
- return JSON.parse(raw);
868
+ return detectAzdoContext().org ?? null;
550
869
  } catch {
551
- process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
552
- `);
553
- return {};
870
+ return null;
554
871
  }
555
872
  }
556
- function saveConfig(config) {
557
- const configPath = getConfigPath();
558
- const dir = path.dirname(configPath);
559
- fs.mkdirSync(dir, { recursive: true });
560
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
873
+ function defaultReadConfig() {
874
+ return loadConfig();
561
875
  }
562
- function validateKey(key) {
563
- if (!VALID_KEYS.includes(key)) {
564
- throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
876
+ function resolveOrg(options) {
877
+ if (options.org && options.org.length > 0) {
878
+ return { org: options.org, source: "flag" };
565
879
  }
566
- }
567
- function getConfigValue(key) {
568
- validateKey(key);
569
- const config = loadConfig();
570
- return config[key];
571
- }
572
- function setConfigValue(key, value) {
573
- validateKey(key);
574
- const config = loadConfig();
575
- if (value === "") {
576
- delete config[key];
577
- } else if (key === "markdown") {
578
- if (value !== "true" && value !== "false") {
579
- throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
580
- }
581
- config.markdown = value === "true";
582
- } else if (key === "fields") {
583
- config.fields = value.split(",").map((s) => s.trim());
584
- } else {
585
- config[key] = value;
880
+ const gitOrg = (options.detectFromGit ?? defaultDetectFromGit)();
881
+ if (gitOrg && gitOrg.length > 0) {
882
+ return { org: gitOrg, source: "git" };
586
883
  }
587
- saveConfig(config);
884
+ const configOrg = (options.readConfig ?? defaultReadConfig)().org;
885
+ if (configOrg && configOrg.length > 0) {
886
+ return { org: configOrg, source: "config" };
887
+ }
888
+ return null;
588
889
  }
589
- function unsetConfigValue(key) {
590
- validateKey(key);
591
- const config = loadConfig();
592
- delete config[key];
593
- saveConfig(config);
890
+ function formatResolutionError() {
891
+ return [
892
+ "Could not resolve an Azure DevOps organization. Options (in priority order):",
893
+ " 1. Pass --org <name> on the command line.",
894
+ " 2. Run this command from a git repo whose origin remote is an Azure DevOps URL.",
895
+ " 3. Run `azdo config set org <name>` once to set a persistent default."
896
+ ].join("\n");
594
897
  }
595
898
 
596
899
  // src/services/context.ts
597
900
  function resolveContext(options) {
598
- if (options.org && options.project) {
599
- return { org: options.org, project: options.project };
600
- }
601
- const config = loadConfig();
602
- if (config.org && config.project) {
603
- return { org: config.org, project: config.project };
604
- }
901
+ const resolvedOrg = resolveOrg({ org: options.org });
605
902
  let gitContext = null;
606
903
  try {
607
904
  gitContext = detectAzdoContext();
608
905
  } catch {
609
906
  }
610
- const org = config.org || gitContext?.org;
611
- const project = config.project || gitContext?.project;
907
+ const config = loadConfig();
908
+ const org = resolvedOrg?.org;
909
+ const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || config.project;
612
910
  if (org && project) {
613
911
  return { org, project };
614
912
  }
913
+ if (!org) {
914
+ throw new Error(formatResolutionError());
915
+ }
615
916
  throw new Error(
616
917
  'Could not determine org/project. Use --org and --project flags, work from an Azure DevOps git repo, or run "azdo config set org/project".'
617
918
  );
@@ -771,7 +1072,7 @@ function stripHtml(html) {
771
1072
  text = text.replaceAll(/<br\s*\/?>/gi, "\n");
772
1073
  text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
773
1074
  text = text.replaceAll(/<li>/gi, "\n");
774
- text = text.replaceAll(/<[^>]*>/g, "");
1075
+ text = removeHtmlTags(text);
775
1076
  text = text.replaceAll("&amp;", "&");
776
1077
  text = text.replaceAll("&lt;", "<");
777
1078
  text = text.replaceAll("&gt;", ">");
@@ -781,22 +1082,71 @@ function stripHtml(html) {
781
1082
  text = text.replaceAll(/\n{3,}/g, "\n\n");
782
1083
  return text.trim();
783
1084
  }
1085
+ function removeHtmlTags(value) {
1086
+ let result = "";
1087
+ let insideTag = false;
1088
+ for (const char of value) {
1089
+ if (char === "<") {
1090
+ insideTag = true;
1091
+ continue;
1092
+ }
1093
+ if (char === ">" && insideTag) {
1094
+ insideTag = false;
1095
+ continue;
1096
+ }
1097
+ if (!insideTag) {
1098
+ result += char;
1099
+ }
1100
+ }
1101
+ return result;
1102
+ }
784
1103
  function convertRichText(html, markdown) {
785
1104
  if (!html) return "";
786
1105
  return markdown ? toMarkdown(html) : stripHtml(html);
787
1106
  }
1107
+ function formatMarkdownField(fieldLabel, value) {
1108
+ if (value.includes("\n")) {
1109
+ return `${fieldLabel}:
1110
+ ${value}`;
1111
+ }
1112
+ return `${fieldLabel}: ${value}`;
1113
+ }
788
1114
  function formatExtraFields(extraFields, markdown) {
789
1115
  return Object.entries(extraFields).map(([refName, value]) => {
790
1116
  const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
791
- const displayValue = markdown ? toMarkdown(value) : value;
792
- return `${fieldLabel.padEnd(13)}${displayValue}`;
1117
+ if (markdown) {
1118
+ const displayValue = toMarkdown(value);
1119
+ return formatMarkdownField(fieldLabel, displayValue);
1120
+ }
1121
+ return formatMarkdownField(fieldLabel, value);
793
1122
  });
794
1123
  }
795
- function summarizeDescription(text, label) {
1124
+ function summarizeDescription(text, label, markdown) {
796
1125
  const descLines = text.split("\n").filter((l) => l.trim() !== "");
797
1126
  const firstThree = descLines.slice(0, 3);
798
1127
  const suffix = descLines.length > 3 ? "\n..." : "";
799
- return [`${label("Description:")}${firstThree.join("\n")}${suffix}`];
1128
+ const content = `${firstThree.join("\n")}${suffix}`;
1129
+ if (markdown) {
1130
+ return [formatMarkdownField("Description", content)];
1131
+ }
1132
+ return [`${label("Description:")}${content}`];
1133
+ }
1134
+ function formatFileSize(bytes) {
1135
+ if (bytes < 1024) return `${bytes} B`;
1136
+ const kb = bytes / 1024;
1137
+ if (kb < 1024) return `${kb.toFixed(1)} KB`;
1138
+ const mb = kb / 1024;
1139
+ return `${mb.toFixed(1)} MB`;
1140
+ }
1141
+ function formatAttachments(attachments, short) {
1142
+ if (short) {
1143
+ return [`Attachments: ${attachments.length}`];
1144
+ }
1145
+ const lines = ["", "Attachments:"];
1146
+ for (const att of attachments) {
1147
+ lines.push(` ${att.name} (${formatFileSize(att.size)})`);
1148
+ }
1149
+ return lines;
800
1150
  }
801
1151
  function formatWorkItem(workItem, short, markdown = false) {
802
1152
  const label = (name) => name.padEnd(13);
@@ -813,59 +1163,361 @@ function formatWorkItem(workItem, short, markdown = false) {
813
1163
  `${label("Iteration:")}${workItem.iterationPath}`
814
1164
  );
815
1165
  }
816
- lines.push(`${label("URL:")}${workItem.url}`);
817
- if (workItem.extraFields) {
818
- lines.push(...formatExtraFields(workItem.extraFields, markdown));
1166
+ lines.push(`${label("URL:")}${workItem.url}`);
1167
+ if (workItem.extraFields) {
1168
+ lines.push(...formatExtraFields(workItem.extraFields, markdown));
1169
+ }
1170
+ lines.push("");
1171
+ const descriptionText = convertRichText(workItem.description, markdown);
1172
+ if (short) {
1173
+ lines.push(...summarizeDescription(descriptionText, label, markdown));
1174
+ } else {
1175
+ lines.push("Description:", descriptionText);
1176
+ }
1177
+ if (workItem.attachments) {
1178
+ lines.push(...formatAttachments(workItem.attachments, short));
1179
+ }
1180
+ return lines.join("\n");
1181
+ }
1182
+ function createGetItemCommand() {
1183
+ const command = new Command("get-item");
1184
+ 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(
1185
+ async (idStr, options) => {
1186
+ const id = parseWorkItemId(idStr);
1187
+ validateOrgProjectPair(options);
1188
+ let context;
1189
+ try {
1190
+ context = resolveContext(options);
1191
+ const credential = await requirePat(context.org);
1192
+ const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
1193
+ const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
1194
+ const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
1195
+ const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
1196
+ process.stdout.write(output + "\n");
1197
+ } catch (err) {
1198
+ handleCommandError(err, id, context, "read", false);
1199
+ }
1200
+ }
1201
+ );
1202
+ return command;
1203
+ }
1204
+
1205
+ // src/commands/clear-pat.ts
1206
+ import { Command as Command2 } from "commander";
1207
+ function createClearPatCommand() {
1208
+ const command = new Command2("clear-pat");
1209
+ command.description("Remove a stored Azure DevOps PAT (deprecated: use `azdo auth logout`)").option("--org <name>", "Azure DevOps organization (overrides auto-detect / config)").action(async (options) => {
1210
+ process.stderr.write("`azdo clear-pat` is deprecated; use `azdo auth logout [--org <name>]` instead.\n");
1211
+ const resolved = resolveOrg({ org: options.org });
1212
+ if (!resolved) {
1213
+ process.stderr.write(`${formatResolutionError()}
1214
+ `);
1215
+ process.exitCode = 3;
1216
+ return;
1217
+ }
1218
+ const deleted = await deletePat(resolved.org);
1219
+ if (deleted) {
1220
+ process.stdout.write(`PAT removed for org ${resolved.org}.
1221
+ `);
1222
+ } else {
1223
+ process.stdout.write(`No stored PAT found for org ${resolved.org}.
1224
+ `);
1225
+ }
1226
+ });
1227
+ return command;
1228
+ }
1229
+
1230
+ // src/commands/auth.ts
1231
+ import { Command as Command3 } from "commander";
1232
+
1233
+ // src/services/browser-open.ts
1234
+ import { execFile } from "child_process";
1235
+ function isHeadless(platform, hasDisplay) {
1236
+ if (platform === "linux") {
1237
+ return !hasDisplay;
1238
+ }
1239
+ return false;
1240
+ }
1241
+ function commandForPlatform(platform) {
1242
+ switch (platform) {
1243
+ case "darwin":
1244
+ return { cmd: "open", args: (url) => [url] };
1245
+ case "win32":
1246
+ return { cmd: "cmd", args: (url) => ["/c", "start", '""', url] };
1247
+ case "linux":
1248
+ return { cmd: "xdg-open", args: (url) => [url] };
1249
+ default:
1250
+ return null;
1251
+ }
1252
+ }
1253
+ async function openUrl(url, opts = {}) {
1254
+ const platform = opts.platform ?? process.platform;
1255
+ const hasDisplay = opts.hasDisplay ?? (process.env.DISPLAY !== void 0 && process.env.DISPLAY !== "");
1256
+ const forcePrint = opts.forcePrint ?? false;
1257
+ if (forcePrint || isHeadless(platform, hasDisplay)) {
1258
+ process.stderr.write(`Open this URL in your browser: ${url}
1259
+ `);
1260
+ return "printed";
1261
+ }
1262
+ const spec = commandForPlatform(platform);
1263
+ if (!spec) {
1264
+ process.stderr.write(`Open this URL in your browser: ${url}
1265
+ `);
1266
+ return "printed";
1267
+ }
1268
+ const runner = opts.execFileFn ?? ((cmd, args, cb) => execFile(cmd, args, { timeout: 5e3 }, (err) => cb(err)));
1269
+ return await new Promise((resolve2) => {
1270
+ try {
1271
+ runner(spec.cmd, spec.args(url), (err) => {
1272
+ if (err) {
1273
+ process.stderr.write(`Open this URL in your browser: ${url}
1274
+ `);
1275
+ resolve2("printed");
1276
+ } else {
1277
+ resolve2("opened");
1278
+ }
1279
+ });
1280
+ } catch {
1281
+ process.stderr.write(`Open this URL in your browser: ${url}
1282
+ `);
1283
+ resolve2("printed");
1284
+ }
1285
+ });
1286
+ }
1287
+
1288
+ // src/commands/auth.ts
1289
+ async function readStdinToString() {
1290
+ const chunks = [];
1291
+ for await (const chunk of process.stdin) {
1292
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
1293
+ }
1294
+ return Buffer.concat(chunks).toString("utf8");
1295
+ }
1296
+ async function confirmOverwrite(org) {
1297
+ if (!process.stdin.isTTY) return true;
1298
+ process.stderr.write(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1299
+ return await new Promise((resolve2) => {
1300
+ process.stdin.setEncoding("utf8");
1301
+ let answered = false;
1302
+ const handler = (data) => {
1303
+ if (answered) return;
1304
+ answered = true;
1305
+ process.stdin.removeListener("data", handler);
1306
+ process.stdin.pause();
1307
+ const trimmed = data.trim().toLowerCase();
1308
+ resolve2(trimmed === "y" || trimmed === "yes");
1309
+ };
1310
+ process.stdin.resume();
1311
+ process.stdin.on("data", handler);
1312
+ });
1313
+ }
1314
+ async function handleAuthRoot(options) {
1315
+ const resolved = resolveOrg({ org: options.org });
1316
+ if (!resolved) {
1317
+ process.stderr.write(`${formatResolutionError()}
1318
+ `);
1319
+ process.exitCode = 3;
1320
+ return;
1321
+ }
1322
+ const org = resolved.org;
1323
+ const wantBrowser = options.browser !== false && !options.fromStdin;
1324
+ if (wantBrowser) {
1325
+ const url = `https://dev.azure.com/${encodeURIComponent(org)}/_usersSettings/tokens`;
1326
+ await openUrl(url);
1327
+ }
1328
+ const raw = options.fromStdin ? await readStdinToString() : await promptForPat();
1329
+ const pat = raw ? normalizePat(raw) : null;
1330
+ if (!pat) {
1331
+ process.stderr.write("No PAT provided. Aborting.\n");
1332
+ process.exitCode = 1;
1333
+ return;
1334
+ }
1335
+ let validation;
1336
+ try {
1337
+ validation = await validatePatAgainstAzdo(pat, org);
1338
+ } catch (err) {
1339
+ process.stderr.write(`Could not reach Azure DevOps to validate PAT: ${err.message}
1340
+ `);
1341
+ process.exitCode = 1;
1342
+ return;
1343
+ }
1344
+ if (!validation.ok) {
1345
+ appendAuthAuditEvent({ event: "auth.validate.fail", org, backend: probeBackend() });
1346
+ process.stderr.write(`PAT validation failed (HTTP ${validation.status}). Token NOT stored.
1347
+ `);
1348
+ process.exitCode = 2;
1349
+ return;
1350
+ }
1351
+ appendAuthAuditEvent({
1352
+ event: "auth.validate.ok",
1353
+ org,
1354
+ backend: probeBackend(),
1355
+ masked_pat: maskedDisplay(pat)
1356
+ });
1357
+ try {
1358
+ const existing = await getPat(org);
1359
+ if (existing !== null) {
1360
+ const overwrite = await confirmOverwrite(org);
1361
+ if (!overwrite) {
1362
+ process.stderr.write("Aborted. Existing PAT preserved.\n");
1363
+ process.exitCode = 1;
1364
+ return;
1365
+ }
1366
+ }
1367
+ await storePat(org, pat);
1368
+ } catch (err) {
1369
+ if (err instanceof CredentialStoreUnavailableError) {
1370
+ process.stderr.write(`${err.message}
1371
+ `);
1372
+ process.exitCode = 4;
1373
+ return;
1374
+ }
1375
+ throw err;
1376
+ }
1377
+ process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
1378
+ `);
1379
+ }
1380
+ async function handleStatus(options, org) {
1381
+ let backend;
1382
+ let value;
1383
+ try {
1384
+ backend = probeBackend();
1385
+ value = await getPat(org);
1386
+ } catch (err) {
1387
+ if (err instanceof CredentialStoreUnavailableError) {
1388
+ process.stderr.write(`${err.message}
1389
+ `);
1390
+ process.exitCode = 4;
1391
+ return;
1392
+ }
1393
+ throw err;
819
1394
  }
820
- lines.push("");
821
- const descriptionText = convertRichText(workItem.description, markdown);
822
- if (short) {
823
- lines.push(...summarizeDescription(descriptionText, label));
1395
+ const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1396
+ const last = storedEvents[storedEvents.length - 1];
1397
+ const updatedAt = last?.ts ?? null;
1398
+ if (!value) {
1399
+ if (options.json) {
1400
+ process.stdout.write(
1401
+ `${JSON.stringify({ org, backend, stored: false, masked: null, updated_at: updatedAt })}
1402
+ `
1403
+ );
1404
+ } else {
1405
+ process.stdout.write(`Organization: ${org}
1406
+ Backend: ${backend}
1407
+ Stored: no
1408
+ `);
1409
+ }
1410
+ process.exitCode = 1;
1411
+ return;
1412
+ }
1413
+ const masked = maskedDisplay(value);
1414
+ if (options.json) {
1415
+ process.stdout.write(
1416
+ `${JSON.stringify({ org, backend, stored: true, masked, updated_at: updatedAt })}
1417
+ `
1418
+ );
824
1419
  } else {
825
- lines.push("Description:", descriptionText);
1420
+ process.stdout.write(
1421
+ `Organization: ${org}
1422
+ Backend: ${backend}
1423
+ Stored: yes
1424
+ Identifier: ${masked}
1425
+ ` + (updatedAt ? `Last updated: ${updatedAt}
1426
+ ` : "")
1427
+ );
826
1428
  }
827
- return lines.join("\n");
828
1429
  }
829
- function createGetItemCommand() {
830
- const command = new Command("get-item");
831
- 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(
832
- async (idStr, options) => {
833
- const id = parseWorkItemId(idStr);
834
- validateOrgProjectPair(options);
835
- let context;
1430
+ async function handleLogout(options, orgFromGlobal) {
1431
+ if (options.all && orgFromGlobal) {
1432
+ process.stderr.write("--org and --all are mutually exclusive.\n");
1433
+ process.exitCode = 1;
1434
+ return;
1435
+ }
1436
+ if (options.all) {
1437
+ let orgs;
1438
+ try {
1439
+ orgs = await listOrgsWithStoredPat();
1440
+ } catch (err) {
1441
+ if (err instanceof CredentialStoreUnavailableError) {
1442
+ process.stderr.write(`${err.message}
1443
+ `);
1444
+ process.exitCode = 4;
1445
+ return;
1446
+ }
1447
+ throw err;
1448
+ }
1449
+ if (orgs.length === 0) {
1450
+ process.stdout.write("No stored PATs to remove.\n");
1451
+ return;
1452
+ }
1453
+ for (const org of orgs) {
836
1454
  try {
837
- context = resolveContext(options);
838
- const credential = await resolvePat();
839
- const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
840
- const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
841
- const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
842
- const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
843
- process.stdout.write(output + "\n");
1455
+ await deletePat(org);
1456
+ process.stdout.write(`PAT removed for org ${org}.
1457
+ `);
844
1458
  } catch (err) {
845
- handleCommandError(err, id, context, "read", false);
1459
+ process.stderr.write(`Failed to remove PAT for org ${org}: ${err.message}
1460
+ `);
1461
+ process.exitCode = 1;
846
1462
  }
847
1463
  }
848
- );
849
- return command;
850
- }
851
-
852
- // src/commands/clear-pat.ts
853
- import { Command as Command2 } from "commander";
854
- function createClearPatCommand() {
855
- const command = new Command2("clear-pat");
856
- command.description("Remove the stored Azure DevOps PAT from the credential store").action(async () => {
857
- const deleted = await deletePat();
858
- if (deleted) {
859
- process.stdout.write("PAT removed from credential store.\n");
1464
+ return;
1465
+ }
1466
+ const resolved = resolveOrg({ org: orgFromGlobal });
1467
+ if (!resolved) {
1468
+ process.stderr.write(`${formatResolutionError()}
1469
+ `);
1470
+ process.exitCode = 3;
1471
+ return;
1472
+ }
1473
+ try {
1474
+ const removed = await deletePat(resolved.org);
1475
+ if (removed) {
1476
+ process.stdout.write(`PAT removed for org ${resolved.org}.
1477
+ `);
860
1478
  } else {
861
- process.stdout.write("No stored PAT found.\n");
1479
+ process.stdout.write(`No stored PAT found for org ${resolved.org}.
1480
+ `);
1481
+ }
1482
+ } catch (err) {
1483
+ if (err instanceof CredentialStoreUnavailableError) {
1484
+ process.stderr.write(`${err.message}
1485
+ `);
1486
+ process.exitCode = 4;
1487
+ return;
1488
+ }
1489
+ throw err;
1490
+ }
1491
+ }
1492
+ function createAuthCommand() {
1493
+ const command = new Command3("auth");
1494
+ command.description("Manage Azure DevOps Personal Access Tokens (PAT) in the OS secret vault");
1495
+ command.option("--org <name>", "Azure DevOps organization (flag wins over auto-detect / config)").option("--from-stdin", "read PAT from stdin instead of prompting", false).option("--no-browser", "do not open the Azure DevOps PAT page in a browser");
1496
+ command.action(async (options) => {
1497
+ await handleAuthRoot(options);
1498
+ });
1499
+ const statusCmd = command.command("status").description("Report whether a PAT is stored for the resolved org (masked, never the full value)").option("--json", "emit a JSON object", false);
1500
+ statusCmd.action(async (options) => {
1501
+ const globals = statusCmd.optsWithGlobals();
1502
+ const resolved = resolveOrg({ org: globals.org });
1503
+ if (!resolved) {
1504
+ process.stderr.write(`${formatResolutionError()}
1505
+ `);
1506
+ process.exitCode = 3;
1507
+ return;
862
1508
  }
1509
+ await handleStatus(options, resolved.org);
1510
+ });
1511
+ const logoutCmd = command.command("logout").description("Remove the stored PAT for an org (or all orgs with --all)").option("--all", "remove the stored PAT for every org", false);
1512
+ logoutCmd.action(async (options) => {
1513
+ const globals = logoutCmd.optsWithGlobals();
1514
+ await handleLogout(options, globals.org);
863
1515
  });
864
1516
  return command;
865
1517
  }
866
1518
 
867
1519
  // src/commands/config.ts
868
- import { Command as Command3 } from "commander";
1520
+ import { Command as Command4 } from "commander";
869
1521
  import { createInterface as createInterface2 } from "readline";
870
1522
  function formatConfigValue(value, unsetFallback = "") {
871
1523
  if (value === void 0) {
@@ -925,9 +1577,9 @@ async function promptForSetting(cfg, setting, ask) {
925
1577
  `);
926
1578
  }
927
1579
  function createConfigCommand() {
928
- const config = new Command3("config");
1580
+ const config = new Command4("config");
929
1581
  config.description("Manage CLI settings");
930
- const set = new Command3("set");
1582
+ const set = new Command4("set");
931
1583
  set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields)").argument("<value>", "setting value").option("--json", "output in JSON format").action((key, value, options) => {
932
1584
  try {
933
1585
  setConfigValue(key, value);
@@ -948,7 +1600,7 @@ function createConfigCommand() {
948
1600
  process.exit(1);
949
1601
  }
950
1602
  });
951
- const get = new Command3("get");
1603
+ const get = new Command4("get");
952
1604
  get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
953
1605
  try {
954
1606
  const value = getConfigValue(key);
@@ -971,7 +1623,7 @@ function createConfigCommand() {
971
1623
  process.exit(1);
972
1624
  }
973
1625
  });
974
- const list = new Command3("list");
1626
+ const list = new Command4("list");
975
1627
  list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
976
1628
  const cfg = loadConfig();
977
1629
  if (options.json) {
@@ -980,7 +1632,7 @@ function createConfigCommand() {
980
1632
  }
981
1633
  writeConfigList(cfg);
982
1634
  });
983
- const unset = new Command3("unset");
1635
+ const unset = new Command4("unset");
984
1636
  unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
985
1637
  try {
986
1638
  unsetConfigValue(key);
@@ -997,7 +1649,7 @@ function createConfigCommand() {
997
1649
  process.exit(1);
998
1650
  }
999
1651
  });
1000
- const wizard = new Command3("wizard");
1652
+ const wizard = new Command4("wizard");
1001
1653
  wizard.description("Interactive wizard to configure all settings").action(async () => {
1002
1654
  if (!process.stdin.isTTY) {
1003
1655
  process.stderr.write(
@@ -1028,9 +1680,9 @@ function createConfigCommand() {
1028
1680
  }
1029
1681
 
1030
1682
  // src/commands/set-state.ts
1031
- import { Command as Command4 } from "commander";
1683
+ import { Command as Command5 } from "commander";
1032
1684
  function createSetStateCommand() {
1033
- const command = new Command4("set-state");
1685
+ const command = new Command5("set-state");
1034
1686
  command.description("Change the state of a work item").argument("<id>", "work item ID").argument("<state>", 'target state (e.g., "Active", "Closed")').option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
1035
1687
  async (idStr, state, options) => {
1036
1688
  const id = parseWorkItemId(idStr);
@@ -1038,7 +1690,7 @@ function createSetStateCommand() {
1038
1690
  let context;
1039
1691
  try {
1040
1692
  context = resolveContext(options);
1041
- const credential = await resolvePat();
1693
+ const credential = await requirePat(context.org);
1042
1694
  const operations = [
1043
1695
  { op: "add", path: "/fields/System.State", value: state }
1044
1696
  ];
@@ -1066,9 +1718,9 @@ function createSetStateCommand() {
1066
1718
  }
1067
1719
 
1068
1720
  // src/commands/assign.ts
1069
- import { Command as Command5 } from "commander";
1721
+ import { Command as Command6 } from "commander";
1070
1722
  function createAssignCommand() {
1071
- const command = new Command5("assign");
1723
+ const command = new Command6("assign");
1072
1724
  command.description("Assign a work item to a user, or unassign it").argument("<id>", "work item ID").argument("[name]", "user display name or email").option("--unassign", "clear the Assigned To field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
1073
1725
  async (idStr, name, options) => {
1074
1726
  const id = parseWorkItemId(idStr);
@@ -1088,7 +1740,7 @@ function createAssignCommand() {
1088
1740
  let context;
1089
1741
  try {
1090
1742
  context = resolveContext(options);
1091
- const credential = await resolvePat();
1743
+ const credential = await requirePat(context.org);
1092
1744
  const value = options.unassign ? "" : name;
1093
1745
  const operations = [
1094
1746
  { op: "add", path: "/fields/System.AssignedTo", value }
@@ -1118,9 +1770,9 @@ function createAssignCommand() {
1118
1770
  }
1119
1771
 
1120
1772
  // src/commands/set-field.ts
1121
- import { Command as Command6 } from "commander";
1773
+ import { Command as Command7 } from "commander";
1122
1774
  function createSetFieldCommand() {
1123
- const command = new Command6("set-field");
1775
+ const command = new Command7("set-field");
1124
1776
  command.description("Set any work item field by its reference name").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Title)").argument("<value>", "new value for the field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
1125
1777
  async (idStr, field, value, options) => {
1126
1778
  const id = parseWorkItemId(idStr);
@@ -1128,7 +1780,7 @@ function createSetFieldCommand() {
1128
1780
  let context;
1129
1781
  try {
1130
1782
  context = resolveContext(options);
1131
- const credential = await resolvePat();
1783
+ const credential = await requirePat(context.org);
1132
1784
  const operations = [
1133
1785
  { op: "add", path: `/fields/${field}`, value }
1134
1786
  ];
@@ -1156,9 +1808,9 @@ function createSetFieldCommand() {
1156
1808
  }
1157
1809
 
1158
1810
  // src/commands/get-md-field.ts
1159
- import { Command as Command7 } from "commander";
1811
+ import { Command as Command8 } from "commander";
1160
1812
  function createGetMdFieldCommand() {
1161
- const command = new Command7("get-md-field");
1813
+ const command = new Command8("get-md-field");
1162
1814
  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(
1163
1815
  async (idStr, field, options) => {
1164
1816
  const id = parseWorkItemId(idStr);
@@ -1166,7 +1818,7 @@ function createGetMdFieldCommand() {
1166
1818
  let context;
1167
1819
  try {
1168
1820
  context = resolveContext(options);
1169
- const credential = await resolvePat();
1821
+ const credential = await requirePat(context.org);
1170
1822
  const value = await getWorkItemFieldValue(context, id, credential.pat, field);
1171
1823
  if (value === null) {
1172
1824
  process.stdout.write("\n");
@@ -1182,8 +1834,8 @@ function createGetMdFieldCommand() {
1182
1834
  }
1183
1835
 
1184
1836
  // src/commands/set-md-field.ts
1185
- import { existsSync, readFileSync as readFileSync2 } from "fs";
1186
- import { Command as Command8 } from "commander";
1837
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
1838
+ import { Command as Command9 } from "commander";
1187
1839
  function fail(message) {
1188
1840
  process.stderr.write(`Error: ${message}
1189
1841
  `);
@@ -1202,11 +1854,11 @@ function resolveContent(inlineContent, options) {
1202
1854
  return null;
1203
1855
  }
1204
1856
  function readFileContent(filePath) {
1205
- if (!existsSync(filePath)) {
1857
+ if (!existsSync2(filePath)) {
1206
1858
  fail(`File not found: ${filePath}`);
1207
1859
  }
1208
1860
  try {
1209
- return readFileSync2(filePath, "utf-8");
1861
+ return readFileSync3(filePath, "utf-8");
1210
1862
  } catch {
1211
1863
  fail(`Cannot read file: ${filePath}`);
1212
1864
  }
@@ -1245,7 +1897,7 @@ function formatOutput(result, options, field) {
1245
1897
  }
1246
1898
  }
1247
1899
  function createSetMdFieldCommand() {
1248
- const command = new Command8("set-md-field");
1900
+ const command = new Command9("set-md-field");
1249
1901
  command.description("Set a work item field with markdown content").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").argument("[content]", "markdown content to set").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").option("--file <path>", "read markdown content from file").action(
1250
1902
  async (idStr, field, inlineContent, options) => {
1251
1903
  const id = parseWorkItemId(idStr);
@@ -1254,7 +1906,7 @@ function createSetMdFieldCommand() {
1254
1906
  let context;
1255
1907
  try {
1256
1908
  context = resolveContext(options);
1257
- const credential = await resolvePat();
1909
+ const credential = await requirePat(context.org);
1258
1910
  const operations = [
1259
1911
  { op: "add", path: `/fields/${field}`, value: content },
1260
1912
  { op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
@@ -1270,8 +1922,8 @@ function createSetMdFieldCommand() {
1270
1922
  }
1271
1923
 
1272
1924
  // src/commands/upsert.ts
1273
- import { existsSync as existsSync2, readFileSync as readFileSync3, unlinkSync } from "fs";
1274
- import { Command as Command9 } from "commander";
1925
+ import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
1926
+ import { Command as Command10 } from "commander";
1275
1927
 
1276
1928
  // src/services/task-document.ts
1277
1929
  var FIELD_ALIASES = /* @__PURE__ */ new Map([
@@ -1434,12 +2086,12 @@ function loadSourceContent(options) {
1434
2086
  return { content: options.content };
1435
2087
  }
1436
2088
  const filePath = options.file;
1437
- if (!existsSync2(filePath)) {
2089
+ if (!existsSync3(filePath)) {
1438
2090
  fail2(`File not found: ${filePath}`);
1439
2091
  }
1440
2092
  try {
1441
2093
  return {
1442
- content: readFileSync3(filePath, "utf-8"),
2094
+ content: readFileSync4(filePath, "utf-8"),
1443
2095
  sourceFile: filePath
1444
2096
  };
1445
2097
  } catch {
@@ -1555,7 +2207,7 @@ function handleUpsertError(err, id, context) {
1555
2207
  process.exit(1);
1556
2208
  }
1557
2209
  function createUpsertCommand() {
1558
- const command = new Command9("upsert");
2210
+ const command = new Command10("upsert");
1559
2211
  command.description("Create or update a work item from a markdown document").argument("[id]", "work item ID to update; omit to create a new work item").option("--content <markdown>", "task document content").option("--file <path>", "read task document from file").option("--type <workItemType>", "create mode work item type (defaults to Task)").option("--json", "output result as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(async (idStr, options) => {
1560
2212
  validateOrgProjectPair(options);
1561
2213
  const id = idStr === void 0 ? void 0 : parseWorkItemId(idStr);
@@ -1570,7 +2222,7 @@ function createUpsertCommand() {
1570
2222
  ensureTitleForCreate(document.fields);
1571
2223
  }
1572
2224
  const operations = toPatchOperations(document.fields, action);
1573
- const credential = await resolvePat();
2225
+ const credential = await requirePat(context.org);
1574
2226
  let writeResult;
1575
2227
  if (action === "created") {
1576
2228
  writeResult = await createWorkItem(context, createType, credential.pat, operations);
@@ -1596,7 +2248,7 @@ function createUpsertCommand() {
1596
2248
  }
1597
2249
 
1598
2250
  // src/commands/list-fields.ts
1599
- import { Command as Command10 } from "commander";
2251
+ import { Command as Command11 } from "commander";
1600
2252
  function stringifyValue(value) {
1601
2253
  if (value === null || value === void 0) return "";
1602
2254
  if (typeof value === "object") return JSON.stringify(value);
@@ -1628,7 +2280,7 @@ function formatFieldList(fields) {
1628
2280
  }).join("\n");
1629
2281
  }
1630
2282
  function createListFieldsCommand() {
1631
- const command = new Command10("list-fields");
2283
+ const command = new Command11("list-fields");
1632
2284
  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(
1633
2285
  async (idStr, options) => {
1634
2286
  const id = parseWorkItemId(idStr);
@@ -1636,7 +2288,7 @@ function createListFieldsCommand() {
1636
2288
  let context;
1637
2289
  try {
1638
2290
  context = resolveContext(options);
1639
- const credential = await resolvePat();
2291
+ const credential = await requirePat(context.org);
1640
2292
  const fields = await getWorkItemFields(context, id, credential.pat);
1641
2293
  if (options.json) {
1642
2294
  process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
@@ -1655,7 +2307,7 @@ function createListFieldsCommand() {
1655
2307
  }
1656
2308
 
1657
2309
  // src/commands/pr.ts
1658
- import { Command as Command11 } from "commander";
2310
+ import { Command as Command12 } from "commander";
1659
2311
 
1660
2312
  // src/services/pr-client.ts
1661
2313
  function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
@@ -1672,6 +2324,13 @@ function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
1672
2324
  }
1673
2325
  return url;
1674
2326
  }
2327
+ function buildPullRequestStatusesUrl(context, repo, prId) {
2328
+ const url = new URL(
2329
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/statuses`
2330
+ );
2331
+ url.searchParams.set("api-version", "7.1");
2332
+ return url;
2333
+ }
1675
2334
  function mapPullRequest(repo, pullRequest) {
1676
2335
  return {
1677
2336
  id: pullRequest.pullRequestId,
@@ -1681,7 +2340,36 @@ function mapPullRequest(repo, pullRequest) {
1681
2340
  targetRefName: pullRequest.targetRefName,
1682
2341
  status: pullRequest.status,
1683
2342
  createdBy: pullRequest.createdBy?.displayName ?? null,
1684
- url: pullRequest._links.web.href
2343
+ url: pullRequest._links?.web?.href ?? null
2344
+ };
2345
+ }
2346
+ function mapPullRequestCheckName(status) {
2347
+ const genre = status.context?.genre?.trim();
2348
+ const name = status.context?.name?.trim();
2349
+ if (genre && name) {
2350
+ return `${genre}/${name}`;
2351
+ }
2352
+ if (name) {
2353
+ return name;
2354
+ }
2355
+ if (genre) {
2356
+ return genre;
2357
+ }
2358
+ return `Status #${status.id}`;
2359
+ }
2360
+ function mapPullRequestCheck(status) {
2361
+ if (status.state === "notApplicable" || status.state === "notSet") {
2362
+ return null;
2363
+ }
2364
+ return {
2365
+ id: status.id,
2366
+ state: status.state,
2367
+ name: mapPullRequestCheckName(status),
2368
+ description: status.description ?? null,
2369
+ targetUrl: status.targetUrl ?? null,
2370
+ createdBy: status.createdBy?.displayName ?? null,
2371
+ createdAt: status.creationDate ?? null,
2372
+ updatedAt: status.updatedDate ?? null
1685
2373
  };
1686
2374
  }
1687
2375
  function mapComment(comment) {
@@ -1697,9 +2385,6 @@ function mapComment(comment) {
1697
2385
  };
1698
2386
  }
1699
2387
  function mapThread(thread) {
1700
- if (thread.status !== "active" && thread.status !== "pending") {
1701
- return null;
1702
- }
1703
2388
  const comments = thread.comments.map(mapComment).filter((comment) => comment !== null);
1704
2389
  if (comments.length === 0) {
1705
2390
  return null;
@@ -1711,12 +2396,49 @@ function mapThread(thread) {
1711
2396
  comments
1712
2397
  };
1713
2398
  }
2399
+ function toActiveCommentThread(thread) {
2400
+ return {
2401
+ id: thread.id,
2402
+ status: thread.status,
2403
+ threadContext: thread.threadContext?.filePath ?? null,
2404
+ comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
2405
+ };
2406
+ }
2407
+ var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
2408
+ function isThreadResolved(status) {
2409
+ return RESOLVED_THREAD_STATUSES.has(status);
2410
+ }
1714
2411
  async function readJsonResponse(response) {
1715
2412
  if (!response.ok) {
1716
2413
  throw new Error(`HTTP_${response.status}`);
1717
2414
  }
1718
2415
  return response.json();
1719
2416
  }
2417
+ async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
2418
+ const url = new URL(
2419
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
2420
+ );
2421
+ url.searchParams.set("api-version", "7.1");
2422
+ const response = await fetchWithErrors(url.toString(), {
2423
+ method: "PATCH",
2424
+ headers: {
2425
+ ...authHeaders(pat),
2426
+ "Content-Type": "application/json"
2427
+ },
2428
+ body: JSON.stringify({ status })
2429
+ });
2430
+ const data = await readJsonResponse(response);
2431
+ return toActiveCommentThread(data);
2432
+ }
2433
+ async function getPullRequestById(context, repo, pat, prId) {
2434
+ const url = new URL(
2435
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
2436
+ );
2437
+ url.searchParams.set("api-version", "7.1");
2438
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
2439
+ const data = await readJsonResponse(response);
2440
+ return mapPullRequest(repo, data);
2441
+ }
1720
2442
  async function listPullRequests(context, repo, pat, sourceBranch, opts) {
1721
2443
  const response = await fetchWithErrors(
1722
2444
  buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
@@ -1725,6 +2447,14 @@ async function listPullRequests(context, repo, pat, sourceBranch, opts) {
1725
2447
  const data = await readJsonResponse(response);
1726
2448
  return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
1727
2449
  }
2450
+ async function getPullRequestChecks(context, repo, pat, prId) {
2451
+ const response = await fetchWithErrors(
2452
+ buildPullRequestStatusesUrl(context, repo, prId).toString(),
2453
+ { headers: authHeaders(pat) }
2454
+ );
2455
+ const data = await readJsonResponse(response);
2456
+ return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
2457
+ }
1728
2458
  async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
1729
2459
  const existing = await listPullRequests(context, repo, pat, sourceBranch, {
1730
2460
  status: "active",
@@ -1778,56 +2508,86 @@ async function getPullRequestThreads(context, repo, pat, prId) {
1778
2508
  }
1779
2509
 
1780
2510
  // src/commands/pr.ts
2511
+ function parsePositivePrNumber(raw) {
2512
+ if (!/^\d+$/.test(raw)) {
2513
+ return null;
2514
+ }
2515
+ const n = Number.parseInt(raw, 10);
2516
+ return Number.isFinite(n) && n > 0 ? n : null;
2517
+ }
1781
2518
  function formatBranchName(refName) {
1782
2519
  return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
1783
2520
  }
1784
2521
  function writeError(message) {
1785
2522
  process.stderr.write(`Error: ${message}
1786
2523
  `);
1787
- process.exit(1);
2524
+ process.exitCode = 1;
1788
2525
  }
1789
2526
  function handlePrCommandError(err, context, mode = "read") {
1790
2527
  const error = err instanceof Error ? err : new Error(String(err));
1791
2528
  if (error.message === "AUTH_FAILED") {
1792
2529
  const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
1793
2530
  writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`);
2531
+ return;
1794
2532
  }
1795
2533
  if (error.message === "PERMISSION_DENIED") {
1796
2534
  writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`);
2535
+ return;
1797
2536
  }
1798
2537
  if (error.message === "NETWORK_ERROR") {
1799
2538
  writeError("Could not connect to Azure DevOps. Check your network connection.");
2539
+ return;
1800
2540
  }
1801
2541
  if (error.message.startsWith("NOT_FOUND")) {
1802
2542
  writeError(`Azure DevOps repository not found in ${context?.org}/${context?.project}.`);
2543
+ return;
1803
2544
  }
1804
2545
  if (error.message.startsWith("HTTP_")) {
1805
2546
  writeError(`Azure DevOps request failed with ${error.message}.`);
2547
+ return;
1806
2548
  }
1807
2549
  writeError(error.message);
1808
2550
  }
2551
+ function formatPullRequestChecks(checks) {
2552
+ if (checks.length === 0) {
2553
+ return ["Checks: none reported by Azure DevOps"];
2554
+ }
2555
+ const lines = ["Checks:"];
2556
+ for (const check of checks) {
2557
+ lines.push(`- [${check.state}] ${check.name}`);
2558
+ if ((check.state === "failed" || check.state === "error") && check.description) {
2559
+ lines.push(` Detail: ${check.description}`);
2560
+ }
2561
+ }
2562
+ return lines;
2563
+ }
1809
2564
  function formatPullRequestBlock(pullRequest) {
1810
2565
  return [
1811
2566
  `#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
1812
2567
  `${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
1813
- pullRequest.url
2568
+ pullRequest.url ?? "\u2014",
2569
+ ...formatPullRequestChecks(pullRequest.checks)
1814
2570
  ].join("\n");
1815
2571
  }
2572
+ function threadStatusLabel(status) {
2573
+ return isThreadResolved(status) ? "resolved" : status;
2574
+ }
1816
2575
  function formatThreads(prId, title, threads) {
1817
- const lines = [`Active comments for pull request #${prId}: ${title}`];
2576
+ const lines = [`Comment threads for pull request #${prId}: ${title}`];
1818
2577
  for (const thread of threads) {
1819
- lines.push("", `Thread #${thread.id} [${thread.status}] ${thread.threadContext ?? "(general)"}`);
2578
+ lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${thread.threadContext ?? "(general)"}`);
1820
2579
  for (const comment of thread.comments) {
1821
2580
  lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
1822
2581
  }
1823
2582
  }
1824
2583
  return lines.join("\n");
1825
2584
  }
1826
- async function resolvePrCommandContext(options) {
2585
+ async function resolvePrCommandContext(options, resolveOpts = {}) {
2586
+ const requireBranch = resolveOpts.requireBranch ?? true;
1827
2587
  const context = resolveContext(options);
1828
2588
  const repo = detectRepoName();
1829
- const branch = getCurrentBranch();
1830
- const credential = await resolvePat();
2589
+ const branch = requireBranch ? getCurrentBranch() : null;
2590
+ const credential = await requirePat(context.org);
1831
2591
  return {
1832
2592
  context,
1833
2593
  repo,
@@ -1836,27 +2596,33 @@ async function resolvePrCommandContext(options) {
1836
2596
  };
1837
2597
  }
1838
2598
  function createPrStatusCommand() {
1839
- const command = new Command11("status");
2599
+ const command = new Command12("status");
1840
2600
  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) => {
1841
2601
  validateOrgProjectPair(options);
1842
2602
  let context;
1843
2603
  try {
1844
2604
  const resolved = await resolvePrCommandContext(options);
1845
2605
  context = resolved.context;
1846
- const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch);
1847
- const { branch, repo } = resolved;
1848
- const result = { branch, repository: repo, pullRequests };
2606
+ const branch = resolved.branch;
2607
+ const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
2608
+ const pullRequestsWithChecks = await Promise.all(
2609
+ pullRequests.map(async (pullRequest) => ({
2610
+ ...pullRequest,
2611
+ checks: await getPullRequestChecks(resolved.context, resolved.repo, resolved.pat, pullRequest.id)
2612
+ }))
2613
+ );
2614
+ const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
1849
2615
  if (options.json) {
1850
2616
  process.stdout.write(`${JSON.stringify(result, null, 2)}
1851
2617
  `);
1852
2618
  return;
1853
2619
  }
1854
- if (pullRequests.length === 0) {
2620
+ if (pullRequestsWithChecks.length === 0) {
1855
2621
  process.stdout.write(`No pull requests found for branch ${branch}.
1856
2622
  `);
1857
2623
  return;
1858
2624
  }
1859
- process.stdout.write(`${pullRequests.map(formatPullRequestBlock).join("\n\n")}
2625
+ process.stdout.write(`${pullRequestsWithChecks.map(formatPullRequestBlock).join("\n\n")}
1860
2626
  `);
1861
2627
  } catch (err) {
1862
2628
  handlePrCommandError(err, context, "read");
@@ -1865,16 +2631,18 @@ function createPrStatusCommand() {
1865
2631
  return command;
1866
2632
  }
1867
2633
  function createPrOpenCommand() {
1868
- const command = new Command11("open");
2634
+ const command = new Command12("open");
1869
2635
  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) => {
1870
2636
  validateOrgProjectPair(options);
1871
2637
  const title = options.title?.trim();
1872
2638
  if (!title) {
1873
2639
  writeError("--title is required for pull request creation.");
2640
+ return;
1874
2641
  }
1875
2642
  const description = options.description?.trim();
1876
2643
  if (!description) {
1877
2644
  writeError("--description is required for pull request creation.");
2645
+ return;
1878
2646
  }
1879
2647
  let context;
1880
2648
  try {
@@ -1882,12 +2650,14 @@ function createPrOpenCommand() {
1882
2650
  context = resolved.context;
1883
2651
  if (resolved.branch === "develop") {
1884
2652
  writeError("Pull request creation requires a source branch other than develop.");
2653
+ return;
1885
2654
  }
2655
+ const openBranch = resolved.branch;
1886
2656
  const result = await openPullRequest(
1887
2657
  resolved.context,
1888
2658
  resolved.repo,
1889
2659
  resolved.pat,
1890
- resolved.branch,
2660
+ openBranch,
1891
2661
  title,
1892
2662
  description
1893
2663
  );
@@ -1898,19 +2668,20 @@ function createPrOpenCommand() {
1898
2668
  }
1899
2669
  if (result.created) {
1900
2670
  process.stdout.write(`Created pull request #${result.pullRequest.id}: ${result.pullRequest.title}
1901
- ${result.pullRequest.url}
2671
+ ${result.pullRequest.url ?? "\u2014"}
1902
2672
  `);
1903
2673
  return;
1904
2674
  }
1905
2675
  process.stdout.write(
1906
2676
  `Active pull request already exists for ${resolved.branch} -> develop: #${result.pullRequest.id}
1907
- ${result.pullRequest.url}
2677
+ ${result.pullRequest.url ?? "\u2014"}
1908
2678
  `
1909
2679
  );
1910
2680
  } catch (err) {
1911
2681
  if (err instanceof Error && err.message.startsWith("AMBIGUOUS_PRS:")) {
1912
2682
  const ids = err.message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
1913
2683
  writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
2684
+ return;
1914
2685
  }
1915
2686
  handlePrCommandError(err, context, "write");
1916
2687
  }
@@ -1918,34 +2689,66 @@ ${result.pullRequest.url}
1918
2689
  return command;
1919
2690
  }
1920
2691
  function createPrCommentsCommand() {
1921
- const command = new Command11("comments");
1922
- 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) => {
2692
+ const command = new Command12("comments");
2693
+ command.description("List pull request comment threads for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--json", "output JSON").action(async (options) => {
1923
2694
  validateOrgProjectPair(options);
1924
2695
  let context;
2696
+ let explicitPrId = null;
2697
+ if (options.prNumber !== void 0) {
2698
+ explicitPrId = parsePositivePrNumber(options.prNumber);
2699
+ if (explicitPrId === null) {
2700
+ writeError(`Invalid --pr-number "${options.prNumber}"; expected a positive integer.`);
2701
+ return;
2702
+ }
2703
+ }
1925
2704
  try {
1926
- const resolved = await resolvePrCommandContext(options);
2705
+ const resolved = await resolvePrCommandContext(options, { requireBranch: explicitPrId === null });
1927
2706
  context = resolved.context;
1928
- const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
1929
- status: "active"
1930
- });
1931
- if (pullRequests.length === 0) {
1932
- writeError(`No active pull request found for branch ${resolved.branch}.`);
1933
- }
1934
- if (pullRequests.length > 1) {
1935
- const ids = pullRequests.map((pullRequest2) => `#${pullRequest2.id}`).join(", ");
1936
- writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
2707
+ let pullRequest;
2708
+ let branchLabel;
2709
+ if (explicitPrId !== null) {
2710
+ try {
2711
+ pullRequest = await getPullRequestById(resolved.context, resolved.repo, resolved.pat, explicitPrId);
2712
+ } catch (err) {
2713
+ if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
2714
+ writeError(`Pull request #${explicitPrId} not found in ${resolved.context.org}/${resolved.context.project}/${resolved.repo}.`);
2715
+ return;
2716
+ }
2717
+ throw err;
2718
+ }
2719
+ branchLabel = resolved.branch ?? pullRequest.sourceRefName;
2720
+ } else {
2721
+ const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
2722
+ status: "active"
2723
+ });
2724
+ if (pullRequests.length === 0) {
2725
+ writeError(`No active pull request found for branch ${resolved.branch}.`);
2726
+ return;
2727
+ }
2728
+ if (pullRequests.length > 1) {
2729
+ const ids = pullRequests.map((pr) => `#${pr.id}`).join(", ");
2730
+ writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
2731
+ return;
2732
+ }
2733
+ pullRequest = pullRequests[0];
2734
+ branchLabel = resolved.branch;
1937
2735
  }
1938
- const pullRequest = pullRequests[0];
1939
- const threads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
1940
- const result = { branch: resolved.branch, pullRequest, threads };
2736
+ const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
2737
+ const threads = options.hideResolved ? allThreads.filter((thread) => !isThreadResolved(thread.status)) : allThreads;
2738
+ const result = { branch: branchLabel, pullRequest, threads };
1941
2739
  if (options.json) {
1942
2740
  process.stdout.write(`${JSON.stringify(result, null, 2)}
1943
2741
  `);
1944
2742
  return;
1945
2743
  }
1946
2744
  if (threads.length === 0) {
1947
- process.stdout.write(`Pull request #${pullRequest.id} has no active comments.
2745
+ if (options.hideResolved && allThreads.length > 0) {
2746
+ process.stdout.write(`Pull request #${pullRequest.id} has no unresolved comment threads (${allThreads.length} resolved thread${allThreads.length === 1 ? "" : "s"} hidden by --hide-resolved).
2747
+ `);
2748
+ } else {
2749
+ process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
1948
2750
  `);
2751
+ }
1949
2752
  return;
1950
2753
  }
1951
2754
  process.stdout.write(`${formatThreads(pullRequest.id, pullRequest.title, threads)}
@@ -1956,17 +2759,136 @@ function createPrCommentsCommand() {
1956
2759
  });
1957
2760
  return command;
1958
2761
  }
2762
+ async function resolveThreadTarget(threadIdRaw, options) {
2763
+ validateOrgProjectPair(options);
2764
+ const threadId = parsePositivePrNumber(threadIdRaw);
2765
+ if (threadId === null) {
2766
+ writeError(`Invalid thread id "${threadIdRaw}"; expected a positive integer.`);
2767
+ return null;
2768
+ }
2769
+ let explicitPrId = null;
2770
+ if (options.prNumber !== void 0) {
2771
+ explicitPrId = parsePositivePrNumber(options.prNumber);
2772
+ if (explicitPrId === null) {
2773
+ writeError(`Invalid --pr-number "${options.prNumber}"; expected a positive integer.`);
2774
+ return null;
2775
+ }
2776
+ }
2777
+ const resolved = await resolvePrCommandContext(options, { requireBranch: explicitPrId === null });
2778
+ let pullRequest;
2779
+ if (explicitPrId !== null) {
2780
+ try {
2781
+ pullRequest = await getPullRequestById(resolved.context, resolved.repo, resolved.pat, explicitPrId);
2782
+ } catch (err) {
2783
+ if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
2784
+ writeError(`Pull request #${explicitPrId} not found in ${resolved.context.org}/${resolved.context.project}/${resolved.repo}.`);
2785
+ return null;
2786
+ }
2787
+ throw err;
2788
+ }
2789
+ } else {
2790
+ const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
2791
+ status: "active"
2792
+ });
2793
+ if (pullRequests.length === 0) {
2794
+ writeError(`No active pull request found for branch ${resolved.branch}.`);
2795
+ return null;
2796
+ }
2797
+ if (pullRequests.length > 1) {
2798
+ const ids = pullRequests.map((pr) => `#${pr.id}`).join(", ");
2799
+ writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
2800
+ return null;
2801
+ }
2802
+ pullRequest = pullRequests[0];
2803
+ }
2804
+ return { context: resolved.context, repo: resolved.repo, pat: resolved.pat, pullRequest, threadId };
2805
+ }
2806
+ async function runThreadStateChange(threadIdRaw, options, direction) {
2807
+ let context;
2808
+ try {
2809
+ const target = await resolveThreadTarget(threadIdRaw, options);
2810
+ if (target === null) {
2811
+ return;
2812
+ }
2813
+ context = target.context;
2814
+ const threads = await getPullRequestThreads(target.context, target.repo, target.pat, target.pullRequest.id);
2815
+ const thread = threads.find((t) => t.id === target.threadId);
2816
+ if (!thread) {
2817
+ writeError(`Thread #${target.threadId} not found on pull request #${target.pullRequest.id}.`);
2818
+ return;
2819
+ }
2820
+ const alreadyInTargetState = direction === "resolve" ? isThreadResolved(thread.status) : !isThreadResolved(thread.status);
2821
+ const targetStatus = direction === "resolve" ? "fixed" : "active";
2822
+ if (alreadyInTargetState) {
2823
+ const humanLabel = direction === "resolve" ? "resolved" : "active";
2824
+ const noopResult = {
2825
+ pullRequestId: target.pullRequest.id,
2826
+ threadId: target.threadId,
2827
+ status: thread.status,
2828
+ noop: true
2829
+ };
2830
+ if (options.json) {
2831
+ process.stdout.write(`${JSON.stringify(noopResult, null, 2)}
2832
+ `);
2833
+ return;
2834
+ }
2835
+ process.stdout.write(`Thread #${target.threadId} is already ${humanLabel} on pull request #${target.pullRequest.id} (current status: ${thread.status}).
2836
+ `);
2837
+ return;
2838
+ }
2839
+ const updated = await patchThreadStatus(
2840
+ target.context,
2841
+ target.repo,
2842
+ target.pat,
2843
+ target.pullRequest.id,
2844
+ target.threadId,
2845
+ targetStatus
2846
+ );
2847
+ const result = {
2848
+ pullRequestId: target.pullRequest.id,
2849
+ threadId: target.threadId,
2850
+ status: targetStatus,
2851
+ noop: false
2852
+ };
2853
+ if (options.json) {
2854
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
2855
+ `);
2856
+ return;
2857
+ }
2858
+ const verb = direction === "resolve" ? "resolved" : "reopened";
2859
+ process.stdout.write(`Thread #${target.threadId} ${verb} on pull request #${target.pullRequest.id} (status: ${updated.status}).
2860
+ `);
2861
+ } catch (err) {
2862
+ handlePrCommandError(err, context, "write");
2863
+ }
2864
+ }
2865
+ function createPrCommentResolveCommand() {
2866
+ const command = new Command12("comment-resolve");
2867
+ command.description("Mark a pull request comment thread as resolved").argument("<threadId>", "numeric id of the thread to resolve").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--json", "output JSON").action(async (threadIdRaw, options) => {
2868
+ await runThreadStateChange(threadIdRaw, options, "resolve");
2869
+ });
2870
+ return command;
2871
+ }
2872
+ function createPrCommentReopenCommand() {
2873
+ const command = new Command12("comment-reopen");
2874
+ command.description("Reopen (set to active) a previously resolved pull request comment thread").argument("<threadId>", "numeric id of the thread to reopen").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--json", "output JSON").action(async (threadIdRaw, options) => {
2875
+ await runThreadStateChange(threadIdRaw, options, "reopen");
2876
+ });
2877
+ return command;
2878
+ }
1959
2879
  function createPrCommand() {
1960
- const command = new Command11("pr");
2880
+ const command = new Command12("pr");
1961
2881
  command.description("Manage Azure DevOps pull requests");
1962
2882
  command.addCommand(createPrStatusCommand());
1963
2883
  command.addCommand(createPrOpenCommand());
1964
2884
  command.addCommand(createPrCommentsCommand());
2885
+ command.addCommand(createPrCommentResolveCommand());
2886
+ command.addCommand(createPrCommentReopenCommand());
1965
2887
  return command;
1966
2888
  }
1967
2889
 
1968
2890
  // src/commands/comments.ts
1969
- import { Command as Command12 } from "commander";
2891
+ import { Command as Command13 } from "commander";
1970
2892
  function writeError2(message) {
1971
2893
  process.stderr.write(`Error: ${message}
1972
2894
  `);
@@ -1977,22 +2899,23 @@ function formatCommentHeader(comment) {
1977
2899
  const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
1978
2900
  return `Comment #${comment.id} by ${author} at ${timestamp}`;
1979
2901
  }
1980
- function formatComments(result) {
2902
+ function formatComments(result, convertMarkdown) {
1981
2903
  const lines = [`Comments for work item #${result.workItemId}`];
1982
2904
  for (const comment of result.comments) {
1983
- lines.push("", formatCommentHeader(comment), comment.text);
2905
+ const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
2906
+ lines.push("", formatCommentHeader(comment), text);
1984
2907
  }
1985
2908
  return lines.join("\n");
1986
2909
  }
1987
2910
  function createCommentsListCommand() {
1988
- const command = new Command12("list");
1989
- command.description("List visible comments for a work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (idStr, options) => {
2911
+ const command = new Command13("list");
2912
+ command.description("List visible comments for a work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "convert HTML comment bodies to markdown").action(async (idStr, options) => {
1990
2913
  validateOrgProjectPair(options);
1991
2914
  const id = parseWorkItemId(idStr);
1992
2915
  let context;
1993
2916
  try {
1994
2917
  context = resolveContext(options);
1995
- const credential = await resolvePat();
2918
+ const credential = await requirePat(context.org);
1996
2919
  const result = await listWorkItemComments(context, id, credential.pat);
1997
2920
  if (options.json) {
1998
2921
  process.stdout.write(`${JSON.stringify(result, null, 2)}
@@ -2004,7 +2927,7 @@ function createCommentsListCommand() {
2004
2927
  `);
2005
2928
  return;
2006
2929
  }
2007
- process.stdout.write(`${formatComments(result)}
2930
+ process.stdout.write(`${formatComments(result, options.markdown === true)}
2008
2931
  `);
2009
2932
  } catch (err) {
2010
2933
  handleCommandError(err, id, context, "read");
@@ -2013,8 +2936,8 @@ function createCommentsListCommand() {
2013
2936
  return command;
2014
2937
  }
2015
2938
  function createCommentsAddCommand() {
2016
- const command = new Command12("add");
2017
- command.description("Add a comment to a work item").argument("<id>", "work item ID").argument("<text>", "comment text").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (idStr, text, options) => {
2939
+ const command = new Command13("add");
2940
+ command.description("Add a comment to a work item").argument("<id>", "work item ID").argument("<text>", "comment text").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "post comment as markdown").action(async (idStr, text, options) => {
2018
2941
  validateOrgProjectPair(options);
2019
2942
  const id = parseWorkItemId(idStr);
2020
2943
  if (text.trim() === "") {
@@ -2023,8 +2946,9 @@ function createCommentsAddCommand() {
2023
2946
  let context;
2024
2947
  try {
2025
2948
  context = resolveContext(options);
2026
- const credential = await resolvePat();
2027
- const result = await addWorkItemComment(context, id, credential.pat, text);
2949
+ const credential = await requirePat(context.org);
2950
+ const format = options.markdown === true ? "markdown" : "html";
2951
+ const result = await addWorkItemComment(context, id, credential.pat, text, format);
2028
2952
  if (options.json) {
2029
2953
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2030
2954
  `);
@@ -2039,17 +2963,65 @@ function createCommentsAddCommand() {
2039
2963
  return command;
2040
2964
  }
2041
2965
  function createCommentsCommand() {
2042
- const command = new Command12("comments");
2966
+ const command = new Command13("comments");
2043
2967
  command.description("Manage Azure DevOps work item comments");
2044
2968
  command.addCommand(createCommentsListCommand());
2045
2969
  command.addCommand(createCommentsAddCommand());
2046
2970
  return command;
2047
2971
  }
2048
2972
 
2973
+ // src/commands/download-attachment.ts
2974
+ import { Command as Command14 } from "commander";
2975
+ import { writeFile } from "fs/promises";
2976
+ import { existsSync as existsSync4 } from "fs";
2977
+ import { join as join2 } from "path";
2978
+ function createDownloadAttachmentCommand() {
2979
+ const command = new Command14("download-attachment");
2980
+ command.description("Download an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to download").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--output <dir>", "target directory for the downloaded file").action(
2981
+ async (idStr, filename, options) => {
2982
+ const id = parseWorkItemId(idStr);
2983
+ validateOrgProjectPair(options);
2984
+ let context;
2985
+ try {
2986
+ context = resolveContext(options);
2987
+ const credential = await requirePat(context.org);
2988
+ const outputDir = options.output ?? ".";
2989
+ if (!existsSync4(outputDir)) {
2990
+ process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
2991
+ `);
2992
+ process.exit(1);
2993
+ }
2994
+ const workItem = await getWorkItem(context, id, credential.pat);
2995
+ const attachment = workItem.attachments?.find(
2996
+ (a) => a.name === filename
2997
+ );
2998
+ if (!attachment) {
2999
+ process.stderr.write(
3000
+ `Error: Attachment "${filename}" not found on work item ${id}.
3001
+ `
3002
+ );
3003
+ process.exit(1);
3004
+ }
3005
+ const data = await downloadAttachment(attachment.url, credential.pat);
3006
+ const outputPath = join2(outputDir, filename);
3007
+ await writeFile(outputPath, Buffer.from(data));
3008
+ process.stdout.write(
3009
+ `Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
3010
+ `
3011
+ );
3012
+ } catch (err) {
3013
+ handleCommandError(err, id, context, "read", false);
3014
+ }
3015
+ }
3016
+ );
3017
+ return command;
3018
+ }
3019
+
2049
3020
  // src/index.ts
2050
- var program = new Command13();
3021
+ var program = new Command15();
2051
3022
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
2052
3023
  program.addCommand(createGetItemCommand());
3024
+ program.addCommand(createAuthCommand());
2053
3025
  program.addCommand(createClearPatCommand());
2054
3026
  program.addCommand(createConfigCommand());
2055
3027
  program.addCommand(createSetStateCommand());
@@ -2061,6 +3033,7 @@ program.addCommand(createUpsertCommand());
2061
3033
  program.addCommand(createListFieldsCommand());
2062
3034
  program.addCommand(createPrCommand());
2063
3035
  program.addCommand(createCommentsCommand());
3036
+ program.addCommand(createDownloadAttachmentCommand());
2064
3037
  program.showHelpAfterError();
2065
3038
  program.parse();
2066
3039
  if (process.argv.length <= 2) {