azdo-cli 0.11.0 → 0.13.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.
package/dist/index.js CHANGED
@@ -6,15 +6,19 @@ import {
6
6
  SETTINGS,
7
7
  appendAuthAuditEvent,
8
8
  buildScopeString,
9
+ copyOrgScope,
9
10
  defaultScopes,
11
+ deleteOrgScope,
10
12
  deletePat,
11
13
  firstPartyShippedScopes,
12
14
  getConfigValue,
15
+ getOrgScopedValue,
13
16
  getPat,
14
17
  getStoredCredential,
15
18
  listOrgsWithStoredPat,
16
19
  loadConfig,
17
20
  maskedDisplay,
21
+ moveOrgScope,
18
22
  normalizePat,
19
23
  openUrl,
20
24
  probeBackend,
@@ -22,16 +26,19 @@ import {
22
26
  readTokenResponse,
23
27
  refreshIfNeeded,
24
28
  resolveOAuthConfig,
29
+ resolveScopedConfig,
25
30
  runAuthCodeFlow,
26
31
  setConfigValue,
32
+ setOrgScopedValue,
27
33
  storeOAuthCredential,
28
34
  storePat,
29
35
  tokenResponseToCredential,
30
- unsetConfigValue
31
- } from "./chunk-C7RAZJHV.js";
36
+ unsetConfigValue,
37
+ unsetOrgScopedValue
38
+ } from "./chunk-XVXMDWQE.js";
32
39
 
33
40
  // src/index.ts
34
- import { Command as Command15 } from "commander";
41
+ import { Command as Command17 } from "commander";
35
42
 
36
43
  // src/version.ts
37
44
  import { readFileSync } from "fs";
@@ -44,6 +51,78 @@ var version = pkg.version;
44
51
  // src/commands/get-item.ts
45
52
  import { Command } from "commander";
46
53
 
54
+ // src/services/trace-writer.ts
55
+ import { openSync, writeSync, closeSync } from "fs";
56
+ import { platform } from "os";
57
+ var REDACTED = "[REDACTED]";
58
+ var SENSITIVE_HEADER = /^(authorization|x-.*token)$/i;
59
+ var SENSITIVE_QUERY_PARAM = /^(token|pat)$/i;
60
+ var SENSITIVE_BODY_FIELD = /^(token|accessToken|pat)$/;
61
+ function redactHeaders(headers) {
62
+ const out = {};
63
+ for (const [key, value] of Object.entries(headers)) {
64
+ out[key] = SENSITIVE_HEADER.test(key) ? REDACTED : value;
65
+ }
66
+ return out;
67
+ }
68
+ function redactUrl(url) {
69
+ try {
70
+ const u = new URL(url);
71
+ for (const [key] of u.searchParams.entries()) {
72
+ if (SENSITIVE_QUERY_PARAM.test(key)) {
73
+ u.searchParams.set(key, REDACTED);
74
+ }
75
+ }
76
+ return u.toString();
77
+ } catch {
78
+ return url;
79
+ }
80
+ }
81
+ function redactBody(body) {
82
+ if (body === null) return null;
83
+ try {
84
+ const parsed = JSON.parse(body);
85
+ let changed = false;
86
+ const redacted = { ...parsed };
87
+ for (const key of Object.keys(parsed)) {
88
+ if (SENSITIVE_BODY_FIELD.test(key)) {
89
+ redacted[key] = REDACTED;
90
+ changed = true;
91
+ }
92
+ }
93
+ return changed ? JSON.stringify(redacted) : body;
94
+ } catch {
95
+ return body;
96
+ }
97
+ }
98
+ var TraceWriter = class {
99
+ fd;
100
+ constructor(filepath) {
101
+ const mode = platform() === "win32" ? void 0 : 384;
102
+ this.fd = openSync(filepath, "a", mode);
103
+ }
104
+ append(entry) {
105
+ const line = JSON.stringify(entry) + "\n\n";
106
+ writeSync(this.fd, line);
107
+ }
108
+ close() {
109
+ closeSync(this.fd);
110
+ }
111
+ };
112
+ var activeWriter = null;
113
+ function initTraceWriter(filepath) {
114
+ try {
115
+ activeWriter = new TraceWriter(filepath);
116
+ } catch (err) {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ process.stderr.write(`Warning: could not open trace file "${filepath}": ${msg}
119
+ `);
120
+ }
121
+ }
122
+ function getActiveTraceWriter() {
123
+ return activeWriter;
124
+ }
125
+
47
126
  // src/services/azdo-client.ts
48
127
  var DEFAULT_FIELDS = [
49
128
  "System.Title",
@@ -67,12 +146,48 @@ function authHeaders(credentialOrPat) {
67
146
  const token = Buffer.from(`:${credentialOrPat.pat}`).toString("base64");
68
147
  return { Authorization: `Basic ${token}` };
69
148
  }
149
+ async function fetchRaw(url, init) {
150
+ let response;
151
+ try {
152
+ response = await fetch(url, init);
153
+ } catch (err) {
154
+ throw new Error(`NETWORK_ERROR: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
155
+ }
156
+ const body = await response.text();
157
+ return { status: response.status, body };
158
+ }
70
159
  async function fetchWithErrors(url, init) {
160
+ const writer = getActiveTraceWriter();
71
161
  let response;
72
162
  try {
73
163
  response = await fetch(url, init);
74
- } catch {
75
- throw new Error("NETWORK_ERROR");
164
+ } catch (err) {
165
+ throw new Error("NETWORK_ERROR", { cause: err });
166
+ }
167
+ if (writer) {
168
+ const reqHeaders = redactHeaders(init.headers ?? {});
169
+ const reqBody = typeof init.body === "string" ? redactBody(init.body) : null;
170
+ let responseBody = "";
171
+ const clone = response.clone();
172
+ try {
173
+ responseBody = await clone.text();
174
+ } catch {
175
+ }
176
+ const respHeaders = {};
177
+ response.headers.forEach((v, k) => {
178
+ respHeaders[k] = v;
179
+ });
180
+ const entry = {
181
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
182
+ method: (init.method ?? "GET").toUpperCase(),
183
+ url: redactUrl(url),
184
+ requestHeaders: reqHeaders,
185
+ requestBody: reqBody ?? null,
186
+ responseStatus: response.status,
187
+ responseHeaders: respHeaders,
188
+ responseBody
189
+ };
190
+ writer.append(entry);
76
191
  }
77
192
  if (response.status === 401) throw new Error("AUTH_FAILED");
78
193
  if (response.status === 403) throw new Error("PERMISSION_DENIED");
@@ -245,28 +360,66 @@ async function fetchWorkItemResponse(context, id, cred, options = {}) {
245
360
  }
246
361
  return await response.json();
247
362
  }
248
- async function getWorkItem(context, id, cred, extraFields) {
249
- const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
250
- const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, {
251
- fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
252
- }) : await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
253
- const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
254
- const descriptionParts = [];
255
- if (data.fields["System.Description"]) {
256
- descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
363
+ async function getOrgFieldNames(context, cred) {
364
+ const url = new URL(
365
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/fields`
366
+ );
367
+ url.searchParams.set("api-version", "7.1");
368
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
369
+ if (!response.ok) {
370
+ throw new Error(`HTTP_${response.status}`);
371
+ }
372
+ const data = await response.json();
373
+ return (data.value ?? []).map((f) => f.referenceName);
374
+ }
375
+ function buildCombinedDescription(fields) {
376
+ const parts = [];
377
+ if (fields["System.Description"]) {
378
+ parts.push({ label: "Description", value: fields["System.Description"] });
257
379
  }
258
- if (data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
259
- descriptionParts.push({ label: "Acceptance Criteria", value: data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
380
+ if (fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
381
+ parts.push({ label: "Acceptance Criteria", value: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
260
382
  }
261
- if (data.fields["Microsoft.VSTS.TCM.ReproSteps"]) {
262
- descriptionParts.push({ label: "Repro Steps", value: data.fields["Microsoft.VSTS.TCM.ReproSteps"] });
383
+ if (fields["Microsoft.VSTS.TCM.ReproSteps"]) {
384
+ parts.push({ label: "Repro Steps", value: fields["Microsoft.VSTS.TCM.ReproSteps"] });
385
+ }
386
+ if (parts.length === 0) return null;
387
+ if (parts.length === 1) return parts[0].value;
388
+ return parts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
389
+ }
390
+ async function fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields) {
391
+ try {
392
+ const data = await fetchWorkItemResponse(context, id, cred, {
393
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
394
+ });
395
+ return { data, effectiveExtraFields: normalizedExtraFields };
396
+ } catch (err) {
397
+ const msg = err instanceof Error ? err.message : String(err);
398
+ if (!msg.includes("TF51535")) throw err;
399
+ const orgFieldNames = await getOrgFieldNames(context, cred);
400
+ const orgFieldsLower = new Set(orgFieldNames.map((n) => n.toLowerCase()));
401
+ const missing = normalizedExtraFields.filter((f) => !orgFieldsLower.has(f.toLowerCase()));
402
+ const effectiveExtraFields = normalizedExtraFields.filter((f) => orgFieldsLower.has(f.toLowerCase()));
403
+ for (const f of missing) {
404
+ process.stderr.write(`azdo: warning: field '${f}' does not exist in organization '${context.org}' and was skipped
405
+ `);
406
+ }
407
+ const data = await fetchWorkItemResponse(context, id, cred, {
408
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...effectiveExtraFields])
409
+ });
410
+ return { data, effectiveExtraFields };
263
411
  }
264
- let combinedDescription = null;
265
- if (descriptionParts.length === 1) {
266
- combinedDescription = descriptionParts.at(0)?.value ?? null;
267
- } else if (descriptionParts.length > 1) {
268
- combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
412
+ }
413
+ async function getWorkItem(context, id, cred, extraFields) {
414
+ const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
415
+ let effectiveExtraFields = normalizedExtraFields;
416
+ let data;
417
+ if (normalizedExtraFields.length > 0) {
418
+ ({ data, effectiveExtraFields } = await fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields));
419
+ } else {
420
+ data = await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
269
421
  }
422
+ const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
270
423
  return {
271
424
  id: data.id,
272
425
  rev: data.rev,
@@ -274,11 +427,11 @@ async function getWorkItem(context, id, cred, extraFields) {
274
427
  state: data.fields["System.State"],
275
428
  type: data.fields["System.WorkItemType"],
276
429
  assignedTo: data.fields["System.AssignedTo"]?.displayName ?? null,
277
- description: combinedDescription,
430
+ description: buildCombinedDescription(data.fields),
278
431
  areaPath: data.fields["System.AreaPath"],
279
432
  iterationPath: data.fields["System.IterationPath"],
280
433
  url: data._links.html.href,
281
- extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null,
434
+ extraFields: effectiveExtraFields.length > 0 ? buildExtraFields(data.fields, effectiveExtraFields) : null,
282
435
  attachments: extractAttachments(relationsData.relations)
283
436
  };
284
437
  }
@@ -479,13 +632,13 @@ async function classifyDeviceTokenResponse(response) {
479
632
  async function pollForDeviceToken(deviceCode, oauthConfig, initialIntervalSec, expiresAtMs, deps) {
480
633
  const fetchFn = deps.fetch ?? fetch;
481
634
  const now = deps.now ?? (() => Date.now());
482
- const sleep = deps.sleep ?? defaultSleep;
635
+ const sleep2 = deps.sleep ?? defaultSleep;
483
636
  let intervalSec = Math.max(MIN_INTERVAL_SEC, initialIntervalSec);
484
637
  for (; ; ) {
485
638
  if (now() >= expiresAtMs) {
486
639
  throw new DeviceCodeFlowError("expired_token", "device-code flow expired before authorisation completed");
487
640
  }
488
- await sleep(intervalSec * 1e3);
641
+ await sleep2(intervalSec * 1e3);
489
642
  const body = new URLSearchParams({
490
643
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
491
644
  client_id: oauthConfig.clientId,
@@ -579,7 +732,7 @@ function findDotEnvPat(startDir = process.cwd()) {
579
732
  if (existsSync(envFile)) {
580
733
  const contents = readFileSync2(envFile, "utf8");
581
734
  for (const line of contents.split("\n")) {
582
- const match = line.match(/^AZDO_PAT\s*=\s*(.+)$/);
735
+ const match = line.match(/^AZDO_PAT\s*=([^\n\r]+)$/);
583
736
  if (match) {
584
737
  const value = match[1].trim().replace(/^["']|["']$/g, "");
585
738
  if (value.length > 0) return value;
@@ -765,23 +918,39 @@ async function status() {
765
918
  }
766
919
 
767
920
  // src/services/git-remote.ts
768
- import { execSync } from "child_process";
921
+ import { execFileSync } from "child_process";
922
+ import fs from "fs";
923
+ import path from "path";
769
924
 
770
925
  // src/services/remote-warning.ts
771
- var WARNING = "azdo: warning: origin includes embedded credentials; consider removing them with 'git remote set-url origin <clean-url>'\n";
772
926
  var warned = false;
773
- function noticeCredentialBearingRemote() {
927
+ function buildWarning(remoteName) {
928
+ return `azdo: warning: ${remoteName} includes embedded credentials; consider removing them with 'git remote set-url ${remoteName} <clean-url>'
929
+ `;
930
+ }
931
+ function noticeCredentialBearingRemote(remoteName = "origin") {
774
932
  if (warned) {
775
933
  return;
776
934
  }
777
935
  warned = true;
778
936
  try {
779
- process.stderr.write(WARNING);
937
+ process.stderr.write(buildWarning(remoteName));
780
938
  } catch {
781
939
  }
782
940
  }
783
941
 
784
942
  // src/services/git-remote.ts
943
+ var GIT_BINARY = (() => {
944
+ const known = process.platform === "win32" ? [String.raw`C:\Program Files\Git\bin\git.exe`, String.raw`C:\Program Files (x86)\Git\bin\git.exe`] : ["/usr/bin/git", "/usr/local/bin/git", "/opt/homebrew/bin/git"];
945
+ return known.find((p) => {
946
+ try {
947
+ execFileSync(p, ["--version"], { stdio: "ignore" });
948
+ return true;
949
+ } catch {
950
+ return false;
951
+ }
952
+ }) ?? "git";
953
+ })();
785
954
  var patterns = [
786
955
  // HTTPS (current): https://[user[:token]@]dev.azure.com/{org}/{project}/_git/{repo}[.git]
787
956
  /^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
@@ -794,41 +963,144 @@ var patterns = [
794
963
  // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}[.git]
795
964
  /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/
796
965
  ];
797
- var httpsUserinfo = /^https?:\/\/[^@/]+@/;
798
- function parseAzdoRemote(url) {
966
+ var httpsEmbeddedSecret = /^https?:\/\/[^:@/]+:[^@/]+@/;
967
+ function parseSingleRemoteLine(line) {
968
+ const trimmed = line.trim();
969
+ if (!trimmed) return null;
970
+ const tabIdx = trimmed.indexOf(" ");
971
+ if (tabIdx === -1) return null;
972
+ const remoteName = trimmed.slice(0, tabIdx);
973
+ const afterTab = trimmed.slice(tabIdx + 1);
974
+ const urlEnd = afterTab.lastIndexOf(" (");
975
+ const url = urlEnd === -1 ? afterTab : afterTab.slice(0, urlEnd);
976
+ return { remoteName, url };
977
+ }
978
+ function decodePctSegment(segment) {
979
+ try {
980
+ return decodeURIComponent(segment);
981
+ } catch {
982
+ return segment;
983
+ }
984
+ }
985
+ function matchAzdoRemote(remoteName, url) {
799
986
  for (const pattern of patterns) {
800
987
  const match = pattern.exec(url);
801
- if (match) {
802
- if (httpsUserinfo.test(url)) {
803
- noticeCredentialBearingRemote();
988
+ if (!match) continue;
989
+ const project = decodePctSegment(match[2]);
990
+ if (/^DefaultCollection$/i.test(project)) return null;
991
+ return { remoteName, org: match[1], project, hasEmbeddedSecret: httpsEmbeddedSecret.test(url) };
992
+ }
993
+ return null;
994
+ }
995
+ function parseAllAzdoRemotes(output) {
996
+ const seen = /* @__PURE__ */ new Set();
997
+ const results = [];
998
+ for (const line of output.split("\n")) {
999
+ const parsed = parseSingleRemoteLine(line);
1000
+ if (!parsed) continue;
1001
+ const { remoteName, url } = parsed;
1002
+ if (seen.has(remoteName)) continue;
1003
+ const candidate = matchAzdoRemote(remoteName, url);
1004
+ if (candidate) {
1005
+ seen.add(remoteName);
1006
+ results.push(candidate);
1007
+ }
1008
+ }
1009
+ return results;
1010
+ }
1011
+ function selectRemote(candidates) {
1012
+ if (candidates.length === 0) {
1013
+ throw new Error("No Azure DevOps remote found. Provide --org and --project explicitly.");
1014
+ }
1015
+ const origin = candidates.find((c) => c.remoteName === "origin");
1016
+ if (origin) return origin;
1017
+ if (candidates.length === 1) return candidates[0];
1018
+ const first = candidates[0];
1019
+ const allSame = candidates.every(
1020
+ (c) => c.org.toLowerCase() === first.org.toLowerCase() && c.project.toLowerCase() === first.project.toLowerCase()
1021
+ );
1022
+ if (allSame) return first;
1023
+ const lines = candidates.map((c) => ` ${c.remoteName.padEnd(8)} \u2192 ${c.org}/${c.project}`).join("\n");
1024
+ throw new Error(
1025
+ `Multiple Azure DevOps remotes found with different org/project:
1026
+ ${lines}
1027
+ Use --org/--project (or 'git remote rename <name> origin') to disambiguate.`
1028
+ );
1029
+ }
1030
+ function readGitConfigContent() {
1031
+ const gitDirEnv = process.env.GIT_DIR;
1032
+ if (gitDirEnv) {
1033
+ return fs.readFileSync(path.join(gitDirEnv, "config"), "utf-8");
1034
+ }
1035
+ let dir = process.cwd();
1036
+ for (; ; ) {
1037
+ const gitPath = path.join(dir, ".git");
1038
+ try {
1039
+ const stat = fs.statSync(gitPath);
1040
+ if (stat.isDirectory()) {
1041
+ return fs.readFileSync(path.join(gitPath, "config"), "utf-8");
1042
+ }
1043
+ if (stat.isFile()) {
1044
+ const ref = fs.readFileSync(gitPath, "utf-8");
1045
+ const m = /^gitdir:[ \t]*([^\r\n]+)/m.exec(ref);
1046
+ if (m) {
1047
+ return fs.readFileSync(path.join(path.resolve(dir, m[1].trim()), "config"), "utf-8");
1048
+ }
804
1049
  }
805
- const project = match[2];
806
- if (/^DefaultCollection$/i.test(project)) {
807
- return { org: match[1], project: "" };
1050
+ } catch {
1051
+ }
1052
+ const parent = path.dirname(dir);
1053
+ if (parent === dir) break;
1054
+ dir = parent;
1055
+ }
1056
+ throw new Error("Not in a git repository. Provide --org and --project explicitly.");
1057
+ }
1058
+ function gitConfigToRemoteLines(configContent) {
1059
+ const lines = [];
1060
+ let currentRemote = null;
1061
+ let emittedUrl = false;
1062
+ for (const line of configContent.split("\n")) {
1063
+ const sectionMatch = /^\[remote\s+"([^"]+)"\]/.exec(line);
1064
+ if (sectionMatch) {
1065
+ currentRemote = sectionMatch[1];
1066
+ emittedUrl = false;
1067
+ continue;
1068
+ }
1069
+ if (line.startsWith("[")) {
1070
+ currentRemote = null;
1071
+ emittedUrl = false;
1072
+ continue;
1073
+ }
1074
+ if (currentRemote && !emittedUrl) {
1075
+ const urlMatch = /^[ \t]+url[ \t]*=[ \t]*([^\r\n]+)/.exec(line);
1076
+ if (urlMatch) {
1077
+ lines.push(`${currentRemote} ${urlMatch[1].trim()} (fetch)`);
1078
+ emittedUrl = true;
808
1079
  }
809
- return { org: match[1], project };
810
1080
  }
811
1081
  }
812
- return null;
1082
+ return lines.join("\n");
813
1083
  }
814
1084
  function detectAzdoContext() {
815
- let remoteUrl;
1085
+ let configContent;
816
1086
  try {
817
- remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
1087
+ configContent = readGitConfigContent();
818
1088
  } catch {
819
1089
  throw new Error("Not in a git repository. Provide --org and --project explicitly.");
820
1090
  }
821
- const context = parseAzdoRemote(remoteUrl);
822
- if (!context || !context.org && !context.project) {
823
- throw new Error('Git remote "origin" is not an Azure DevOps URL. Provide --org and --project explicitly.');
1091
+ const remoteLines = gitConfigToRemoteLines(configContent);
1092
+ const candidates = parseAllAzdoRemotes(remoteLines);
1093
+ const selected = selectRemote(candidates);
1094
+ if (selected.hasEmbeddedSecret) {
1095
+ noticeCredentialBearingRemote(selected.remoteName);
824
1096
  }
825
- return context;
1097
+ return { org: selected.org, project: selected.project };
826
1098
  }
827
1099
  function parseRepoName(url) {
828
1100
  for (const pattern of patterns) {
829
1101
  const match = pattern.exec(url);
830
1102
  if (match) {
831
- if (httpsUserinfo.test(url)) {
1103
+ if (httpsEmbeddedSecret.test(url)) {
832
1104
  noticeCredentialBearingRemote();
833
1105
  }
834
1106
  return match[3];
@@ -839,7 +1111,7 @@ function parseRepoName(url) {
839
1111
  function detectRepoName() {
840
1112
  let remoteUrl;
841
1113
  try {
842
- remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
1114
+ remoteUrl = execFileSync(GIT_BINARY, ["remote", "get-url", "origin"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
843
1115
  } catch {
844
1116
  throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
845
1117
  }
@@ -850,7 +1122,7 @@ function detectRepoName() {
850
1122
  return repo;
851
1123
  }
852
1124
  function getCurrentBranch() {
853
- const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
1125
+ const branch = execFileSync(GIT_BINARY, ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
854
1126
  if (branch === "HEAD") {
855
1127
  throw new Error("Not on a named branch. Check out a named branch and try again.");
856
1128
  }
@@ -886,7 +1158,7 @@ function formatResolutionError() {
886
1158
  return [
887
1159
  "Could not resolve an Azure DevOps organization. Options (in priority order):",
888
1160
  " 1. Pass --org <name> on the command line.",
889
- " 2. Run this command from a git repo whose origin remote is an Azure DevOps URL.",
1161
+ " 2. Run this command from a git repo that has an Azure DevOps remote.",
890
1162
  " 3. Run `azdo config set org <name>` once to set a persistent default."
891
1163
  ].join("\n");
892
1164
  }
@@ -899,9 +1171,9 @@ function resolveContext(options) {
899
1171
  gitContext = detectAzdoContext();
900
1172
  } catch {
901
1173
  }
902
- const config = loadConfig();
903
1174
  const org = resolvedOrg?.org;
904
- const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || config.project;
1175
+ const scopedCfg = resolveScopedConfig(org);
1176
+ const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || scopedCfg.project;
905
1177
  if (org && project) {
906
1178
  return { org, project };
907
1179
  }
@@ -1053,6 +1325,173 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
1053
1325
  }
1054
1326
  }
1055
1327
 
1328
+ // src/services/image-download.ts
1329
+ import { Jimp } from "jimp";
1330
+ import { writeFile } from "fs/promises";
1331
+ import { existsSync as existsSync2 } from "fs";
1332
+ import { tmpdir } from "os";
1333
+ import { join as join2 } from "path";
1334
+ var ATTACHMENT_GUID_RE = /_apis\/wit\/attachments\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/;
1335
+ function isAzureDevOpsAttachmentHost(hostname) {
1336
+ const host = hostname.toLowerCase();
1337
+ return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
1338
+ }
1339
+ function decodeHtmlEntities(value) {
1340
+ return value.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
1341
+ }
1342
+ function parseAttachmentReference(rawUrl, sourceField) {
1343
+ const url = decodeHtmlEntities(rawUrl.trim());
1344
+ let parsed;
1345
+ try {
1346
+ parsed = new URL(url);
1347
+ } catch {
1348
+ return null;
1349
+ }
1350
+ if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
1351
+ return null;
1352
+ }
1353
+ const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
1354
+ if (!match) return null;
1355
+ const guid = match[1].toLowerCase();
1356
+ let suggestedExtension = ".png";
1357
+ const fileName = parsed.searchParams.get("fileName");
1358
+ if (fileName?.includes(".")) {
1359
+ suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
1360
+ }
1361
+ return { url, sourceField, guid, suggestedExtension };
1362
+ }
1363
+ function extractImageReferences(content, sourceField) {
1364
+ if (!content) return [];
1365
+ const references = [];
1366
+ const seen = /* @__PURE__ */ new Set();
1367
+ const add = (rawUrl) => {
1368
+ const reference = parseAttachmentReference(rawUrl, sourceField);
1369
+ if (reference && !seen.has(reference.guid)) {
1370
+ seen.add(reference.guid);
1371
+ references.push(reference);
1372
+ }
1373
+ };
1374
+ const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
1375
+ let match;
1376
+ while ((match = imgRegex.exec(content)) !== null) {
1377
+ add(match[1]);
1378
+ }
1379
+ const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
1380
+ while ((match = markdownRegex.exec(content)) !== null) {
1381
+ add(match[1]);
1382
+ }
1383
+ return references;
1384
+ }
1385
+ function addImageDownloadOptions(command) {
1386
+ return command.option("--download-images", "download images embedded in rich-text fields to local files").option("--resize-images <pixels>", "max image width in px; downloads and resizes embedded images to PNG (implies --download-images)").option("--images-path <dir>", "destination directory for downloaded images (default: system temp dir)");
1387
+ }
1388
+ function resolveImageDownloadOptionsOrExit(flags) {
1389
+ try {
1390
+ return resolveImageDownloadOptions(flags);
1391
+ } catch (err) {
1392
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
1393
+ `);
1394
+ process.exit(1);
1395
+ }
1396
+ }
1397
+ function resolveImageDownloadOptions(flags) {
1398
+ const wantsResize = flags.resizeImages !== void 0;
1399
+ const enabled = Boolean(flags.downloadImages) || wantsResize;
1400
+ let maxWidth;
1401
+ if (wantsResize) {
1402
+ const parsed = Number(flags.resizeImages);
1403
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1404
+ throw new Error(
1405
+ `Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
1406
+ );
1407
+ }
1408
+ maxWidth = parsed;
1409
+ }
1410
+ const outputDir = flags.imagesPath ?? tmpdir();
1411
+ if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
1412
+ throw new Error(`Images path "${outputDir}" does not exist.`);
1413
+ }
1414
+ return { enabled, maxWidth, outputDir };
1415
+ }
1416
+ function buildImageFileName(workItemId, index, reference, resizing) {
1417
+ const ext = resizing ? ".png" : reference.suggestedExtension;
1418
+ return `wi-${workItemId}-${index}${ext}`;
1419
+ }
1420
+ async function processImageBytes(bytes, maxWidth) {
1421
+ if (maxWidth === void 0) {
1422
+ return { buffer: Buffer.from(bytes), resized: false, format: "original" };
1423
+ }
1424
+ const image = await Jimp.read(Buffer.from(bytes));
1425
+ let resized = false;
1426
+ if (image.bitmap.width > maxWidth) {
1427
+ image.resize({ w: maxWidth });
1428
+ resized = true;
1429
+ }
1430
+ const buffer = await image.getBuffer("image/png");
1431
+ return { buffer, resized, format: "png" };
1432
+ }
1433
+ async function downloadImagesFromFields(fields, args, credential) {
1434
+ const { workItemId, options } = args;
1435
+ const resizing = options.maxWidth !== void 0;
1436
+ const seen = /* @__PURE__ */ new Set();
1437
+ const references = [];
1438
+ for (const field of fields) {
1439
+ for (const reference of extractImageReferences(field.content, field.field)) {
1440
+ if (!seen.has(reference.guid)) {
1441
+ seen.add(reference.guid);
1442
+ references.push(reference);
1443
+ }
1444
+ }
1445
+ }
1446
+ const results = [];
1447
+ let index = 0;
1448
+ for (const reference of references) {
1449
+ index += 1;
1450
+ try {
1451
+ const bytes = await downloadAttachment(reference.url, credential);
1452
+ const processed = await processImageBytes(bytes, options.maxWidth);
1453
+ const fileName = buildImageFileName(workItemId, index, reference, resizing);
1454
+ const outputPath = join2(options.outputDir, fileName);
1455
+ await writeFile(outputPath, processed.buffer);
1456
+ results.push({
1457
+ reference,
1458
+ path: outputPath,
1459
+ resized: processed.resized,
1460
+ format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
1461
+ });
1462
+ } catch (err) {
1463
+ results.push({
1464
+ reference,
1465
+ resized: false,
1466
+ format: reference.suggestedExtension.replace(/^\./, ""),
1467
+ error: err instanceof Error ? err.message : String(err)
1468
+ });
1469
+ }
1470
+ }
1471
+ return results;
1472
+ }
1473
+ async function runImageDownload(fields, args, credential) {
1474
+ const results = await downloadImagesFromFields(fields, args, credential);
1475
+ process.stdout.write(formatImageSummary(results) + "\n");
1476
+ for (const result of results) {
1477
+ if (result.error) {
1478
+ process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
1479
+ `);
1480
+ }
1481
+ }
1482
+ }
1483
+ function formatImageSummary(results) {
1484
+ if (results.length === 0) {
1485
+ return "Images: no images found in rich-text fields";
1486
+ }
1487
+ const saved = results.filter((r) => r.path);
1488
+ const lines = [`Images: ${saved.length} downloaded`];
1489
+ for (const result of saved) {
1490
+ lines.push(` ${result.path}`);
1491
+ }
1492
+ return lines.join("\n");
1493
+ }
1494
+
1056
1495
  // src/commands/get-item.ts
1057
1496
  function parseRequestedFields(raw) {
1058
1497
  if (raw === void 0) return void 0;
@@ -1176,19 +1615,32 @@ function formatWorkItem(workItem, short, markdown = false) {
1176
1615
  }
1177
1616
  function createGetItemCommand() {
1178
1617
  const command = new Command("get-item");
1179
- 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(
1618
+ 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");
1619
+ addImageDownloadOptions(command);
1620
+ command.action(
1180
1621
  async (idStr, options) => {
1181
1622
  const id = parseWorkItemId(idStr);
1182
1623
  validateOrgProjectPair(options);
1624
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1183
1625
  let context;
1184
1626
  try {
1185
1627
  context = resolveContext(options);
1186
1628
  const credential = await requireAuthCredential(context.org);
1187
- const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
1629
+ const scopedCfg = resolveScopedConfig(context.org);
1630
+ const fieldsList = options.fields === void 0 ? parseRequestedFields(scopedCfg.fields) : parseRequestedFields(options.fields);
1188
1631
  const workItem = await getWorkItem(context, id, credential, fieldsList);
1189
- const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
1632
+ const markdownEnabled = options.markdown ?? scopedCfg.markdown ?? false;
1190
1633
  const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
1191
1634
  process.stdout.write(output + "\n");
1635
+ if (imageOptions.enabled) {
1636
+ const fields = [{ content: workItem.description ?? "", field: "Description" }];
1637
+ if (workItem.extraFields) {
1638
+ for (const [name, value] of Object.entries(workItem.extraFields)) {
1639
+ fields.push({ content: value, field: name });
1640
+ }
1641
+ }
1642
+ await runImageDownload(fields, { workItemId: id, options: imageOptions }, credential);
1643
+ }
1192
1644
  } catch (err) {
1193
1645
  handleCommandError(err, id, context, "read", false);
1194
1646
  }
@@ -1224,6 +1676,79 @@ function createClearPatCommand() {
1224
1676
 
1225
1677
  // src/commands/auth.ts
1226
1678
  import { Command as Command3 } from "commander";
1679
+
1680
+ // src/services/auth-diagnostics.ts
1681
+ async function runConnectivityTest(org, cred) {
1682
+ const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?api-version=7.1&$top=1`;
1683
+ let result;
1684
+ try {
1685
+ result = await fetchRaw(url, { headers: authHeaders(cred) });
1686
+ } catch (err) {
1687
+ return { status: "failed", error: err instanceof Error ? err.message : String(err) };
1688
+ }
1689
+ if (result.status >= 200 && result.status < 300) {
1690
+ return { status: "ok", error: null };
1691
+ }
1692
+ let error = `HTTP ${result.status}`;
1693
+ try {
1694
+ const parsed = JSON.parse(result.body);
1695
+ if (typeof parsed.message === "string" && parsed.message.trim() !== "") {
1696
+ error = parsed.message.trim();
1697
+ }
1698
+ } catch {
1699
+ }
1700
+ return { status: "failed", error };
1701
+ }
1702
+ async function diagnoseAuth(org, project, resolveCredential) {
1703
+ const cred = await resolveCredential(org);
1704
+ if (cred === null) {
1705
+ return {
1706
+ authType: "none",
1707
+ credentialSource: null,
1708
+ org,
1709
+ project,
1710
+ connectivityStatus: "no-credentials",
1711
+ connectivityError: null
1712
+ };
1713
+ }
1714
+ const connectivity = await runConnectivityTest(org, cred);
1715
+ const envVarName = process.env.AZDO_PAT ? "AZDO_PAT" : "dotenv";
1716
+ const sourceLabel = cred.source === "env" ? `env:${envVarName}` : "credential-store";
1717
+ return {
1718
+ authType: cred.kind ?? "pat",
1719
+ credentialSource: sourceLabel,
1720
+ org,
1721
+ project,
1722
+ connectivityStatus: connectivity.status,
1723
+ connectivityError: connectivity.error
1724
+ };
1725
+ }
1726
+ function formatDiagnosticReport(report, json) {
1727
+ if (json) {
1728
+ return JSON.stringify(report, null, 2);
1729
+ }
1730
+ let connectivityLine;
1731
+ if (report.connectivityStatus === "ok") {
1732
+ connectivityLine = "OK";
1733
+ } else if (report.connectivityStatus === "no-credentials") {
1734
+ connectivityLine = "no credentials found";
1735
+ } else {
1736
+ connectivityLine = "FAILED";
1737
+ }
1738
+ const lines = [
1739
+ `Auth type: ${report.authType}`,
1740
+ `Source: ${report.credentialSource ?? "(none)"}`,
1741
+ `Org: ${report.org}`,
1742
+ `Project: ${report.project ?? "(not set)"}`,
1743
+ `Connectivity: ${connectivityLine}`
1744
+ ];
1745
+ if (report.connectivityStatus === "failed" && report.connectivityError !== null) {
1746
+ lines.push(`Error: ${report.connectivityError}`);
1747
+ }
1748
+ return lines.join("\n");
1749
+ }
1750
+
1751
+ // src/commands/auth.ts
1227
1752
  async function readStdinToString() {
1228
1753
  const chunks = [];
1229
1754
  for await (const chunk of process.stdin) {
@@ -1645,6 +2170,25 @@ Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
1645
2170
  const globals = logoutCmd.optsWithGlobals();
1646
2171
  await handleLogout(options, globals.org);
1647
2172
  });
2173
+ const diagnoseCmd = command.command("diagnose").description("Show auth type, credential source, org, and live connectivity test result").option("--org <name>", "Azure DevOps organization (overrides context resolution)").option("--project <name>", "Azure DevOps project (optional context)").option("--json", "emit JSON instead of human-readable text", false);
2174
+ diagnoseCmd.action(async (options) => {
2175
+ const globals = diagnoseCmd.optsWithGlobals();
2176
+ const orgName = options.org ?? globals.org;
2177
+ const resolved = resolveOrg({ org: orgName });
2178
+ if (!resolved) {
2179
+ process.stderr.write(`${formatResolutionError()}
2180
+ `);
2181
+ process.exitCode = 1;
2182
+ return;
2183
+ }
2184
+ const report = await diagnoseAuth(resolved.org, options.project ?? null, resolveAuthCredential);
2185
+ const output = formatDiagnosticReport(report, options.json ?? false);
2186
+ process.stdout.write(`${output}
2187
+ `);
2188
+ if (report.connectivityStatus === "failed") {
2189
+ process.exitCode = 1;
2190
+ }
2191
+ });
1648
2192
  return command;
1649
2193
  }
1650
2194
 
@@ -1657,18 +2201,47 @@ function formatConfigValue(value, unsetFallback = "") {
1657
2201
  }
1658
2202
  return Array.isArray(value) ? value.join(",") : value;
1659
2203
  }
2204
+ function buildConfigListEntries(cfg) {
2205
+ const entries = SETTINGS.map((s) => ({
2206
+ scope: "default",
2207
+ key: s.key,
2208
+ value: cfg[s.key]
2209
+ }));
2210
+ for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
2211
+ for (const [k, v] of Object.entries(scope)) {
2212
+ entries.push({ scope: orgName, key: k, value: v });
2213
+ }
2214
+ }
2215
+ return entries;
2216
+ }
1660
2217
  function writeConfigList(cfg) {
1661
2218
  const keyWidth = 10;
1662
2219
  const valueWidth = 30;
2220
+ const scopeWidth = 12;
2221
+ process.stdout.write(
2222
+ `${"scope".padEnd(scopeWidth)}${"key".padEnd(keyWidth)}${"value".padEnd(valueWidth)}description
2223
+ `
2224
+ );
1663
2225
  for (const setting of SETTINGS) {
1664
2226
  const raw = cfg[setting.key];
1665
2227
  const value = formatConfigValue(raw, "(not set)");
1666
2228
  const marker = raw === void 0 && setting.required ? " *" : "";
1667
2229
  process.stdout.write(
1668
- `${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
2230
+ `${"default".padEnd(scopeWidth)}${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
1669
2231
  `
1670
2232
  );
1671
2233
  }
2234
+ for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
2235
+ const orgScope = scope;
2236
+ const scopedSettings = Object.entries(orgScope);
2237
+ for (const [k, v] of scopedSettings) {
2238
+ const value = formatConfigValue(v, "(not set)");
2239
+ process.stdout.write(
2240
+ `${orgName.padEnd(scopeWidth)}${k.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}
2241
+ `
2242
+ );
2243
+ }
2244
+ }
1672
2245
  const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
1673
2246
  if (hasUnset) {
1674
2247
  process.stdout.write(
@@ -1712,17 +2285,22 @@ function createConfigCommand() {
1712
2285
  const config = new Command4("config");
1713
2286
  config.description("Manage CLI settings");
1714
2287
  const set = new Command4("set");
1715
- 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) => {
2288
+ set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").argument("<value>", "setting value").option("--org <org>", "set value in an org-scoped configuration").option("--json", "output in JSON format").action((key, value, options) => {
1716
2289
  try {
1717
- setConfigValue(key, value);
2290
+ if (options.org) {
2291
+ setOrgScopedValue(options.org, key, value);
2292
+ } else {
2293
+ setConfigValue(key, value);
2294
+ }
1718
2295
  if (options.json) {
1719
- const output = { key, value };
2296
+ const output = { key, value, scope: options.org ?? "default" };
1720
2297
  if (key === "fields") {
1721
2298
  output.value = value.split(",").map((s) => s.trim());
1722
2299
  }
1723
2300
  process.stdout.write(JSON.stringify(output) + "\n");
1724
2301
  } else {
1725
- process.stdout.write(`Set "${key}" to "${value}"
2302
+ const scopeTag = options.org ? ` (org: ${options.org})` : "";
2303
+ process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
1726
2304
  `);
1727
2305
  }
1728
2306
  } catch (err) {
@@ -1733,12 +2311,12 @@ function createConfigCommand() {
1733
2311
  }
1734
2312
  });
1735
2313
  const get = new Command4("get");
1736
- get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
2314
+ get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "read from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
1737
2315
  try {
1738
- const value = getConfigValue(key);
2316
+ const value = options.org ? getOrgScopedValue(options.org, key) : getConfigValue(key);
1739
2317
  if (options.json) {
1740
2318
  process.stdout.write(
1741
- JSON.stringify({ key, value: value ?? null }) + "\n"
2319
+ JSON.stringify({ key, value: value ?? null, scope: options.org ?? "default" }) + "\n"
1742
2320
  );
1743
2321
  } else if (value === void 0) {
1744
2322
  process.stdout.write(`Setting "${key}" is not configured.
@@ -1746,7 +2324,7 @@ function createConfigCommand() {
1746
2324
  } else if (Array.isArray(value)) {
1747
2325
  process.stdout.write(value.join(",") + "\n");
1748
2326
  } else {
1749
- process.stdout.write(value + "\n");
2327
+ process.stdout.write(String(value) + "\n");
1750
2328
  }
1751
2329
  } catch (err) {
1752
2330
  const message = err instanceof Error ? err.message : String(err);
@@ -1759,19 +2337,25 @@ function createConfigCommand() {
1759
2337
  list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
1760
2338
  const cfg = loadConfig();
1761
2339
  if (options.json) {
1762
- process.stdout.write(JSON.stringify(cfg) + "\n");
2340
+ const entries = buildConfigListEntries(cfg);
2341
+ process.stdout.write(JSON.stringify(entries) + "\n");
1763
2342
  return;
1764
2343
  }
1765
2344
  writeConfigList(cfg);
1766
2345
  });
1767
2346
  const unset = new Command4("unset");
1768
- unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
2347
+ unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "remove from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
1769
2348
  try {
1770
- unsetConfigValue(key);
2349
+ if (options.org) {
2350
+ unsetOrgScopedValue(options.org, key);
2351
+ } else {
2352
+ unsetConfigValue(key);
2353
+ }
1771
2354
  if (options.json) {
1772
- process.stdout.write(JSON.stringify({ key, unset: true }) + "\n");
2355
+ process.stdout.write(JSON.stringify({ key, unset: true, scope: options.org ?? "default" }) + "\n");
1773
2356
  } else {
1774
- process.stdout.write(`Unset "${key}"
2357
+ const scopeTag = options.org ? ` (org: ${options.org})` : "";
2358
+ process.stdout.write(`Unset "${key}"${scopeTag}
1775
2359
  `);
1776
2360
  }
1777
2361
  } catch (err) {
@@ -1803,10 +2387,52 @@ function createConfigCommand() {
1803
2387
  rl.close();
1804
2388
  process.stderr.write("Configuration complete!\n");
1805
2389
  });
2390
+ const orgCopy = new Command4("org-copy");
2391
+ orgCopy.description('Copy settings from one scope to another (use "default" as source to copy top-level settings)').argument("<from>", 'source scope name or "default"').argument("<to>", "destination org name").option("--force", "overwrite existing values on collision").action((from, to, options) => {
2392
+ try {
2393
+ copyOrgScope(from, to, options.force ?? false);
2394
+ process.stdout.write(`Copied scope "${from}" to "${to}"
2395
+ `);
2396
+ } catch (err) {
2397
+ const message = err instanceof Error ? err.message : String(err);
2398
+ process.stderr.write(`Error: ${message}
2399
+ `);
2400
+ process.exit(1);
2401
+ }
2402
+ });
2403
+ const orgMove = new Command4("org-move");
2404
+ orgMove.description("Move settings from one org scope to another").argument("<from>", "source org name").argument("<to>", "destination org name").option("--force", "overwrite existing values on collision").action((from, to, options) => {
2405
+ try {
2406
+ moveOrgScope(from, to, options.force ?? false);
2407
+ process.stdout.write(`Moved scope "${from}" to "${to}"
2408
+ `);
2409
+ } catch (err) {
2410
+ const message = err instanceof Error ? err.message : String(err);
2411
+ process.stderr.write(`Error: ${message}
2412
+ `);
2413
+ process.exit(1);
2414
+ }
2415
+ });
2416
+ const orgDelete = new Command4("org-delete");
2417
+ orgDelete.description("Delete an org-scoped configuration").argument("<name>", "org name").action((name) => {
2418
+ try {
2419
+ deleteOrgScope(name);
2420
+ process.stdout.write(`Deleted scope "${name}"
2421
+ `);
2422
+ } catch (err) {
2423
+ const message = err instanceof Error ? err.message : String(err);
2424
+ process.stderr.write(`Error: ${message}
2425
+ `);
2426
+ process.exit(1);
2427
+ }
2428
+ });
1806
2429
  config.addCommand(set);
1807
2430
  config.addCommand(get);
1808
2431
  config.addCommand(list);
1809
2432
  config.addCommand(unset);
2433
+ config.addCommand(orgCopy);
2434
+ config.addCommand(orgMove);
2435
+ config.addCommand(orgDelete);
1810
2436
  config.addCommand(wizard);
1811
2437
  return config;
1812
2438
  }
@@ -1943,10 +2569,13 @@ function createSetFieldCommand() {
1943
2569
  import { Command as Command8 } from "commander";
1944
2570
  function createGetMdFieldCommand() {
1945
2571
  const command = new Command8("get-md-field");
1946
- 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(
2572
+ 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");
2573
+ addImageDownloadOptions(command);
2574
+ command.action(
1947
2575
  async (idStr, field, options) => {
1948
2576
  const id = parseWorkItemId(idStr);
1949
2577
  validateOrgProjectPair(options);
2578
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1950
2579
  let context;
1951
2580
  try {
1952
2581
  context = resolveContext(options);
@@ -1957,6 +2586,13 @@ function createGetMdFieldCommand() {
1957
2586
  } else {
1958
2587
  process.stdout.write(toMarkdown(value) + "\n");
1959
2588
  }
2589
+ if (imageOptions.enabled) {
2590
+ await runImageDownload(
2591
+ [{ content: value ?? "", field }],
2592
+ { workItemId: id, options: imageOptions },
2593
+ credential
2594
+ );
2595
+ }
1960
2596
  } catch (err) {
1961
2597
  handleCommandError(err, id, context, "read");
1962
2598
  }
@@ -1966,7 +2602,7 @@ function createGetMdFieldCommand() {
1966
2602
  }
1967
2603
 
1968
2604
  // src/commands/set-md-field.ts
1969
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
2605
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1970
2606
  import { Command as Command9 } from "commander";
1971
2607
  function fail(message) {
1972
2608
  process.stderr.write(`Error: ${message}
@@ -1986,7 +2622,7 @@ function resolveContent(inlineContent, options) {
1986
2622
  return null;
1987
2623
  }
1988
2624
  function readFileContent(filePath) {
1989
- if (!existsSync2(filePath)) {
2625
+ if (!existsSync3(filePath)) {
1990
2626
  fail(`File not found: ${filePath}`);
1991
2627
  }
1992
2628
  try {
@@ -2054,7 +2690,7 @@ function createSetMdFieldCommand() {
2054
2690
  }
2055
2691
 
2056
2692
  // src/commands/upsert.ts
2057
- import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
2693
+ import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
2058
2694
  import { Command as Command10 } from "commander";
2059
2695
 
2060
2696
  // src/services/task-document.ts
@@ -2218,7 +2854,7 @@ function loadSourceContent(options) {
2218
2854
  return { content: options.content };
2219
2855
  }
2220
2856
  const filePath = options.file;
2221
- if (!existsSync3(filePath)) {
2857
+ if (!existsSync4(filePath)) {
2222
2858
  fail2(`File not found: ${filePath}`);
2223
2859
  }
2224
2860
  try {
@@ -2463,18 +3099,43 @@ function buildPullRequestStatusesUrl(context, repo, prId) {
2463
3099
  url.searchParams.set("api-version", "7.1");
2464
3100
  return url;
2465
3101
  }
2466
- function mapPullRequest(repo, pullRequest) {
2467
- return {
2468
- id: pullRequest.pullRequestId,
2469
- title: pullRequest.title,
2470
- repository: repo,
2471
- sourceRefName: pullRequest.sourceRefName,
2472
- targetRefName: pullRequest.targetRefName,
2473
- status: pullRequest.status,
2474
- createdBy: pullRequest.createdBy?.displayName ?? null,
2475
- url: pullRequest._links?.web?.href ?? null
2476
- };
2477
- }
3102
+ function buildProjectUrl(context) {
3103
+ const url = new URL(
3104
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/projects/${encodeURIComponent(context.project)}`
3105
+ );
3106
+ url.searchParams.set("api-version", "7.1");
3107
+ return url;
3108
+ }
3109
+ function buildPolicyEvaluationsUrl(context, projectId, prId) {
3110
+ const url = new URL(
3111
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/policy/evaluations`
3112
+ );
3113
+ url.searchParams.set("api-version", "7.1");
3114
+ url.searchParams.set("artifactId", `vstfs:///CodeReview/CodeReviewId/${projectId}/${prId}`);
3115
+ return url;
3116
+ }
3117
+ function buildPullRequestBuildsUrl(context, prId) {
3118
+ const url = new URL(
3119
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/build/builds`
3120
+ );
3121
+ url.searchParams.set("branchName", `refs/pull/${prId}/merge`);
3122
+ url.searchParams.set("queryOrder", "queueTimeDescending");
3123
+ url.searchParams.set("$top", "50");
3124
+ url.searchParams.set("api-version", "7.1");
3125
+ return url;
3126
+ }
3127
+ function mapPullRequest(repo, pullRequest) {
3128
+ return {
3129
+ id: pullRequest.pullRequestId,
3130
+ title: pullRequest.title,
3131
+ repository: repo,
3132
+ sourceRefName: pullRequest.sourceRefName,
3133
+ targetRefName: pullRequest.targetRefName,
3134
+ status: pullRequest.status,
3135
+ createdBy: pullRequest.createdBy?.displayName ?? null,
3136
+ url: pullRequest._links?.web?.href ?? null
3137
+ };
3138
+ }
2478
3139
  function mapPullRequestCheckName(status2) {
2479
3140
  const genre = status2.context?.genre?.trim();
2480
3141
  const name = status2.context?.name?.trim();
@@ -2501,7 +3162,66 @@ function mapPullRequestCheck(status2) {
2501
3162
  targetUrl: status2.targetUrl ?? null,
2502
3163
  createdBy: status2.createdBy?.displayName ?? null,
2503
3164
  createdAt: status2.creationDate ?? null,
2504
- updatedAt: status2.updatedDate ?? null
3165
+ updatedAt: status2.updatedDate ?? null,
3166
+ source: "status"
3167
+ };
3168
+ }
3169
+ function mapPolicyEvaluationState(status2) {
3170
+ switch (status2) {
3171
+ case "approved":
3172
+ return "succeeded";
3173
+ case "rejected":
3174
+ return "failed";
3175
+ case "running":
3176
+ case "queued":
3177
+ return "pending";
3178
+ case "notApplicable":
3179
+ case "notSet":
3180
+ case void 0:
3181
+ return null;
3182
+ default:
3183
+ return status2;
3184
+ }
3185
+ }
3186
+ function mapBuildToCheckState(build) {
3187
+ if (build.status !== "completed") {
3188
+ return "pending";
3189
+ }
3190
+ switch (build.result) {
3191
+ case "succeeded":
3192
+ case "partiallySucceeded":
3193
+ return "succeeded";
3194
+ case "failed":
3195
+ return "failed";
3196
+ case "canceled":
3197
+ return "error";
3198
+ default:
3199
+ return "pending";
3200
+ }
3201
+ }
3202
+ function mapPolicyEvaluationName(evaluation) {
3203
+ const display = evaluation.configuration?.settings?.displayName?.trim() || evaluation.configuration?.type?.displayName?.trim();
3204
+ if (display) {
3205
+ return display;
3206
+ }
3207
+ return `Policy ${evaluation.configuration?.id ?? evaluation.evaluationId ?? "?"}`;
3208
+ }
3209
+ function mapPolicyEvaluationCheck(evaluation) {
3210
+ const state = mapPolicyEvaluationState(evaluation.status);
3211
+ if (state === null) {
3212
+ return null;
3213
+ }
3214
+ return {
3215
+ id: evaluation.configuration?.id ?? 0,
3216
+ state,
3217
+ name: mapPolicyEvaluationName(evaluation),
3218
+ description: null,
3219
+ targetUrl: null,
3220
+ createdBy: null,
3221
+ createdAt: null,
3222
+ updatedAt: null,
3223
+ source: "policy",
3224
+ isBlocking: evaluation.configuration?.isBlocking ?? null
2505
3225
  };
2506
3226
  }
2507
3227
  function mapComment(comment) {
@@ -2521,18 +3241,22 @@ function mapThread(thread) {
2521
3241
  if (comments.length === 0) {
2522
3242
  return null;
2523
3243
  }
3244
+ const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
2524
3245
  return {
2525
3246
  id: thread.id,
2526
- status: thread.status,
3247
+ status: thread.status ?? "unknown",
2527
3248
  threadContext: thread.threadContext?.filePath ?? null,
3249
+ line,
2528
3250
  comments
2529
3251
  };
2530
3252
  }
2531
3253
  function toActiveCommentThread(thread) {
3254
+ const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
2532
3255
  return {
2533
3256
  id: thread.id,
2534
- status: thread.status,
3257
+ status: thread.status ?? "unknown",
2535
3258
  threadContext: thread.threadContext?.filePath ?? null,
3259
+ line,
2536
3260
  comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
2537
3261
  };
2538
3262
  }
@@ -2587,6 +3311,39 @@ async function getPullRequestChecks(context, repo, cred, prId) {
2587
3311
  const data = await readJsonResponse(response);
2588
3312
  return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
2589
3313
  }
3314
+ async function resolveProjectId(context, cred) {
3315
+ const response = await fetchWithErrors(buildProjectUrl(context).toString(), {
3316
+ headers: authHeaders(cred)
3317
+ });
3318
+ const data = await readJsonResponse(response);
3319
+ return data.id;
3320
+ }
3321
+ async function getPullRequestPolicyEvaluations(context, cred, projectId, prId) {
3322
+ const response = await fetchWithErrors(
3323
+ buildPolicyEvaluationsUrl(context, projectId, prId).toString(),
3324
+ { headers: authHeaders(cred) }
3325
+ );
3326
+ const data = await readJsonResponse(response);
3327
+ return data.value.map(mapPolicyEvaluationCheck).filter((check) => check !== null);
3328
+ }
3329
+ async function getPullRequestBuilds(context, cred, prId) {
3330
+ const response = await fetchWithErrors(buildPullRequestBuildsUrl(context, prId).toString(), {
3331
+ headers: authHeaders(cred)
3332
+ });
3333
+ const data = await readJsonResponse(response);
3334
+ return data.value.map((build) => ({
3335
+ id: build.id,
3336
+ state: mapBuildToCheckState(build),
3337
+ name: build.definition?.name ?? `Build #${build.id}`,
3338
+ description: null,
3339
+ targetUrl: build._links?.web?.href ?? null,
3340
+ createdBy: null,
3341
+ createdAt: build.queueTime ?? null,
3342
+ updatedAt: build.finishTime ?? null,
3343
+ source: "build",
3344
+ isBlocking: null
3345
+ }));
3346
+ }
2590
3347
  async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
2591
3348
  const existing = await listPullRequests(context, repo, cred, sourceBranch, {
2592
3349
  status: "active",
@@ -2638,6 +3395,30 @@ async function getPullRequestThreads(context, repo, cred, prId) {
2638
3395
  const data = await readJsonResponse(response);
2639
3396
  return data.value.map(mapThread).filter((thread) => thread !== null);
2640
3397
  }
3398
+ function buildThreadCommentUrl(context, repo, prId, threadId) {
3399
+ const url = new URL(
3400
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}/comments`
3401
+ );
3402
+ url.searchParams.set("api-version", "7.1");
3403
+ return url;
3404
+ }
3405
+ async function postThreadComment(context, repo, cred, prId, threadId, content) {
3406
+ const response = await fetchWithErrors(buildThreadCommentUrl(context, repo, prId, threadId).toString(), {
3407
+ method: "POST",
3408
+ headers: {
3409
+ ...authHeaders(cred),
3410
+ "Content-Type": "application/json"
3411
+ },
3412
+ body: JSON.stringify({ content, parentCommentId: 0, commentType: 1 })
3413
+ });
3414
+ const data = await readJsonResponse(response);
3415
+ return {
3416
+ id: data.id,
3417
+ author: data.author?.displayName ?? null,
3418
+ content: data.content ?? content,
3419
+ publishedAt: data.publishedDate ?? null
3420
+ };
3421
+ }
2641
3422
 
2642
3423
  // src/commands/pr.ts
2643
3424
  function parsePositivePrNumber(raw) {
@@ -2655,7 +3436,8 @@ function autoDetectZeroMatch(branch) {
2655
3436
  return `No open pull request matches branch ${branch}. Pass --pr-number to target a specific PR, or push the branch and open a pull request.`;
2656
3437
  }
2657
3438
  function autoDetectMultiMatch(branch, ids) {
2658
- return `Multiple open pull requests match branch ${branch}: ${ids.map((id) => `#${id}`).join(", ")}. Re-run with --pr-number to choose.`;
3439
+ const idList = ids.map((id) => `#${id}`).join(", ");
3440
+ return `Multiple open pull requests match branch ${branch}: ${idList}. Re-run with --pr-number to choose.`;
2659
3441
  }
2660
3442
  function writeContractError(line) {
2661
3443
  process.stderr.write(`${line}
@@ -2695,34 +3477,101 @@ function handlePrCommandError(err, context, mode = "read") {
2695
3477
  }
2696
3478
  writeError(error.message);
2697
3479
  }
2698
- function formatPullRequestChecks(checks) {
3480
+ function formatPullRequestChecks(checks, checksError) {
3481
+ if (checksError) {
3482
+ return [`Checks: unable to retrieve (${checksError})`];
3483
+ }
2699
3484
  if (checks.length === 0) {
2700
3485
  return ["Checks: none reported by Azure DevOps"];
2701
3486
  }
2702
3487
  const lines = ["Checks:"];
2703
3488
  for (const check of checks) {
2704
- lines.push(`- [${check.state}] ${check.name}`);
3489
+ const optionalTag = check.isBlocking === false ? " [optional]" : "";
3490
+ lines.push(`- [${check.state}] ${check.name}${optionalTag}`);
2705
3491
  if ((check.state === "failed" || check.state === "error") && check.description) {
2706
3492
  lines.push(` Detail: ${check.description}`);
2707
3493
  }
2708
3494
  }
2709
3495
  return lines;
2710
3496
  }
3497
+ function countCodeComments(threads) {
3498
+ let open = 0;
3499
+ let closed = 0;
3500
+ for (const thread of threads) {
3501
+ if (thread.threadContext === null) {
3502
+ continue;
3503
+ }
3504
+ if (isThreadResolved(thread.status)) {
3505
+ closed += 1;
3506
+ } else {
3507
+ open += 1;
3508
+ }
3509
+ }
3510
+ return { open, closed };
3511
+ }
3512
+ function formatCodeCommentCounts(counts) {
3513
+ return `Code comments: ${counts.open} open, ${counts.closed} closed`;
3514
+ }
2711
3515
  function formatPullRequestBlock(pullRequest) {
2712
3516
  return [
2713
3517
  `#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
2714
3518
  `${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
2715
3519
  pullRequest.url ?? "\u2014",
2716
- ...formatPullRequestChecks(pullRequest.checks)
3520
+ ...formatPullRequestChecks(pullRequest.checks, pullRequest.checksError),
3521
+ formatCodeCommentCounts(pullRequest.codeCommentCounts)
2717
3522
  ].join("\n");
2718
3523
  }
3524
+ async function buildPullRequestStatusEntry(context, repo, cred, pullRequest, projectId) {
3525
+ let statusChecks = [];
3526
+ let statusOk = true;
3527
+ try {
3528
+ statusChecks = await getPullRequestChecks(context, repo, cred, pullRequest.id);
3529
+ } catch {
3530
+ statusOk = false;
3531
+ }
3532
+ let policyChecks = [];
3533
+ let policyOk = true;
3534
+ if (projectId === null) {
3535
+ policyOk = false;
3536
+ } else {
3537
+ try {
3538
+ policyChecks = await getPullRequestPolicyEvaluations(context, cred, projectId, pullRequest.id);
3539
+ } catch {
3540
+ policyOk = false;
3541
+ }
3542
+ }
3543
+ let buildChecks = [];
3544
+ let buildsOk = true;
3545
+ try {
3546
+ buildChecks = await getPullRequestBuilds(context, cred, pullRequest.id);
3547
+ } catch {
3548
+ buildsOk = false;
3549
+ }
3550
+ let codeCommentCounts;
3551
+ try {
3552
+ const threads = await getPullRequestThreads(context, repo, cred, pullRequest.id);
3553
+ codeCommentCounts = countCodeComments(threads);
3554
+ } catch {
3555
+ codeCommentCounts = { open: 0, closed: 0 };
3556
+ }
3557
+ const checks = [...statusChecks, ...policyChecks, ...buildChecks];
3558
+ const checksError = checks.length === 0 && (!statusOk || !policyOk || !buildsOk) ? "Azure DevOps request failed" : null;
3559
+ return {
3560
+ ...pullRequest,
3561
+ checks,
3562
+ codeCommentCounts,
3563
+ checksError
3564
+ };
3565
+ }
2719
3566
  function threadStatusLabel(status2) {
2720
3567
  return isThreadResolved(status2) ? "resolved" : status2;
2721
3568
  }
2722
3569
  function formatThreads(prId, title, threads) {
2723
3570
  const lines = [`Comment threads for pull request #${prId}: ${title}`];
2724
3571
  for (const thread of threads) {
2725
- lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${thread.threadContext ?? "(general)"}`);
3572
+ const lineSuffix = thread.line === null ? "" : `:${thread.line}`;
3573
+ const location = thread.threadContext ? `${thread.threadContext}${lineSuffix}` : "(general)";
3574
+ lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${location}`);
2726
3575
  for (const comment of thread.comments) {
2727
3576
  lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
2728
3577
  }
@@ -2752,11 +3601,16 @@ function createPrStatusCommand() {
2752
3601
  context = resolved.context;
2753
3602
  const branch = resolved.branch;
2754
3603
  const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
3604
+ let projectId = null;
3605
+ try {
3606
+ projectId = await resolveProjectId(resolved.context, resolved.pat);
3607
+ } catch {
3608
+ projectId = null;
3609
+ }
2755
3610
  const pullRequestsWithChecks = await Promise.all(
2756
- pullRequests.map(async (pullRequest) => ({
2757
- ...pullRequest,
2758
- checks: await getPullRequestChecks(resolved.context, resolved.repo, resolved.pat, pullRequest.id)
2759
- }))
3611
+ pullRequests.map(
3612
+ async (pullRequest) => buildPullRequestStatusEntry(resolved.context, resolved.repo, resolved.pat, pullRequest, projectId)
3613
+ )
2760
3614
  );
2761
3615
  const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
2762
3616
  if (options.json) {
@@ -2837,7 +3691,7 @@ ${result.pullRequest.url ?? "\u2014"}
2837
3691
  }
2838
3692
  function createPrCommentsCommand() {
2839
3693
  const command = new Command12("comments");
2840
- configureUnwrappedHelp(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>", PR_NUMBER_HELP).option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--json", "output JSON").action(async (options) => {
3694
+ configureUnwrappedHelp(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>", PR_NUMBER_HELP).option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--exclude-resolved", "alias of --hide-resolved: exclude resolved / won't fix / closed / by design threads").option("--code-related-only", "show only threads anchored to a file/line; omit general discussion threads").option("--json", "output JSON").action(async (options) => {
2841
3695
  validateOrgProjectPair(options);
2842
3696
  let context;
2843
3697
  let explicitPrId = null;
@@ -2879,8 +3733,12 @@ function createPrCommentsCommand() {
2879
3733
  pullRequest = pullRequests[0];
2880
3734
  branchLabel = resolved.branch;
2881
3735
  }
3736
+ const hideResolved = options.hideResolved === true || options.excludeResolved === true;
3737
+ const codeRelatedOnly = options.codeRelatedOnly === true;
2882
3738
  const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
2883
- const threads = options.hideResolved ? allThreads.filter((thread) => !isThreadResolved(thread.status)) : allThreads;
3739
+ const threads = allThreads.filter(
3740
+ (thread) => (!hideResolved || !isThreadResolved(thread.status)) && (!codeRelatedOnly || thread.threadContext !== null)
3741
+ );
2884
3742
  const result = { branch: branchLabel, pullRequest, threads };
2885
3743
  if (options.json) {
2886
3744
  process.stdout.write(`${JSON.stringify(result, null, 2)}
@@ -2888,9 +3746,18 @@ function createPrCommentsCommand() {
2888
3746
  return;
2889
3747
  }
2890
3748
  if (threads.length === 0) {
2891
- if (options.hideResolved && allThreads.length > 0) {
2892
- process.stdout.write(`Pull request #${pullRequest.id} has no unresolved comment threads (${allThreads.length} resolved thread${allThreads.length === 1 ? "" : "s"} hidden by --hide-resolved).
2893
- `);
3749
+ if (allThreads.length > 0 && (hideResolved || codeRelatedOnly)) {
3750
+ const filters = [];
3751
+ if (codeRelatedOnly) {
3752
+ filters.push("code-related");
3753
+ }
3754
+ if (hideResolved) {
3755
+ filters.push("unresolved");
3756
+ }
3757
+ process.stdout.write(
3758
+ `Pull request #${pullRequest.id} has no ${filters.join(" ")} comment threads (filtered from ${allThreads.length} thread${allThreads.length === 1 ? "" : "s"}).
3759
+ `
3760
+ );
2894
3761
  } else {
2895
3762
  process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
2896
3763
  `);
@@ -2903,6 +3770,7 @@ function createPrCommentsCommand() {
2903
3770
  handlePrCommandError(err, context, "read");
2904
3771
  }
2905
3772
  });
3773
+ command.addCommand(createPrCommentsReplyCommand());
2906
3774
  return command;
2907
3775
  }
2908
3776
  async function resolveThreadTarget(threadIdRaw, options) {
@@ -3021,6 +3889,64 @@ function createPrCommentReopenCommand() {
3021
3889
  });
3022
3890
  return command;
3023
3891
  }
3892
+ async function runCommentReply(threadIdRaw, text, options) {
3893
+ let context;
3894
+ try {
3895
+ const trimmedText = text.trim();
3896
+ if (!trimmedText) {
3897
+ writeError("Reply text must not be empty.");
3898
+ return;
3899
+ }
3900
+ const target = await resolveThreadTarget(threadIdRaw, options);
3901
+ if (target === null) {
3902
+ return;
3903
+ }
3904
+ context = target.context;
3905
+ const threads = await getPullRequestThreads(target.context, target.repo, target.pat, target.pullRequest.id);
3906
+ const thread = threads.find((t) => t.id === target.threadId);
3907
+ if (!thread) {
3908
+ writeError(`Thread #${target.threadId} not found on pull request #${target.pullRequest.id}.`);
3909
+ return;
3910
+ }
3911
+ const posted = await postThreadComment(
3912
+ target.context,
3913
+ target.repo,
3914
+ target.pat,
3915
+ target.pullRequest.id,
3916
+ target.threadId,
3917
+ trimmedText
3918
+ );
3919
+ const result = {
3920
+ pullRequestId: target.pullRequest.id,
3921
+ threadId: target.threadId,
3922
+ commentId: posted.id,
3923
+ content: posted.content
3924
+ };
3925
+ if (options.json) {
3926
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
3927
+ `);
3928
+ return;
3929
+ }
3930
+ process.stdout.write(`Reply posted to thread #${target.threadId} on pull request #${target.pullRequest.id}.
3931
+ `);
3932
+ } catch (err) {
3933
+ handlePrCommandError(err, context, "write");
3934
+ }
3935
+ }
3936
+ function createPrCommentsReplyCommand() {
3937
+ const command = new Command12("reply");
3938
+ configureUnwrappedHelp(command).description("Post a reply to a pull request comment thread").argument("<threadId>", "numeric id of the thread to reply to").argument("<text>", "text of the reply").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--json", "output JSON").action(async (threadIdRaw, text, options) => {
3939
+ await runCommentReply(threadIdRaw, text, options);
3940
+ });
3941
+ return command;
3942
+ }
3943
+ function createPrCommentReplyCommand() {
3944
+ const command = new Command12("comment-reply");
3945
+ configureUnwrappedHelp(command).description('Post a reply to a pull request comment thread (alias of "azdo pr comments reply")').argument("<threadId>", "numeric id of the thread to reply to").argument("<text>", "text of the reply").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--json", "output JSON").action(async (threadIdRaw, text, options) => {
3946
+ await runCommentReply(threadIdRaw, text, options);
3947
+ });
3948
+ return command;
3949
+ }
3024
3950
  function createPrCommand() {
3025
3951
  const command = new Command12("pr");
3026
3952
  command.description("Manage Azure DevOps pull requests");
@@ -3029,99 +3955,1012 @@ function createPrCommand() {
3029
3955
  command.addCommand(createPrCommentsCommand());
3030
3956
  command.addCommand(createPrCommentResolveCommand());
3031
3957
  command.addCommand(createPrCommentReopenCommand());
3958
+ command.addCommand(createPrCommentReplyCommand());
3032
3959
  return command;
3033
3960
  }
3034
3961
 
3035
- // src/commands/comments.ts
3962
+ // src/commands/pipeline.ts
3036
3963
  import { Command as Command13 } from "commander";
3037
- function writeError2(message) {
3038
- process.stderr.write(`Error: ${message}
3039
- `);
3040
- process.exit(1);
3964
+
3965
+ // src/services/pipeline-client.ts
3966
+ var API_VERSION = "7.1";
3967
+ function orgProjectBase(context) {
3968
+ return `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}`;
3041
3969
  }
3042
- function formatCommentHeader(comment) {
3043
- const author = comment.author ?? "Unknown";
3044
- const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
3045
- return `Comment #${comment.id} by ${author} at ${timestamp}`;
3970
+ function withApiVersion(url) {
3971
+ url.searchParams.set("api-version", API_VERSION);
3972
+ return url;
3046
3973
  }
3047
- function formatComments(result, convertMarkdown) {
3048
- const lines = [`Comments for work item #${result.workItemId}`];
3049
- for (const comment of result.comments) {
3050
- const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
3051
- lines.push("", formatCommentHeader(comment), text);
3974
+ async function readJsonResponse2(response) {
3975
+ if (!response.ok) {
3976
+ throw new Error(`HTTP_${response.status}`);
3052
3977
  }
3053
- return lines.join("\n");
3978
+ return await response.json();
3054
3979
  }
3055
- function createCommentsListCommand() {
3056
- const command = new Command13("list");
3057
- 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) => {
3058
- validateOrgProjectPair(options);
3059
- const id = parseWorkItemId(idStr);
3060
- let context;
3061
- try {
3062
- context = resolveContext(options);
3063
- const credential = await requireAuthCredential(context.org);
3064
- const result = await listWorkItemComments(context, id, credential);
3065
- if (options.json) {
3066
- process.stdout.write(`${JSON.stringify(result, null, 2)}
3067
- `);
3068
- return;
3069
- }
3070
- if (result.comments.length === 0) {
3071
- process.stdout.write(`Work item #${id} has no comments.
3072
- `);
3073
- return;
3074
- }
3075
- process.stdout.write(`${formatComments(result, options.markdown === true)}
3076
- `);
3077
- } catch (err) {
3078
- handleCommandError(err, id, context, "read");
3079
- }
3980
+ function mapPipeline(pipeline) {
3981
+ return {
3982
+ id: pipeline.id,
3983
+ name: pipeline.name,
3984
+ folder: pipeline.folder?.trim() ? pipeline.folder : null
3985
+ };
3986
+ }
3987
+ function mapRunState(state) {
3988
+ if (state === "inProgress" || state === "completed") {
3989
+ return state;
3990
+ }
3991
+ return "unknown";
3992
+ }
3993
+ function mapRunResult(result) {
3994
+ if (result === "succeeded" || result === "failed" || result === "canceled") {
3995
+ return result;
3996
+ }
3997
+ if (result === "partiallySucceeded") {
3998
+ return "failed";
3999
+ }
4000
+ return null;
4001
+ }
4002
+ function mapBuildState(status2) {
4003
+ if (status2 === "completed") return "completed";
4004
+ if (status2 === void 0 || status2 === "none") return "unknown";
4005
+ return "inProgress";
4006
+ }
4007
+ function mapBuildSummary(build) {
4008
+ return {
4009
+ id: build.id,
4010
+ name: build.buildNumber ?? null,
4011
+ state: mapBuildState(build.status),
4012
+ result: mapRunResult(build.result),
4013
+ createdDate: build.queueTime ?? build.startTime ?? null,
4014
+ finishedDate: build.finishTime ?? null,
4015
+ sourceBranch: build.sourceBranch ?? null,
4016
+ sourceCommit: build.sourceVersion ?? null
4017
+ };
4018
+ }
4019
+ function normalizeRef(branch) {
4020
+ return branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
4021
+ }
4022
+ async function getPipelineDefinitions(context, cred) {
4023
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/pipelines`));
4024
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4025
+ const data = await readJsonResponse2(response);
4026
+ return data.value.map(mapPipeline);
4027
+ }
4028
+ var COMMIT_LOOKBACK = 200;
4029
+ async function getPipelineRuns(context, cred, query) {
4030
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds`));
4031
+ if (query.definitionId !== void 0) {
4032
+ url.searchParams.set("definitions", String(query.definitionId));
4033
+ }
4034
+ if (query.prNumber !== void 0) {
4035
+ url.searchParams.set("branchName", `refs/pull/${query.prNumber}/merge`);
4036
+ } else if (query.branch) {
4037
+ url.searchParams.set("branchName", normalizeRef(query.branch));
4038
+ }
4039
+ url.searchParams.set("queryOrder", "queueTimeDescending");
4040
+ url.searchParams.set("$top", String(query.commit ? COMMIT_LOOKBACK : query.top));
4041
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4042
+ const data = await readJsonResponse2(response);
4043
+ let runs = data.value.map(mapBuildSummary);
4044
+ if (query.commit) {
4045
+ const needle = query.commit.toLowerCase();
4046
+ runs = runs.filter((run) => run.sourceCommit?.toLowerCase().startsWith(needle));
4047
+ }
4048
+ return runs.slice(0, query.top);
4049
+ }
4050
+ async function runPipeline(context, cred, pipelineId, opts) {
4051
+ const url = withApiVersion(
4052
+ new URL(`${orgProjectBase(context)}/_apis/pipelines/${pipelineId}/runs`)
4053
+ );
4054
+ const body = {};
4055
+ if (opts.branch) {
4056
+ const refName = opts.branch.startsWith("refs/") ? opts.branch : `refs/heads/${opts.branch}`;
4057
+ body.resources = { repositories: { self: { refName } } };
4058
+ }
4059
+ if (opts.parameters && Object.keys(opts.parameters).length > 0) {
4060
+ body.templateParameters = opts.parameters;
4061
+ }
4062
+ const response = await fetchWithErrors(url.toString(), {
4063
+ method: "POST",
4064
+ headers: { ...authHeaders(cred), "Content-Type": "application/json" },
4065
+ body: JSON.stringify(body)
3080
4066
  });
3081
- return command;
4067
+ const data = await readJsonResponse2(response);
4068
+ return {
4069
+ id: data.id,
4070
+ state: mapRunState(data.state),
4071
+ webUrl: data._links?.web?.href ?? null
4072
+ };
3082
4073
  }
3083
- function createCommentsAddCommand() {
3084
- const command = new Command13("add");
3085
- 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) => {
3086
- validateOrgProjectPair(options);
3087
- const id = parseWorkItemId(idStr);
3088
- if (text.trim() === "") {
3089
- writeError2("Comment text must be a non-empty string.");
3090
- }
3091
- let context;
3092
- try {
3093
- context = resolveContext(options);
3094
- const credential = await requireAuthCredential(context.org);
3095
- const format = options.markdown === true ? "markdown" : "html";
3096
- const result = await addWorkItemComment(context, id, credential, text, format);
3097
- if (options.json) {
3098
- process.stdout.write(`${JSON.stringify(result, null, 2)}
3099
- `);
3100
- return;
4074
+ function buildUrl(context, buildId) {
4075
+ return withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}`));
4076
+ }
4077
+ async function getBuild(context, cred, buildId) {
4078
+ const response = await fetchWithErrors(buildUrl(context, buildId).toString(), {
4079
+ headers: authHeaders(cred)
4080
+ });
4081
+ return readJsonResponse2(response);
4082
+ }
4083
+ async function getBuildStatus(context, cred, buildId) {
4084
+ const build = await getBuild(context, cred, buildId);
4085
+ return { state: mapBuildState(build.status), result: mapRunResult(build.result) };
4086
+ }
4087
+ async function getBuildTimeline(context, cred, buildId) {
4088
+ const url = withApiVersion(
4089
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/timeline`)
4090
+ );
4091
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4092
+ const data = await readJsonResponse2(response);
4093
+ const records = data.records ?? [];
4094
+ const errors = [];
4095
+ const stages = [];
4096
+ const jobs = [];
4097
+ const logSteps = /* @__PURE__ */ new Map();
4098
+ for (const record of records) {
4099
+ for (const issue of record.issues ?? []) {
4100
+ if (issue.type === "error" && issue.message) {
4101
+ errors.push({ message: issue.message, source: record.name ?? null });
3101
4102
  }
3102
- process.stdout.write(`Added comment #${result.commentId} to work item #${result.workItemId}
3103
- `);
3104
- } catch (err) {
3105
- handleCommandError(err, id, context, "write");
3106
4103
  }
4104
+ if (record.name && record.log?.id !== void 0) {
4105
+ logSteps.set(record.log.id, record.name);
4106
+ }
4107
+ if (!record.name) continue;
4108
+ const status2 = {
4109
+ name: record.name,
4110
+ state: record.state ?? "unknown",
4111
+ result: record.result ?? null
4112
+ };
4113
+ if (record.type === "Stage") {
4114
+ stages.push(status2);
4115
+ } else if (record.type === "Job") {
4116
+ jobs.push({ startTime: record.startTime, status: status2 });
4117
+ }
4118
+ }
4119
+ jobs.sort((a, b) => {
4120
+ if (a.startTime === b.startTime) return 0;
4121
+ if (a.startTime === void 0) return 1;
4122
+ if (b.startTime === void 0) return -1;
4123
+ return a.startTime < b.startTime ? -1 : 1;
3107
4124
  });
3108
- return command;
4125
+ return { errors, stages, jobs: jobs.map((j) => j.status), logSteps };
3109
4126
  }
3110
- function createCommentsCommand() {
3111
- const command = new Command13("comments");
3112
- command.description("Manage Azure DevOps work item comments");
3113
- command.addCommand(createCommentsListCommand());
3114
- command.addCommand(createCommentsAddCommand());
3115
- return command;
3116
- }
3117
-
3118
- // src/commands/download-attachment.ts
4127
+ async function listTestRuns(context, cred, buildId) {
4128
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/test/runs`));
4129
+ url.searchParams.set("buildUri", `vstfs:///Build/Build/${buildId}`);
4130
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4131
+ const data = await readJsonResponse2(response);
4132
+ return data.value;
4133
+ }
4134
+ async function getTestSummary(context, cred, buildId) {
4135
+ const testRuns = await listTestRuns(context, cred, buildId);
4136
+ let total = 0;
4137
+ let failed = 0;
4138
+ for (const run of testRuns) {
4139
+ const runTotal = run.totalTests ?? 0;
4140
+ total += runTotal;
4141
+ const passedOrSkipped = (run.passedTests ?? 0) + (run.notApplicableTests ?? 0) + (run.incompleteTests ?? 0);
4142
+ failed += Math.max(0, runTotal - passedOrSkipped);
4143
+ }
4144
+ if (total === 0) {
4145
+ return { present: false, total: 0, failed: 0, failedTests: [] };
4146
+ }
4147
+ return { present: true, total, failed, failedTests: [] };
4148
+ }
4149
+ var MAX_FAILED_TESTS = 50;
4150
+ async function getFailedTests(context, cred, buildId) {
4151
+ const testRuns = await listTestRuns(context, cred, buildId);
4152
+ const failed = [];
4153
+ for (const testRun of testRuns) {
4154
+ if (failed.length >= MAX_FAILED_TESTS) break;
4155
+ const resultsUrl = withApiVersion(
4156
+ new URL(`${orgProjectBase(context)}/_apis/test/runs/${testRun.id}/results`)
4157
+ );
4158
+ resultsUrl.searchParams.set("outcomes", "Failed");
4159
+ resultsUrl.searchParams.set("$top", String(MAX_FAILED_TESTS - failed.length));
4160
+ const resultsResponse = await fetchWithErrors(resultsUrl.toString(), {
4161
+ headers: authHeaders(cred)
4162
+ });
4163
+ const resultsData = await readJsonResponse2(resultsResponse);
4164
+ for (const result of resultsData.value) {
4165
+ failed.push({
4166
+ name: result.testCaseTitle ?? result.automatedTestName ?? "(unnamed test)",
4167
+ errorMessage: result.errorMessage ?? null
4168
+ });
4169
+ }
4170
+ }
4171
+ return failed;
4172
+ }
4173
+ function secondsBetween(start, finish) {
4174
+ if (!start || !finish) return null;
4175
+ const ms = Date.parse(finish) - Date.parse(start);
4176
+ return Number.isFinite(ms) ? Math.round(ms / 1e3) : null;
4177
+ }
4178
+ async function getRunDetail(context, cred, buildId) {
4179
+ const build = await getBuild(context, cred, buildId);
4180
+ let errors = [];
4181
+ let stages = [];
4182
+ let jobs = [];
4183
+ let errorsAvailable = true;
4184
+ try {
4185
+ const timeline = await getBuildTimeline(context, cred, buildId);
4186
+ errors = timeline.errors;
4187
+ stages = timeline.stages;
4188
+ jobs = timeline.jobs;
4189
+ } catch {
4190
+ errorsAvailable = false;
4191
+ }
4192
+ let tests = { present: false, total: 0, failed: 0, failedTests: [] };
4193
+ let testsAvailable = true;
4194
+ try {
4195
+ tests = await getTestSummary(context, cred, buildId);
4196
+ if (tests.failed > 0) {
4197
+ try {
4198
+ tests = { ...tests, failedTests: await getFailedTests(context, cred, buildId) };
4199
+ } catch {
4200
+ }
4201
+ }
4202
+ } catch {
4203
+ testsAvailable = false;
4204
+ }
4205
+ return {
4206
+ id: build.id,
4207
+ name: build.buildNumber ?? null,
4208
+ state: mapBuildState(build.status),
4209
+ result: mapRunResult(build.result),
4210
+ // createdDate is the queue time, matching the run-list mapping.
4211
+ createdDate: build.queueTime ?? build.startTime ?? null,
4212
+ startedDate: build.startTime ?? null,
4213
+ finishedDate: build.finishTime ?? null,
4214
+ durationSeconds: secondsBetween(build.startTime, build.finishTime),
4215
+ reason: build.reason ?? null,
4216
+ requestedFor: build.requestedFor?.displayName ?? null,
4217
+ sourceBranch: build.sourceBranch ?? null,
4218
+ sourceCommit: build.sourceVersion ?? null,
4219
+ webUrl: build._links?.web?.href ?? null,
4220
+ errors,
4221
+ errorsAvailable,
4222
+ stages,
4223
+ jobs,
4224
+ tests,
4225
+ testsAvailable
4226
+ };
4227
+ }
4228
+ async function getRunLogs(context, cred, buildId) {
4229
+ const url = withApiVersion(
4230
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs`)
4231
+ );
4232
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4233
+ const data = await readJsonResponse2(response);
4234
+ let logSteps = /* @__PURE__ */ new Map();
4235
+ try {
4236
+ logSteps = (await getBuildTimeline(context, cred, buildId)).logSteps;
4237
+ } catch {
4238
+ }
4239
+ return data.value.map((log) => ({
4240
+ id: log.id,
4241
+ createdOn: log.createdOn ?? null,
4242
+ lineCount: log.lineCount ?? null,
4243
+ step: logSteps.get(log.id) ?? null
4244
+ }));
4245
+ }
4246
+ async function getRunLog(context, cred, buildId, logId) {
4247
+ const url = withApiVersion(
4248
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs/${logId}`)
4249
+ );
4250
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4251
+ if (!response.ok) {
4252
+ throw new Error(`HTTP_${response.status}`);
4253
+ }
4254
+ return response.text();
4255
+ }
4256
+
4257
+ // src/commands/pipeline.ts
4258
+ var EXIT_FAILED = 1;
4259
+ var EXIT_CANCELED = 2;
4260
+ var EXIT_TIMEOUT = 124;
4261
+ function writeError2(message) {
4262
+ process.stderr.write(`Error: ${message}
4263
+ `);
4264
+ process.exitCode = 1;
4265
+ }
4266
+ function handlePipelineError(err, context) {
4267
+ const error = err instanceof Error ? err : new Error(String(err));
4268
+ if (error.message === "AUTH_FAILED") {
4269
+ writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
4270
+ return;
4271
+ }
4272
+ if (error.message === "PERMISSION_DENIED") {
4273
+ writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
4274
+ return;
4275
+ }
4276
+ if (error.message === "NETWORK_ERROR") {
4277
+ writeError2("Could not connect to Azure DevOps. Check your network connection.");
4278
+ return;
4279
+ }
4280
+ if (error.message.startsWith("NOT_FOUND")) {
4281
+ writeError2(`Resource not found in ${context?.org}/${context?.project}.`);
4282
+ return;
4283
+ }
4284
+ if (error.message.startsWith("HTTP_")) {
4285
+ writeError2(`Azure DevOps request failed with ${error.message}.`);
4286
+ return;
4287
+ }
4288
+ writeError2(error.message);
4289
+ }
4290
+ function parsePositiveId(raw) {
4291
+ if (!/^\d+$/.test(raw)) return null;
4292
+ const n = Number.parseInt(raw, 10);
4293
+ return Number.isFinite(n) && n > 0 ? n : null;
4294
+ }
4295
+ function parseOptionalCount(value, flag) {
4296
+ if (value === void 0) return void 0;
4297
+ const parsed = parsePositiveId(value);
4298
+ if (parsed === null) {
4299
+ writeError2(`Invalid ${flag} "${value}"; expected a positive integer.`);
4300
+ return null;
4301
+ }
4302
+ return parsed;
4303
+ }
4304
+ function formatBranchName2(refName) {
4305
+ if (!refName) return "\u2014";
4306
+ return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
4307
+ }
4308
+ async function resolvePipelineContext(options) {
4309
+ const context = resolveContext(options);
4310
+ const cred = await requireAuthCredential(context.org);
4311
+ return { context, cred };
4312
+ }
4313
+ function sleep(ms) {
4314
+ return new Promise((resolve2) => {
4315
+ setTimeout(resolve2, ms);
4316
+ });
4317
+ }
4318
+ function formatTable(rows, rightAlign = /* @__PURE__ */ new Set()) {
4319
+ const widths = [];
4320
+ for (const row of rows) {
4321
+ row.forEach((cell, i) => {
4322
+ widths[i] = Math.max(widths[i] ?? 0, cell.length);
4323
+ });
4324
+ }
4325
+ return rows.map(
4326
+ (row) => row.map((cell, i) => rightAlign.has(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i])).join(" ").trimEnd()
4327
+ ).join("\n");
4328
+ }
4329
+ function createPipelineListCommand() {
4330
+ const command = new Command13("list");
4331
+ command.description("List Azure DevOps pipeline definitions").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--filter <name>", "filter definitions by name (case-insensitive substring)").option("--json", "output JSON").action(async (options) => {
4332
+ validateOrgProjectPair(options);
4333
+ let context;
4334
+ try {
4335
+ const resolved = await resolvePipelineContext(options);
4336
+ context = resolved.context;
4337
+ let definitions = await getPipelineDefinitions(resolved.context, resolved.cred);
4338
+ if (options.filter) {
4339
+ const needle = options.filter.toLowerCase();
4340
+ definitions = definitions.filter((d) => d.name.toLowerCase().includes(needle));
4341
+ }
4342
+ if (options.json) {
4343
+ process.stdout.write(`${JSON.stringify(definitions, null, 2)}
4344
+ `);
4345
+ return;
4346
+ }
4347
+ if (definitions.length === 0) {
4348
+ process.stdout.write("No pipelines found.\n");
4349
+ return;
4350
+ }
4351
+ const hasFolder = definitions.some((d) => d.folder);
4352
+ const rows = definitions.map(
4353
+ (d) => hasFolder ? [String(d.id), d.name, d.folder ?? ""] : [String(d.id), d.name]
4354
+ );
4355
+ process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
4356
+ `);
4357
+ } catch (err) {
4358
+ handlePipelineError(err, context);
4359
+ }
4360
+ });
4361
+ return command;
4362
+ }
4363
+ function runRow(run) {
4364
+ const status2 = run.result ? `${run.state}/${run.result}` : run.state;
4365
+ return [
4366
+ String(run.id),
4367
+ `[${status2}]`,
4368
+ run.createdDate ?? "\u2014",
4369
+ formatBranchName2(run.sourceBranch),
4370
+ run.sourceCommit ? run.sourceCommit.slice(0, 8) : "\u2014"
4371
+ ];
4372
+ }
4373
+ var COMMIT_SHA_PATTERN = /^[0-9a-f]{6,40}$/i;
4374
+ function parseGetRunsInputs(defIdRaw, options) {
4375
+ let defId;
4376
+ if (defIdRaw !== void 0) {
4377
+ const parsed = parsePositiveId(defIdRaw);
4378
+ if (parsed === null) {
4379
+ writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
4380
+ return null;
4381
+ }
4382
+ defId = parsed;
4383
+ } else if (options.commit === void 0 && options.pr === void 0) {
4384
+ writeError2("Definition id is required unless --commit or --pr is given.");
4385
+ return null;
4386
+ }
4387
+ const limit = parseOptionalCount(options.limit, "--limit");
4388
+ if (limit === null) return null;
4389
+ const prNumber = parseOptionalCount(options.pr, "--pr");
4390
+ if (prNumber === null) return null;
4391
+ if (options.commit !== void 0 && !COMMIT_SHA_PATTERN.test(options.commit)) {
4392
+ writeError2(`Invalid --commit "${options.commit}"; expected 6-40 hex characters.`);
4393
+ return null;
4394
+ }
4395
+ if (options.branch !== void 0 && prNumber !== void 0) {
4396
+ writeError2("Use either --branch or --pr, not both.");
4397
+ return null;
4398
+ }
4399
+ return { defId, limit: limit ?? 10, prNumber };
4400
+ }
4401
+ function createPipelineGetRunsCommand() {
4402
+ const command = new Command13("get-runs");
4403
+ command.description("List recent runs for a pipeline definition (newest first)").argument("[def_id]", "pipeline definition id (optional with --commit or --pr)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--limit <n>", "maximum number of runs to show (default 10)").option("--branch <branch>", "only show runs for this source branch").option("--commit <sha>", "only show runs that built this commit (full or abbreviated SHA)").option("--pr <number>", "only show runs for this pull request").option("--json", "output JSON").action(async (defIdRaw, options) => {
4404
+ validateOrgProjectPair(options);
4405
+ const inputs = parseGetRunsInputs(defIdRaw, options);
4406
+ if (inputs === null) {
4407
+ return;
4408
+ }
4409
+ let context;
4410
+ try {
4411
+ const resolved = await resolvePipelineContext(options);
4412
+ context = resolved.context;
4413
+ const runs = await getPipelineRuns(resolved.context, resolved.cred, {
4414
+ definitionId: inputs.defId,
4415
+ branch: options.branch,
4416
+ prNumber: inputs.prNumber,
4417
+ commit: options.commit,
4418
+ top: inputs.limit
4419
+ });
4420
+ if (options.json) {
4421
+ process.stdout.write(`${JSON.stringify(runs, null, 2)}
4422
+ `);
4423
+ return;
4424
+ }
4425
+ if (runs.length === 0) {
4426
+ process.stdout.write(
4427
+ inputs.defId === void 0 ? "No runs found matching the filters.\n" : `No runs found for pipeline ${inputs.defId}.
4428
+ `
4429
+ );
4430
+ return;
4431
+ }
4432
+ process.stdout.write(`${formatTable(runs.map(runRow), /* @__PURE__ */ new Set([0]))}
4433
+ `);
4434
+ } catch (err) {
4435
+ handlePipelineError(err, context);
4436
+ }
4437
+ });
4438
+ return command;
4439
+ }
4440
+ function applyWaitExitCode(result) {
4441
+ if (result.timedOut) {
4442
+ process.exitCode = EXIT_TIMEOUT;
4443
+ return;
4444
+ }
4445
+ switch (result.result) {
4446
+ case "succeeded":
4447
+ return;
4448
+ case "canceled":
4449
+ process.exitCode = EXIT_CANCELED;
4450
+ return;
4451
+ case "failed":
4452
+ default:
4453
+ process.exitCode = EXIT_FAILED;
4454
+ }
4455
+ }
4456
+ function createPipelineWaitCommand() {
4457
+ const command = new Command13("wait");
4458
+ command.description("Wait for a pipeline run to finish; exit code reflects the result (0 success, non-zero otherwise)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--timeout <seconds>", "maximum seconds to wait (default 1800)").option("--poll-interval <seconds>", "seconds between status checks (default 5)").option("--json", "output JSON").action(async (runIdRaw, options) => {
4459
+ validateOrgProjectPair(options);
4460
+ const runId = parsePositiveId(runIdRaw);
4461
+ if (runId === null) {
4462
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4463
+ return;
4464
+ }
4465
+ const timeoutSec = options.timeout === void 0 ? 1800 : Number(options.timeout);
4466
+ const pollSec = options.pollInterval === void 0 ? 5 : Number(options.pollInterval);
4467
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
4468
+ writeError2(`Invalid --timeout "${options.timeout}"; expected a non-negative number.`);
4469
+ return;
4470
+ }
4471
+ if (!Number.isFinite(pollSec) || pollSec <= 0) {
4472
+ writeError2(`Invalid --poll-interval "${options.pollInterval}"; expected a positive number.`);
4473
+ return;
4474
+ }
4475
+ let context;
4476
+ try {
4477
+ const resolved = await resolvePipelineContext(options);
4478
+ context = resolved.context;
4479
+ const deadline = Date.now() + timeoutSec * 1e3;
4480
+ let waitResult = null;
4481
+ for (; ; ) {
4482
+ const status2 = await getBuildStatus(resolved.context, resolved.cred, runId);
4483
+ if (status2.state === "completed") {
4484
+ waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: false };
4485
+ break;
4486
+ }
4487
+ if (Date.now() >= deadline) {
4488
+ waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: true };
4489
+ break;
4490
+ }
4491
+ await sleep(pollSec * 1e3);
4492
+ }
4493
+ applyWaitExitCode(waitResult);
4494
+ if (options.json) {
4495
+ process.stdout.write(`${JSON.stringify(waitResult, null, 2)}
4496
+ `);
4497
+ return;
4498
+ }
4499
+ if (waitResult.timedOut) {
4500
+ process.stdout.write(`Run ${runId} did not finish within ${timeoutSec}s (still ${waitResult.state}).
4501
+ `);
4502
+ } else {
4503
+ process.stdout.write(`Run ${runId} finished: ${waitResult.result ?? waitResult.state}.
4504
+ `);
4505
+ }
4506
+ } catch (err) {
4507
+ handlePipelineError(err, context);
4508
+ }
4509
+ });
4510
+ return command;
4511
+ }
4512
+ function timelineRows(items, available) {
4513
+ if (!available) {
4514
+ return [" unavailable"];
4515
+ }
4516
+ if (items.length === 0) {
4517
+ return [" (none)"];
4518
+ }
4519
+ return items.map((item) => ` - ${item.name} [${item.result ?? item.state}]`);
4520
+ }
4521
+ function formatDuration(totalSeconds) {
4522
+ const h = Math.floor(totalSeconds / 3600);
4523
+ const m = Math.floor(totalSeconds % 3600 / 60);
4524
+ const s = totalSeconds % 60;
4525
+ if (h > 0) return `${h}h${m}m${s}s`;
4526
+ if (m > 0) return `${m}m${s}s`;
4527
+ return `${s}s`;
4528
+ }
4529
+ function errorRows(detail) {
4530
+ if (!detail.errorsAvailable) {
4531
+ return [" unavailable"];
4532
+ }
4533
+ if (detail.errors.length === 0) {
4534
+ return [" (none)"];
4535
+ }
4536
+ return detail.errors.map((error) => {
4537
+ const source = error.source ? `[${error.source}] ` : "";
4538
+ return ` - ${source}${error.message}`;
4539
+ });
4540
+ }
4541
+ function failedTestRow(test) {
4542
+ if (!test.errorMessage) {
4543
+ return ` - ${test.name}`;
4544
+ }
4545
+ const firstLine = test.errorMessage.split("\n", 1)[0].trim();
4546
+ return ` - ${test.name}: ${firstLine}`;
4547
+ }
4548
+ function testRows(detail) {
4549
+ if (!detail.testsAvailable) {
4550
+ return [" unavailable"];
4551
+ }
4552
+ if (detail.tests.present) {
4553
+ return [
4554
+ ` ${detail.tests.failed} failing of ${detail.tests.total}`,
4555
+ ...detail.tests.failedTests.map(failedTestRow)
4556
+ ];
4557
+ }
4558
+ return [" no tests present"];
4559
+ }
4560
+ function formatRunDetail(detail) {
4561
+ const status2 = detail.result ? `${detail.state}/${detail.result}` : detail.state;
4562
+ const name = detail.name ? ` ${detail.name}` : "";
4563
+ const duration = detail.durationSeconds == null ? "\u2014" : formatDuration(detail.durationSeconds);
4564
+ return [
4565
+ `Run #${detail.id} [${status2}]${name}`,
4566
+ `Queued: ${detail.createdDate ?? "\u2014"} Started: ${detail.startedDate ?? "\u2014"} Finished: ${detail.finishedDate ?? "\u2014"}`,
4567
+ `Duration: ${duration} Reason: ${detail.reason ?? "\u2014"} Requested for: ${detail.requestedFor ?? "\u2014"}`,
4568
+ `Branch: ${formatBranchName2(detail.sourceBranch)} Commit: ${detail.sourceCommit ?? "unavailable"}`,
4569
+ ...detail.webUrl ? [`Link: ${detail.webUrl}`] : [],
4570
+ "",
4571
+ "Stages:",
4572
+ ...timelineRows(detail.stages, detail.errorsAvailable),
4573
+ "",
4574
+ "Jobs:",
4575
+ ...timelineRows(detail.jobs, detail.errorsAvailable),
4576
+ "",
4577
+ "Errors:",
4578
+ ...errorRows(detail),
4579
+ "",
4580
+ "Tests:",
4581
+ ...testRows(detail)
4582
+ ].join("\n");
4583
+ }
4584
+ function createPipelineGetRunDetailCommand() {
4585
+ const command = new Command13("get-run-detail");
4586
+ command.description("Show a detailed summary of a single pipeline run (errors, failing tests, stages)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (runIdRaw, options) => {
4587
+ validateOrgProjectPair(options);
4588
+ const runId = parsePositiveId(runIdRaw);
4589
+ if (runId === null) {
4590
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4591
+ return;
4592
+ }
4593
+ let context;
4594
+ try {
4595
+ const resolved = await resolvePipelineContext(options);
4596
+ context = resolved.context;
4597
+ const detail = await getRunDetail(resolved.context, resolved.cred, runId);
4598
+ if (options.json) {
4599
+ process.stdout.write(`${JSON.stringify(detail, null, 2)}
4600
+ `);
4601
+ return;
4602
+ }
4603
+ process.stdout.write(`${formatRunDetail(detail)}
4604
+ `);
4605
+ } catch (err) {
4606
+ handlePipelineError(err, context);
4607
+ }
4608
+ });
4609
+ return command;
4610
+ }
4611
+ function grepWithContext(lines, grep, context) {
4612
+ const include = /* @__PURE__ */ new Set();
4613
+ lines.forEach((line, i) => {
4614
+ if (grep.test(line)) {
4615
+ for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) {
4616
+ include.add(j);
4617
+ }
4618
+ }
4619
+ });
4620
+ const selected = [];
4621
+ let prev = -1;
4622
+ for (const i of [...include].sort((a, b) => a - b)) {
4623
+ if (selected.length > 0 && i > prev + 1) {
4624
+ selected.push("--");
4625
+ }
4626
+ selected.push(lines[i]);
4627
+ prev = i;
4628
+ }
4629
+ return selected;
4630
+ }
4631
+ function filterLogLines(content, grep, tail, context) {
4632
+ let lines = content.split("\n");
4633
+ if (lines.at(-1) === "") {
4634
+ lines.pop();
4635
+ }
4636
+ if (grep) {
4637
+ lines = context > 0 ? grepWithContext(lines, grep, context) : lines.filter((line) => grep.test(line));
4638
+ }
4639
+ if (tail !== void 0 && lines.length > tail) {
4640
+ lines = lines.slice(-tail);
4641
+ }
4642
+ return lines;
4643
+ }
4644
+ function parseLogFilters(options) {
4645
+ if (options.logId !== void 0 && options.step !== void 0) {
4646
+ writeError2("Use either --log-id or --step, not both.");
4647
+ return null;
4648
+ }
4649
+ const selectsSingleLog = options.logId !== void 0 || options.step !== void 0;
4650
+ const slices = options.tail !== void 0 || options.grep !== void 0 || options.context !== void 0;
4651
+ if (slices && !selectsSingleLog) {
4652
+ writeError2("--tail, --grep, and --context require --log-id or --step.");
4653
+ return null;
4654
+ }
4655
+ if (options.context !== void 0 && options.grep === void 0) {
4656
+ writeError2("--context requires --grep.");
4657
+ return null;
4658
+ }
4659
+ const tail = parseOptionalCount(options.tail, "--tail");
4660
+ if (tail === null) return null;
4661
+ const contextLines = parseOptionalCount(options.context, "--context");
4662
+ if (contextLines === null) return null;
4663
+ let grep;
4664
+ if (options.grep !== void 0) {
4665
+ try {
4666
+ grep = new RegExp(options.grep);
4667
+ } catch {
4668
+ writeError2(`Invalid --grep "${options.grep}"; expected a valid regular expression.`);
4669
+ return null;
4670
+ }
4671
+ }
4672
+ return { tail, contextLines: contextLines ?? 0, grep };
4673
+ }
4674
+ function chooseStepLog(logs, step, runId) {
4675
+ const needle = step.toLowerCase();
4676
+ const matches = logs.filter((l) => l.step?.toLowerCase().includes(needle));
4677
+ const exact = matches.filter((l) => l.step?.toLowerCase() === needle);
4678
+ const chosen = exact.length === 1 ? exact : matches;
4679
+ if (chosen.length === 0) {
4680
+ writeError2(`No log matches step "${step}" in run ${runId}.`);
4681
+ return null;
4682
+ }
4683
+ if (chosen.length > 1) {
4684
+ const candidates = chosen.map((l) => `${l.id} (${l.step})`).join(", ");
4685
+ writeError2(`Step "${step}" matches multiple logs: ${candidates}. Be more specific or use --log-id.`);
4686
+ return null;
4687
+ }
4688
+ return chosen[0].id;
4689
+ }
4690
+ async function resolveRequestedLogId(resolved, runId, options) {
4691
+ if (options.logId !== void 0) {
4692
+ const parsed = parsePositiveId(options.logId);
4693
+ if (parsed === null) {
4694
+ writeError2(`Invalid --log-id "${options.logId}"; expected a positive integer.`);
4695
+ return null;
4696
+ }
4697
+ return parsed;
4698
+ }
4699
+ if (options.step === void 0) {
4700
+ return void 0;
4701
+ }
4702
+ const allLogs = await getRunLogs(resolved.context, resolved.cred, runId);
4703
+ return chooseStepLog(allLogs, options.step, runId);
4704
+ }
4705
+ function printSingleLog(content, filters) {
4706
+ if (filters.grep !== void 0 || filters.tail !== void 0) {
4707
+ const lines = filterLogLines(content, filters.grep, filters.tail, filters.contextLines);
4708
+ if (lines.length > 0) {
4709
+ process.stdout.write(`${lines.join("\n")}
4710
+ `);
4711
+ }
4712
+ return;
4713
+ }
4714
+ process.stdout.write(content.endsWith("\n") ? content : `${content}
4715
+ `);
4716
+ }
4717
+ function createPipelineLogsCommand() {
4718
+ const command = new Command13("logs");
4719
+ command.description("List a pipeline run's logs, or print a specific log with --log-id").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--log-id <id>", "print the content of this log id").option("--step <name>", "print the log of the step/job matching this name (case-insensitive substring)").option("--tail <n>", "with --log-id/--step, print only the last N lines").option("--grep <pattern>", "with --log-id/--step, print only lines matching this regular expression").option("--context <n>", "with --grep, also print N lines around each match (grep -C)").option("--json", "output JSON").action(async (runIdRaw, options) => {
4720
+ validateOrgProjectPair(options);
4721
+ const runId = parsePositiveId(runIdRaw);
4722
+ if (runId === null) {
4723
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4724
+ return;
4725
+ }
4726
+ const filters = parseLogFilters(options);
4727
+ if (filters === null) {
4728
+ return;
4729
+ }
4730
+ let context;
4731
+ try {
4732
+ const resolved = await resolvePipelineContext(options);
4733
+ context = resolved.context;
4734
+ const logId = await resolveRequestedLogId(resolved, runId, options);
4735
+ if (logId === null) {
4736
+ return;
4737
+ }
4738
+ if (logId !== void 0) {
4739
+ const content = await getRunLog(resolved.context, resolved.cred, runId, logId);
4740
+ printSingleLog(content, filters);
4741
+ return;
4742
+ }
4743
+ const logs = await getRunLogs(resolved.context, resolved.cred, runId);
4744
+ if (options.json) {
4745
+ process.stdout.write(`${JSON.stringify(logs, null, 2)}
4746
+ `);
4747
+ return;
4748
+ }
4749
+ if (logs.length === 0) {
4750
+ process.stdout.write(`No logs found for run ${runId}.
4751
+ `);
4752
+ return;
4753
+ }
4754
+ const rows = logs.map((l) => [
4755
+ String(l.id),
4756
+ l.createdOn ?? "\u2014",
4757
+ l.lineCount == null ? "" : `${l.lineCount} lines`,
4758
+ l.step ?? ""
4759
+ ]);
4760
+ process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
4761
+ `);
4762
+ } catch (err) {
4763
+ handlePipelineError(err, context);
4764
+ }
4765
+ });
4766
+ return command;
4767
+ }
4768
+ function createPipelineTestsCommand() {
4769
+ const command = new Command13("tests");
4770
+ command.description("Show a run's test results: summary plus failing tests by name (no log grepping needed)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--failed", "list only the failing tests").option("--json", "output JSON").action(async (runIdRaw, options) => {
4771
+ validateOrgProjectPair(options);
4772
+ const runId = parsePositiveId(runIdRaw);
4773
+ if (runId === null) {
4774
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4775
+ return;
4776
+ }
4777
+ let context;
4778
+ try {
4779
+ const resolved = await resolvePipelineContext(options);
4780
+ context = resolved.context;
4781
+ const summary = await getTestSummary(resolved.context, resolved.cred, runId);
4782
+ const failedTests = summary.failed > 0 ? await getFailedTests(resolved.context, resolved.cred, runId) : [];
4783
+ if (options.json) {
4784
+ process.stdout.write(`${JSON.stringify({ ...summary, failedTests }, null, 2)}
4785
+ `);
4786
+ return;
4787
+ }
4788
+ if (!summary.present) {
4789
+ process.stdout.write(`No test results published for run ${runId}.
4790
+ `);
4791
+ return;
4792
+ }
4793
+ if (!options.failed) {
4794
+ process.stdout.write(`Run #${runId}: ${summary.failed} failing of ${summary.total} tests
4795
+ `);
4796
+ }
4797
+ if (failedTests.length > 0) {
4798
+ process.stdout.write(`${failedTests.map(failedTestRow).join("\n")}
4799
+ `);
4800
+ } else if (options.failed) {
4801
+ process.stdout.write("No failing tests.\n");
4802
+ }
4803
+ } catch (err) {
4804
+ handlePipelineError(err, context);
4805
+ }
4806
+ });
4807
+ return command;
4808
+ }
4809
+ function parseParameters(values) {
4810
+ const result = {};
4811
+ for (const entry of values ?? []) {
4812
+ const eq = entry.indexOf("=");
4813
+ if (eq <= 0) {
4814
+ return null;
4815
+ }
4816
+ const key = entry.slice(0, eq);
4817
+ const value = entry.slice(eq + 1);
4818
+ result[key] = value;
4819
+ }
4820
+ return result;
4821
+ }
4822
+ function collectParameter(value, previous) {
4823
+ return previous.concat([value]);
4824
+ }
4825
+ function createPipelineStartCommand() {
4826
+ const command = new Command13("start");
4827
+ command.description("Queue a new run of a pipeline definition").argument("<def_id>", "pipeline definition id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--branch <branch>", "branch to run against (default: pipeline default branch)").option("--parameter <key=value>", "template parameter (repeatable)", collectParameter, []).option("--json", "output JSON").action(async (defIdRaw, options) => {
4828
+ validateOrgProjectPair(options);
4829
+ const defId = parsePositiveId(defIdRaw);
4830
+ if (defId === null) {
4831
+ writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
4832
+ return;
4833
+ }
4834
+ const parameters = parseParameters(options.parameter);
4835
+ if (parameters === null) {
4836
+ writeError2("Invalid --parameter; expected key=value.");
4837
+ return;
4838
+ }
4839
+ let context;
4840
+ try {
4841
+ const resolved = await resolvePipelineContext(options);
4842
+ context = resolved.context;
4843
+ const result = await runPipeline(resolved.context, resolved.cred, defId, {
4844
+ branch: options.branch,
4845
+ parameters
4846
+ });
4847
+ if (options.json) {
4848
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4849
+ `);
4850
+ return;
4851
+ }
4852
+ process.stdout.write(`Queued run #${result.id} [${result.state}]
4853
+ ${result.webUrl ?? "\u2014"}
4854
+ `);
4855
+ } catch (err) {
4856
+ handlePipelineError(err, context);
4857
+ }
4858
+ });
4859
+ return command;
4860
+ }
4861
+ function createPipelineCommand() {
4862
+ const command = new Command13("pipeline");
4863
+ command.description("Manage Azure DevOps pipelines");
4864
+ command.addCommand(createPipelineListCommand());
4865
+ command.addCommand(createPipelineGetRunsCommand());
4866
+ command.addCommand(createPipelineWaitCommand());
4867
+ command.addCommand(createPipelineGetRunDetailCommand());
4868
+ command.addCommand(createPipelineLogsCommand());
4869
+ command.addCommand(createPipelineTestsCommand());
4870
+ command.addCommand(createPipelineStartCommand());
4871
+ return command;
4872
+ }
4873
+
4874
+ // src/commands/comments.ts
3119
4875
  import { Command as Command14 } from "commander";
3120
- import { writeFile } from "fs/promises";
3121
- import { existsSync as existsSync4 } from "fs";
3122
- import { join as join2 } from "path";
4876
+ function writeError3(message) {
4877
+ process.stderr.write(`Error: ${message}
4878
+ `);
4879
+ process.exit(1);
4880
+ }
4881
+ function formatCommentHeader(comment) {
4882
+ const author = comment.author ?? "Unknown";
4883
+ const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
4884
+ return `Comment #${comment.id} by ${author} at ${timestamp}`;
4885
+ }
4886
+ function formatComments(result, convertMarkdown) {
4887
+ const lines = [`Comments for work item #${result.workItemId}`];
4888
+ for (const comment of result.comments) {
4889
+ const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
4890
+ lines.push("", formatCommentHeader(comment), text);
4891
+ }
4892
+ return lines.join("\n");
4893
+ }
4894
+ function createCommentsListCommand() {
4895
+ const command = new Command14("list");
4896
+ 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) => {
4897
+ validateOrgProjectPair(options);
4898
+ const id = parseWorkItemId(idStr);
4899
+ let context;
4900
+ try {
4901
+ context = resolveContext(options);
4902
+ const credential = await requireAuthCredential(context.org);
4903
+ const result = await listWorkItemComments(context, id, credential);
4904
+ if (options.json) {
4905
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4906
+ `);
4907
+ return;
4908
+ }
4909
+ if (result.comments.length === 0) {
4910
+ process.stdout.write(`Work item #${id} has no comments.
4911
+ `);
4912
+ return;
4913
+ }
4914
+ process.stdout.write(`${formatComments(result, options.markdown === true)}
4915
+ `);
4916
+ } catch (err) {
4917
+ handleCommandError(err, id, context, "read");
4918
+ }
4919
+ });
4920
+ return command;
4921
+ }
4922
+ function createCommentsAddCommand() {
4923
+ const command = new Command14("add");
4924
+ 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) => {
4925
+ validateOrgProjectPair(options);
4926
+ const id = parseWorkItemId(idStr);
4927
+ if (text.trim() === "") {
4928
+ writeError3("Comment text must be a non-empty string.");
4929
+ }
4930
+ let context;
4931
+ try {
4932
+ context = resolveContext(options);
4933
+ const credential = await requireAuthCredential(context.org);
4934
+ const format = options.markdown === true ? "markdown" : "html";
4935
+ const result = await addWorkItemComment(context, id, credential, text, format);
4936
+ if (options.json) {
4937
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4938
+ `);
4939
+ return;
4940
+ }
4941
+ process.stdout.write(`Added comment #${result.commentId} to work item #${result.workItemId}
4942
+ `);
4943
+ } catch (err) {
4944
+ handleCommandError(err, id, context, "write");
4945
+ }
4946
+ });
4947
+ return command;
4948
+ }
4949
+ function createCommentsCommand() {
4950
+ const command = new Command14("comments");
4951
+ command.description("Manage Azure DevOps work item comments");
4952
+ command.addCommand(createCommentsListCommand());
4953
+ command.addCommand(createCommentsAddCommand());
4954
+ return command;
4955
+ }
4956
+
4957
+ // src/commands/download-attachment.ts
4958
+ import { Command as Command15 } from "commander";
4959
+ import { writeFile as writeFile2 } from "fs/promises";
4960
+ import { existsSync as existsSync5 } from "fs";
4961
+ import { join as join3 } from "path";
3123
4962
  function createDownloadAttachmentCommand() {
3124
- const command = new Command14("download-attachment");
4963
+ const command = new Command15("download-attachment");
3125
4964
  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(
3126
4965
  async (idStr, filename, options) => {
3127
4966
  const id = parseWorkItemId(idStr);
@@ -3131,7 +4970,7 @@ function createDownloadAttachmentCommand() {
3131
4970
  context = resolveContext(options);
3132
4971
  const credential = await requireAuthCredential(context.org);
3133
4972
  const outputDir = options.output ?? ".";
3134
- if (!existsSync4(outputDir)) {
4973
+ if (!existsSync5(outputDir)) {
3135
4974
  process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
3136
4975
  `);
3137
4976
  process.exit(1);
@@ -3148,8 +4987,8 @@ function createDownloadAttachmentCommand() {
3148
4987
  process.exit(1);
3149
4988
  }
3150
4989
  const data = await downloadAttachment(attachment.url, credential);
3151
- const outputPath = join2(outputDir, filename);
3152
- await writeFile(outputPath, Buffer.from(data));
4990
+ const outputPath = join3(outputDir, filename);
4991
+ await writeFile2(outputPath, Buffer.from(data));
3153
4992
  process.stdout.write(
3154
4993
  `Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
3155
4994
  `
@@ -3162,9 +5001,416 @@ function createDownloadAttachmentCommand() {
3162
5001
  return command;
3163
5002
  }
3164
5003
 
5004
+ // src/commands/relations.ts
5005
+ import { Command as Command16 } from "commander";
5006
+
5007
+ // src/services/relations-client.ts
5008
+ var API_VERSION2 = "7.1";
5009
+ function buildRelationTypesUrl(context) {
5010
+ const url = new URL(
5011
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workitemrelationtypes`
5012
+ );
5013
+ url.searchParams.set("api-version", API_VERSION2);
5014
+ return url;
5015
+ }
5016
+ function buildWorkItemUrl2(context, id, expand) {
5017
+ const url = new URL(
5018
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
5019
+ );
5020
+ url.searchParams.set("api-version", API_VERSION2);
5021
+ if (expand) url.searchParams.set("$expand", expand);
5022
+ return url;
5023
+ }
5024
+ function buildBatchWorkItemsUrl(context, ids) {
5025
+ const url = new URL(
5026
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems`
5027
+ );
5028
+ url.searchParams.set("ids", ids.join(","));
5029
+ url.searchParams.set("fields", "System.Id,System.Title");
5030
+ url.searchParams.set("api-version", API_VERSION2);
5031
+ return url;
5032
+ }
5033
+ function mapRelationType(raw) {
5034
+ return {
5035
+ referenceName: raw.referenceName,
5036
+ name: raw.name,
5037
+ usage: raw.attributes?.usage ?? "unknown",
5038
+ enabled: raw.attributes?.enabled !== false,
5039
+ directional: raw.attributes?.directional ?? null
5040
+ };
5041
+ }
5042
+ async function readJsonResponse3(response) {
5043
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
5044
+ return await response.json();
5045
+ }
5046
+ function parseTargetId(url) {
5047
+ const match = /\/workItems\/(\d+)$/i.exec(url);
5048
+ return match ? Number(match[1]) : null;
5049
+ }
5050
+ async function getWorkItemRelationTypes(context, cred) {
5051
+ const url = buildRelationTypesUrl(context);
5052
+ const response = await fetchWithErrors(url.toString(), {
5053
+ headers: authHeaders(cred)
5054
+ });
5055
+ const body = await readJsonResponse3(response);
5056
+ return (body.value ?? []).map(mapRelationType).filter((t) => t.usage === "workItemLink" && t.enabled);
5057
+ }
5058
+ async function resolveRelationType(context, cred, alias) {
5059
+ const types = await getWorkItemRelationTypes(context, cred);
5060
+ const lower = alias.toLowerCase();
5061
+ const match = types.find((t) => t.name.toLowerCase() === lower);
5062
+ if (!match) throw new Error(`UNKNOWN_RELATION_TYPE:${alias}`);
5063
+ return match;
5064
+ }
5065
+ async function getWorkItemWithRelations(context, cred, id) {
5066
+ const url = buildWorkItemUrl2(context, id, "relations");
5067
+ const response = await fetchWithErrors(url.toString(), {
5068
+ headers: authHeaders(cred)
5069
+ });
5070
+ return readJsonResponse3(response);
5071
+ }
5072
+ async function addWorkItemRelation(context, cred, type, id1, id2) {
5073
+ if (id1 === id2) throw new Error("SELF_RELATION");
5074
+ const relType = await resolveRelationType(context, cred, type);
5075
+ const workItem = await getWorkItemWithRelations(context, cred, id1);
5076
+ const targetUrl = `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workItems/${id2}`;
5077
+ const existing = (workItem.relations ?? []).find(
5078
+ (r) => r.rel === relType.referenceName && r.url.toLowerCase() === targetUrl.toLowerCase()
5079
+ );
5080
+ if (existing) {
5081
+ return { status: "already_exists", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5082
+ }
5083
+ const patchUrl = buildWorkItemUrl2(context, id1);
5084
+ const response = await fetchWithErrors(patchUrl.toString(), {
5085
+ method: "PATCH",
5086
+ headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
5087
+ body: JSON.stringify([
5088
+ { op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
5089
+ ])
5090
+ });
5091
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
5092
+ return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5093
+ }
5094
+ async function removeWorkItemRelation(context, cred, type, id1, id2) {
5095
+ if (id1 === id2) throw new Error("SELF_RELATION");
5096
+ const relType = await resolveRelationType(context, cred, type);
5097
+ const workItem = await getWorkItemWithRelations(context, cred, id1);
5098
+ const relations = workItem.relations ?? [];
5099
+ const index = relations.findIndex(
5100
+ (r) => r.rel === relType.referenceName && parseTargetId(r.url) === id2
5101
+ );
5102
+ if (index === -1) {
5103
+ return { status: "not_found", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5104
+ }
5105
+ const patchUrl = buildWorkItemUrl2(context, id1);
5106
+ const response = await fetchWithErrors(patchUrl.toString(), {
5107
+ method: "PATCH",
5108
+ headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
5109
+ body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
5110
+ });
5111
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
5112
+ return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5113
+ }
5114
+ async function listWorkItemRelations(context, cred, id) {
5115
+ const workItem = await getWorkItemWithRelations(context, cred, id);
5116
+ const allRelations = workItem.relations ?? [];
5117
+ const wiRelations = allRelations.filter(
5118
+ (r) => !r.rel.startsWith("AttachedFile") && !r.rel.startsWith("Hyperlink") && !r.rel.startsWith("ArtifactLink")
5119
+ );
5120
+ if (wiRelations.length === 0) {
5121
+ return { workItemId: id, relations: [] };
5122
+ }
5123
+ const types = await getWorkItemRelationTypes(context, cred);
5124
+ const typeNameMap = new Map(types.map((t) => [t.referenceName, t.name]));
5125
+ const targetIds = wiRelations.map((r) => parseTargetId(r.url)).filter((n) => n !== null);
5126
+ const titleMap = /* @__PURE__ */ new Map();
5127
+ if (targetIds.length > 0) {
5128
+ try {
5129
+ const batchUrl = buildBatchWorkItemsUrl(context, targetIds);
5130
+ const batchResponse = await fetchWithErrors(batchUrl.toString(), {
5131
+ headers: authHeaders(cred)
5132
+ });
5133
+ const batchBody = await readJsonResponse3(batchResponse);
5134
+ for (const item of batchBody.value ?? []) {
5135
+ titleMap.set(item.id, item.fields["System.Title"] ?? "");
5136
+ }
5137
+ } catch {
5138
+ }
5139
+ }
5140
+ const relations = wiRelations.map((r) => {
5141
+ const targetId = parseTargetId(r.url);
5142
+ return {
5143
+ rel: r.rel,
5144
+ relName: typeNameMap.get(r.rel) ?? r.rel,
5145
+ targetId: targetId ?? 0,
5146
+ targetTitle: targetId !== null ? titleMap.get(targetId) ?? null : null,
5147
+ targetUrl: r.url,
5148
+ comment: r.attributes?.comment ?? null
5149
+ };
5150
+ });
5151
+ return { workItemId: id, relations };
5152
+ }
5153
+
5154
+ // src/commands/relations.ts
5155
+ function addCommonOptions(cmd) {
5156
+ return cmd.option("--json", "Output as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
5157
+ }
5158
+ async function resolveCredentials(opts) {
5159
+ const context = resolveContext(opts);
5160
+ const cred = await requireAuthCredential(context.org);
5161
+ return { context, cred };
5162
+ }
5163
+ function formatRelationTypes(types) {
5164
+ if (types.length === 0) return "No work item relation types found.";
5165
+ const nameWidth = Math.max(...types.map((t) => t.name.length), 4);
5166
+ const lines = ["Available work item relation types:", ""];
5167
+ for (const t of types) {
5168
+ lines.push(`${t.name.padEnd(nameWidth + 2)}${t.referenceName}`);
5169
+ }
5170
+ return lines.join("\n");
5171
+ }
5172
+ function formatRelationsList(workItemId, relations) {
5173
+ if (relations.length === 0) return `Work item #${workItemId} has no relations.`;
5174
+ const typeWidth = Math.max(...relations.map((r) => r.relName.length), 4) + 2;
5175
+ const idWidth = Math.max(...relations.map((r) => String(r.targetId).length), 4) + 1;
5176
+ const lines = [`Relations for work item #${workItemId}:`, ""];
5177
+ for (const r of relations) {
5178
+ const typeLabel = `[${r.relName}]`.padEnd(typeWidth);
5179
+ const idLabel = `#${r.targetId}`.padEnd(idWidth);
5180
+ lines.push(`${typeLabel} ${idLabel} ${r.targetTitle ?? "(unknown)"}`);
5181
+ }
5182
+ return lines.join("\n");
5183
+ }
5184
+ function handleRelationError(err, id1) {
5185
+ const msg = err instanceof Error ? err.message : String(err);
5186
+ if (msg === "SELF_RELATION") {
5187
+ process.stderr.write(`Error: cannot relate a work item to itself (#${id1}).
5188
+ `);
5189
+ } else if (msg.startsWith("UNKNOWN_RELATION_TYPE:")) {
5190
+ const name = msg.replace("UNKNOWN_RELATION_TYPE:", "");
5191
+ process.stderr.write(
5192
+ `Error: unknown relation type "${name}". Run 'azdo relations types' to see valid names.
5193
+ `
5194
+ );
5195
+ } else if (msg.startsWith("NOT_FOUND")) {
5196
+ const target = id1 !== void 0 ? id1 : "unknown";
5197
+ process.stderr.write(`Error: work item #${target} not found.
5198
+ `);
5199
+ } else if (msg === "AUTH_FAILED") {
5200
+ process.stderr.write(
5201
+ `Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
5202
+ `
5203
+ );
5204
+ } else {
5205
+ process.stderr.write(`Error: ${msg}
5206
+ `);
5207
+ }
5208
+ process.exit(1);
5209
+ }
5210
+ function parsePositiveInt(value, label) {
5211
+ const n = Number(value);
5212
+ if (!Number.isInteger(n) || n <= 0) {
5213
+ process.stderr.write(`Error: ${label} must be a positive integer.
5214
+ `);
5215
+ process.exit(1);
5216
+ }
5217
+ return n;
5218
+ }
5219
+ function createRelationsCommand() {
5220
+ const relations = new Command16("relations").description("Manage work item relations");
5221
+ addCommonOptions(
5222
+ relations.command("types").description("List all available work item relation types")
5223
+ ).action(async (opts) => {
5224
+ try {
5225
+ const { context, cred } = await resolveCredentials(opts);
5226
+ const types = await getWorkItemRelationTypes(context, cred);
5227
+ if (opts.json) {
5228
+ process.stdout.write(JSON.stringify(types, null, 2) + "\n");
5229
+ } else {
5230
+ process.stdout.write(formatRelationTypes(types) + "\n");
5231
+ }
5232
+ } catch (err) {
5233
+ handleRelationError(err);
5234
+ }
5235
+ });
5236
+ addCommonOptions(
5237
+ relations.command("add <type> <id1> <id2>").description("Add a directed relation from work item <id1> to <id2>")
5238
+ ).action(async (type, id1Str, id2Str, opts) => {
5239
+ const id1 = parsePositiveInt(id1Str, "id1");
5240
+ const id2 = parsePositiveInt(id2Str, "id2");
5241
+ try {
5242
+ const { context, cred } = await resolveCredentials(opts);
5243
+ const result = await addWorkItemRelation(context, cred, type, id1, id2);
5244
+ if (opts.json) {
5245
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
5246
+ } else if (result.status === "already_exists") {
5247
+ process.stdout.write(`Relation already exists: #${id1} --[${result.type}]--> #${id2}
5248
+ `);
5249
+ } else {
5250
+ process.stdout.write(`Added relation: #${id1} --[${result.type}]--> #${id2}
5251
+ `);
5252
+ }
5253
+ } catch (err) {
5254
+ handleRelationError(err, id1);
5255
+ }
5256
+ });
5257
+ addCommonOptions(
5258
+ relations.command("remove <type> <id1> <id2>").description("Remove a directed relation of <type> from work item <id1> to <id2>")
5259
+ ).action(async (type, id1Str, id2Str, opts) => {
5260
+ const id1 = parsePositiveInt(id1Str, "id1");
5261
+ const id2 = parsePositiveInt(id2Str, "id2");
5262
+ try {
5263
+ const { context, cred } = await resolveCredentials(opts);
5264
+ const result = await removeWorkItemRelation(context, cred, type, id1, id2);
5265
+ if (opts.json) {
5266
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
5267
+ } else if (result.status === "not_found") {
5268
+ process.stdout.write(`No relation of type '${result.type}' found between #${id1} and #${id2}
5269
+ `);
5270
+ } else {
5271
+ process.stdout.write(`Removed relation: #${id1} --[${result.type}]--> #${id2}
5272
+ `);
5273
+ }
5274
+ } catch (err) {
5275
+ handleRelationError(err, id1);
5276
+ }
5277
+ });
5278
+ addCommonOptions(
5279
+ relations.command("list <id>").description("List all work item link relations on a work item")
5280
+ ).action(async (idStr, opts) => {
5281
+ const id = parsePositiveInt(idStr, "id");
5282
+ try {
5283
+ const { context, cred } = await resolveCredentials(opts);
5284
+ const result = await listWorkItemRelations(context, cred, id);
5285
+ if (opts.json) {
5286
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
5287
+ } else {
5288
+ process.stdout.write(formatRelationsList(result.workItemId, result.relations) + "\n");
5289
+ }
5290
+ } catch (err) {
5291
+ handleRelationError(err, id);
5292
+ }
5293
+ });
5294
+ return relations;
5295
+ }
5296
+
5297
+ // src/services/update-check.ts
5298
+ import fs2 from "fs";
5299
+ import path2 from "path";
5300
+ import os from "os";
5301
+ var THROTTLE_MS = 10 * 60 * 1e3;
5302
+ var FETCH_TIMEOUT_MS = 1500;
5303
+ var REGISTRY_URL = "https://registry.npmjs.org/azdo-cli/latest";
5304
+ function getCachePath() {
5305
+ return path2.join(os.homedir(), ".azdo", "update-check.json");
5306
+ }
5307
+ function defaultReadCache() {
5308
+ try {
5309
+ return fs2.readFileSync(getCachePath(), "utf-8");
5310
+ } catch {
5311
+ return null;
5312
+ }
5313
+ }
5314
+ function defaultWriteCache(data) {
5315
+ try {
5316
+ const cachePath = getCachePath();
5317
+ fs2.mkdirSync(path2.dirname(cachePath), { recursive: true });
5318
+ fs2.writeFileSync(cachePath, data);
5319
+ } catch {
5320
+ }
5321
+ }
5322
+ function parseCache(raw) {
5323
+ if (!raw) return null;
5324
+ let parsed;
5325
+ try {
5326
+ parsed = JSON.parse(raw);
5327
+ } catch {
5328
+ return null;
5329
+ }
5330
+ if (typeof parsed !== "object" || parsed === null) return null;
5331
+ const { lastCheck, latestVersion } = parsed;
5332
+ if (typeof lastCheck !== "number" || !Number.isFinite(lastCheck)) return null;
5333
+ if (typeof latestVersion !== "string" || latestVersion.length === 0) return null;
5334
+ return { lastCheck, latestVersion };
5335
+ }
5336
+ async function defaultFetchLatest() {
5337
+ const controller = new AbortController();
5338
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
5339
+ try {
5340
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
5341
+ if (!res.ok) return null;
5342
+ const body = await res.json();
5343
+ if (typeof body !== "object" || body === null) return null;
5344
+ const v = body.version;
5345
+ return typeof v === "string" && v.length > 0 ? v : null;
5346
+ } catch {
5347
+ return null;
5348
+ } finally {
5349
+ clearTimeout(timer);
5350
+ }
5351
+ }
5352
+ function parseVersion(v) {
5353
+ if (typeof v !== "string") return null;
5354
+ const trimmed = v.trim().replace(/^v/, "");
5355
+ const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(trimmed);
5356
+ if (!match) return null;
5357
+ const release = [Number(match[1]), Number(match[2]), Number(match[3])];
5358
+ if (!release.every(Number.isFinite)) return null;
5359
+ return { release, prerelease: match[4] !== void 0 };
5360
+ }
5361
+ function isNewer(latest, current) {
5362
+ const a = parseVersion(latest);
5363
+ const b = parseVersion(current);
5364
+ if (!a || !b) return false;
5365
+ for (let i = 0; i < 3; i++) {
5366
+ if (a.release[i] > b.release[i]) return true;
5367
+ if (a.release[i] < b.release[i]) return false;
5368
+ }
5369
+ if (a.prerelease && !b.prerelease) return false;
5370
+ if (!a.prerelease && b.prerelease) return true;
5371
+ return false;
5372
+ }
5373
+ async function getUpdateNotice(opts) {
5374
+ try {
5375
+ const {
5376
+ enabled = true,
5377
+ now = Date.now,
5378
+ readCache = defaultReadCache,
5379
+ writeCache = defaultWriteCache,
5380
+ fetchLatest = defaultFetchLatest,
5381
+ isTTY = () => Boolean(process.stderr.isTTY),
5382
+ currentVersion = version
5383
+ } = opts ?? {};
5384
+ if (enabled === false) return null;
5385
+ if (!isTTY()) return null;
5386
+ const cache = parseCache(readCache());
5387
+ const lastCheck = cache?.lastCheck ?? 0;
5388
+ if (now() - lastCheck < THROTTLE_MS) return null;
5389
+ const latest = await fetchLatest();
5390
+ if (!latest) return null;
5391
+ writeCache(JSON.stringify({ lastCheck: now(), latestVersion: latest }));
5392
+ if (isNewer(latest, currentVersion)) {
5393
+ return `A new version of azdo-cli is available: ${currentVersion} \u2192 ${latest}. Run \`npm i -g azdo-cli\` to update.`;
5394
+ }
5395
+ return null;
5396
+ } catch {
5397
+ return null;
5398
+ }
5399
+ }
5400
+
3165
5401
  // src/index.ts
3166
- var program = new Command15();
5402
+ function exitOnEpipe(err) {
5403
+ if (err.code === "EPIPE") {
5404
+ process.exit(0);
5405
+ }
5406
+ throw err;
5407
+ }
5408
+ process.stdout.on("error", exitOnEpipe);
5409
+ process.stderr.on("error", exitOnEpipe);
5410
+ var program = new Command17();
3167
5411
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
5412
+ program.option("--no-update-check", "Skip the check for a newer published version");
5413
+ program.option("--trace <filepath>", "Append redacted HTTP request/response trace to a file (owner-read-only permissions)");
3168
5414
  program.addCommand(createGetItemCommand());
3169
5415
  program.addCommand(createAuthCommand());
3170
5416
  program.addCommand(createClearPatCommand());
@@ -3177,10 +5423,24 @@ program.addCommand(createSetMdFieldCommand());
3177
5423
  program.addCommand(createUpsertCommand());
3178
5424
  program.addCommand(createListFieldsCommand());
3179
5425
  program.addCommand(createPrCommand());
5426
+ program.addCommand(createPipelineCommand());
3180
5427
  program.addCommand(createCommentsCommand());
3181
5428
  program.addCommand(createDownloadAttachmentCommand());
5429
+ program.addCommand(createRelationsCommand());
3182
5430
  program.showHelpAfterError();
3183
- program.parse();
5431
+ program.hook("preAction", () => {
5432
+ const { trace } = program.opts();
5433
+ if (trace) {
5434
+ initTraceWriter(trace);
5435
+ }
5436
+ });
5437
+ program.hook("postAction", async () => {
5438
+ const notice = await getUpdateNotice({ enabled: program.opts().updateCheck });
5439
+ if (notice) {
5440
+ process.stderr.write(notice + "\n");
5441
+ }
5442
+ });
5443
+ await program.parseAsync();
3184
5444
  if (process.argv.length <= 2) {
3185
5445
  program.help();
3186
5446
  }