azdo-cli 0.11.0 → 0.12.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";
@@ -245,28 +252,66 @@ async function fetchWorkItemResponse(context, id, cred, options = {}) {
245
252
  }
246
253
  return await response.json();
247
254
  }
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"] });
255
+ async function getOrgFieldNames(context, cred) {
256
+ const url = new URL(
257
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/fields`
258
+ );
259
+ url.searchParams.set("api-version", "7.1");
260
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
261
+ if (!response.ok) {
262
+ throw new Error(`HTTP_${response.status}`);
263
+ }
264
+ const data = await response.json();
265
+ return (data.value ?? []).map((f) => f.referenceName);
266
+ }
267
+ function buildCombinedDescription(fields) {
268
+ const parts = [];
269
+ if (fields["System.Description"]) {
270
+ parts.push({ label: "Description", value: fields["System.Description"] });
271
+ }
272
+ if (fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
273
+ parts.push({ label: "Acceptance Criteria", value: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
257
274
  }
258
- if (data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
259
- descriptionParts.push({ label: "Acceptance Criteria", value: data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
275
+ if (fields["Microsoft.VSTS.TCM.ReproSteps"]) {
276
+ parts.push({ label: "Repro Steps", value: fields["Microsoft.VSTS.TCM.ReproSteps"] });
260
277
  }
261
- if (data.fields["Microsoft.VSTS.TCM.ReproSteps"]) {
262
- descriptionParts.push({ label: "Repro Steps", value: data.fields["Microsoft.VSTS.TCM.ReproSteps"] });
278
+ if (parts.length === 0) return null;
279
+ if (parts.length === 1) return parts[0].value;
280
+ return parts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
281
+ }
282
+ async function fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields) {
283
+ try {
284
+ const data = await fetchWorkItemResponse(context, id, cred, {
285
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
286
+ });
287
+ return { data, effectiveExtraFields: normalizedExtraFields };
288
+ } catch (err) {
289
+ const msg = err instanceof Error ? err.message : String(err);
290
+ if (!msg.includes("TF51535")) throw err;
291
+ const orgFieldNames = await getOrgFieldNames(context, cred);
292
+ const orgFieldsLower = new Set(orgFieldNames.map((n) => n.toLowerCase()));
293
+ const missing = normalizedExtraFields.filter((f) => !orgFieldsLower.has(f.toLowerCase()));
294
+ const effectiveExtraFields = normalizedExtraFields.filter((f) => orgFieldsLower.has(f.toLowerCase()));
295
+ for (const f of missing) {
296
+ process.stderr.write(`azdo: warning: field '${f}' does not exist in organization '${context.org}' and was skipped
297
+ `);
298
+ }
299
+ const data = await fetchWorkItemResponse(context, id, cred, {
300
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...effectiveExtraFields])
301
+ });
302
+ return { data, effectiveExtraFields };
263
303
  }
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("");
304
+ }
305
+ async function getWorkItem(context, id, cred, extraFields) {
306
+ const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
307
+ let effectiveExtraFields = normalizedExtraFields;
308
+ let data;
309
+ if (normalizedExtraFields.length > 0) {
310
+ ({ data, effectiveExtraFields } = await fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields));
311
+ } else {
312
+ data = await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
269
313
  }
314
+ const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
270
315
  return {
271
316
  id: data.id,
272
317
  rev: data.rev,
@@ -274,11 +319,11 @@ async function getWorkItem(context, id, cred, extraFields) {
274
319
  state: data.fields["System.State"],
275
320
  type: data.fields["System.WorkItemType"],
276
321
  assignedTo: data.fields["System.AssignedTo"]?.displayName ?? null,
277
- description: combinedDescription,
322
+ description: buildCombinedDescription(data.fields),
278
323
  areaPath: data.fields["System.AreaPath"],
279
324
  iterationPath: data.fields["System.IterationPath"],
280
325
  url: data._links.html.href,
281
- extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null,
326
+ extraFields: effectiveExtraFields.length > 0 ? buildExtraFields(data.fields, effectiveExtraFields) : null,
282
327
  attachments: extractAttachments(relationsData.relations)
283
328
  };
284
329
  }
@@ -479,13 +524,13 @@ async function classifyDeviceTokenResponse(response) {
479
524
  async function pollForDeviceToken(deviceCode, oauthConfig, initialIntervalSec, expiresAtMs, deps) {
480
525
  const fetchFn = deps.fetch ?? fetch;
481
526
  const now = deps.now ?? (() => Date.now());
482
- const sleep = deps.sleep ?? defaultSleep;
527
+ const sleep2 = deps.sleep ?? defaultSleep;
483
528
  let intervalSec = Math.max(MIN_INTERVAL_SEC, initialIntervalSec);
484
529
  for (; ; ) {
485
530
  if (now() >= expiresAtMs) {
486
531
  throw new DeviceCodeFlowError("expired_token", "device-code flow expired before authorisation completed");
487
532
  }
488
- await sleep(intervalSec * 1e3);
533
+ await sleep2(intervalSec * 1e3);
489
534
  const body = new URLSearchParams({
490
535
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
491
536
  client_id: oauthConfig.clientId,
@@ -765,23 +810,39 @@ async function status() {
765
810
  }
766
811
 
767
812
  // src/services/git-remote.ts
768
- import { execSync } from "child_process";
813
+ import { execFileSync } from "child_process";
814
+ import fs from "fs";
815
+ import path from "path";
769
816
 
770
817
  // 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
818
  var warned = false;
773
- function noticeCredentialBearingRemote() {
819
+ function buildWarning(remoteName) {
820
+ return `azdo: warning: ${remoteName} includes embedded credentials; consider removing them with 'git remote set-url ${remoteName} <clean-url>'
821
+ `;
822
+ }
823
+ function noticeCredentialBearingRemote(remoteName = "origin") {
774
824
  if (warned) {
775
825
  return;
776
826
  }
777
827
  warned = true;
778
828
  try {
779
- process.stderr.write(WARNING);
829
+ process.stderr.write(buildWarning(remoteName));
780
830
  } catch {
781
831
  }
782
832
  }
783
833
 
784
834
  // src/services/git-remote.ts
835
+ var GIT_BINARY = (() => {
836
+ 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"];
837
+ return known.find((p) => {
838
+ try {
839
+ execFileSync(p, ["--version"], { stdio: "ignore" });
840
+ return true;
841
+ } catch {
842
+ return false;
843
+ }
844
+ }) ?? "git";
845
+ })();
785
846
  var patterns = [
786
847
  // HTTPS (current): https://[user[:token]@]dev.azure.com/{org}/{project}/_git/{repo}[.git]
787
848
  /^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
@@ -794,41 +855,137 @@ var patterns = [
794
855
  // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}[.git]
795
856
  /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/
796
857
  ];
797
- var httpsUserinfo = /^https?:\/\/[^@/]+@/;
798
- function parseAzdoRemote(url) {
858
+ var httpsEmbeddedSecret = /^https?:\/\/[^:@/]+:[^@/]+@/;
859
+ function parseSingleRemoteLine(line) {
860
+ const trimmed = line.trim();
861
+ if (!trimmed) return null;
862
+ const tabIdx = trimmed.indexOf(" ");
863
+ if (tabIdx === -1) return null;
864
+ const remoteName = trimmed.slice(0, tabIdx);
865
+ const afterTab = trimmed.slice(tabIdx + 1);
866
+ const urlEnd = afterTab.lastIndexOf(" (");
867
+ const url = urlEnd === -1 ? afterTab : afterTab.slice(0, urlEnd);
868
+ return { remoteName, url };
869
+ }
870
+ function matchAzdoRemote(remoteName, url) {
799
871
  for (const pattern of patterns) {
800
872
  const match = pattern.exec(url);
801
- if (match) {
802
- if (httpsUserinfo.test(url)) {
803
- noticeCredentialBearingRemote();
873
+ if (!match) continue;
874
+ const project = match[2];
875
+ if (/^DefaultCollection$/i.test(project)) return null;
876
+ return { remoteName, org: match[1], project, hasEmbeddedSecret: httpsEmbeddedSecret.test(url) };
877
+ }
878
+ return null;
879
+ }
880
+ function parseAllAzdoRemotes(output) {
881
+ const seen = /* @__PURE__ */ new Set();
882
+ const results = [];
883
+ for (const line of output.split("\n")) {
884
+ const parsed = parseSingleRemoteLine(line);
885
+ if (!parsed) continue;
886
+ const { remoteName, url } = parsed;
887
+ if (seen.has(remoteName)) continue;
888
+ const candidate = matchAzdoRemote(remoteName, url);
889
+ if (candidate) {
890
+ seen.add(remoteName);
891
+ results.push(candidate);
892
+ }
893
+ }
894
+ return results;
895
+ }
896
+ function selectRemote(candidates) {
897
+ if (candidates.length === 0) {
898
+ throw new Error("No Azure DevOps remote found. Provide --org and --project explicitly.");
899
+ }
900
+ const origin = candidates.find((c) => c.remoteName === "origin");
901
+ if (origin) return origin;
902
+ if (candidates.length === 1) return candidates[0];
903
+ const first = candidates[0];
904
+ const allSame = candidates.every(
905
+ (c) => c.org.toLowerCase() === first.org.toLowerCase() && c.project.toLowerCase() === first.project.toLowerCase()
906
+ );
907
+ if (allSame) return first;
908
+ const lines = candidates.map((c) => ` ${c.remoteName.padEnd(8)} \u2192 ${c.org}/${c.project}`).join("\n");
909
+ throw new Error(
910
+ `Multiple Azure DevOps remotes found with different org/project:
911
+ ${lines}
912
+ Use --org/--project (or 'git remote rename <name> origin') to disambiguate.`
913
+ );
914
+ }
915
+ function readGitConfigContent() {
916
+ const gitDirEnv = process.env.GIT_DIR;
917
+ if (gitDirEnv) {
918
+ return fs.readFileSync(path.join(gitDirEnv, "config"), "utf-8");
919
+ }
920
+ let dir = process.cwd();
921
+ for (; ; ) {
922
+ const gitPath = path.join(dir, ".git");
923
+ try {
924
+ const stat = fs.statSync(gitPath);
925
+ if (stat.isDirectory()) {
926
+ return fs.readFileSync(path.join(gitPath, "config"), "utf-8");
804
927
  }
805
- const project = match[2];
806
- if (/^DefaultCollection$/i.test(project)) {
807
- return { org: match[1], project: "" };
928
+ if (stat.isFile()) {
929
+ const ref = fs.readFileSync(gitPath, "utf-8");
930
+ const m = /^gitdir:[ \t]*([^\r\n]+)/m.exec(ref);
931
+ if (m) {
932
+ return fs.readFileSync(path.join(path.resolve(dir, m[1].trim()), "config"), "utf-8");
933
+ }
934
+ }
935
+ } catch {
936
+ }
937
+ const parent = path.dirname(dir);
938
+ if (parent === dir) break;
939
+ dir = parent;
940
+ }
941
+ throw new Error("Not in a git repository. Provide --org and --project explicitly.");
942
+ }
943
+ function gitConfigToRemoteLines(configContent) {
944
+ const lines = [];
945
+ let currentRemote = null;
946
+ let emittedUrl = false;
947
+ for (const line of configContent.split("\n")) {
948
+ const sectionMatch = /^\[remote\s+"([^"]+)"\]/.exec(line);
949
+ if (sectionMatch) {
950
+ currentRemote = sectionMatch[1];
951
+ emittedUrl = false;
952
+ continue;
953
+ }
954
+ if (line.startsWith("[")) {
955
+ currentRemote = null;
956
+ emittedUrl = false;
957
+ continue;
958
+ }
959
+ if (currentRemote && !emittedUrl) {
960
+ const urlMatch = /^[ \t]+url[ \t]*=[ \t]*([^\r\n]+)/.exec(line);
961
+ if (urlMatch) {
962
+ lines.push(`${currentRemote} ${urlMatch[1].trim()} (fetch)`);
963
+ emittedUrl = true;
808
964
  }
809
- return { org: match[1], project };
810
965
  }
811
966
  }
812
- return null;
967
+ return lines.join("\n");
813
968
  }
814
969
  function detectAzdoContext() {
815
- let remoteUrl;
970
+ let configContent;
816
971
  try {
817
- remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
972
+ configContent = readGitConfigContent();
818
973
  } catch {
819
974
  throw new Error("Not in a git repository. Provide --org and --project explicitly.");
820
975
  }
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.');
976
+ const remoteLines = gitConfigToRemoteLines(configContent);
977
+ const candidates = parseAllAzdoRemotes(remoteLines);
978
+ const selected = selectRemote(candidates);
979
+ if (selected.hasEmbeddedSecret) {
980
+ noticeCredentialBearingRemote(selected.remoteName);
824
981
  }
825
- return context;
982
+ return { org: selected.org, project: selected.project };
826
983
  }
827
984
  function parseRepoName(url) {
828
985
  for (const pattern of patterns) {
829
986
  const match = pattern.exec(url);
830
987
  if (match) {
831
- if (httpsUserinfo.test(url)) {
988
+ if (httpsEmbeddedSecret.test(url)) {
832
989
  noticeCredentialBearingRemote();
833
990
  }
834
991
  return match[3];
@@ -839,7 +996,7 @@ function parseRepoName(url) {
839
996
  function detectRepoName() {
840
997
  let remoteUrl;
841
998
  try {
842
- remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
999
+ remoteUrl = execFileSync(GIT_BINARY, ["remote", "get-url", "origin"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
843
1000
  } catch {
844
1001
  throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
845
1002
  }
@@ -850,7 +1007,7 @@ function detectRepoName() {
850
1007
  return repo;
851
1008
  }
852
1009
  function getCurrentBranch() {
853
- const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
1010
+ const branch = execFileSync(GIT_BINARY, ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
854
1011
  if (branch === "HEAD") {
855
1012
  throw new Error("Not on a named branch. Check out a named branch and try again.");
856
1013
  }
@@ -886,7 +1043,7 @@ function formatResolutionError() {
886
1043
  return [
887
1044
  "Could not resolve an Azure DevOps organization. Options (in priority order):",
888
1045
  " 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.",
1046
+ " 2. Run this command from a git repo that has an Azure DevOps remote.",
890
1047
  " 3. Run `azdo config set org <name>` once to set a persistent default."
891
1048
  ].join("\n");
892
1049
  }
@@ -899,9 +1056,9 @@ function resolveContext(options) {
899
1056
  gitContext = detectAzdoContext();
900
1057
  } catch {
901
1058
  }
902
- const config = loadConfig();
903
1059
  const org = resolvedOrg?.org;
904
- const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || config.project;
1060
+ const scopedCfg = resolveScopedConfig(org);
1061
+ const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || scopedCfg.project;
905
1062
  if (org && project) {
906
1063
  return { org, project };
907
1064
  }
@@ -1053,6 +1210,173 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
1053
1210
  }
1054
1211
  }
1055
1212
 
1213
+ // src/services/image-download.ts
1214
+ import { Jimp } from "jimp";
1215
+ import { writeFile } from "fs/promises";
1216
+ import { existsSync as existsSync2 } from "fs";
1217
+ import { tmpdir } from "os";
1218
+ import { join as join2 } from "path";
1219
+ 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})/;
1220
+ function isAzureDevOpsAttachmentHost(hostname) {
1221
+ const host = hostname.toLowerCase();
1222
+ return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
1223
+ }
1224
+ function decodeHtmlEntities(value) {
1225
+ return value.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
1226
+ }
1227
+ function parseAttachmentReference(rawUrl, sourceField) {
1228
+ const url = decodeHtmlEntities(rawUrl.trim());
1229
+ let parsed;
1230
+ try {
1231
+ parsed = new URL(url);
1232
+ } catch {
1233
+ return null;
1234
+ }
1235
+ if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
1236
+ return null;
1237
+ }
1238
+ const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
1239
+ if (!match) return null;
1240
+ const guid = match[1].toLowerCase();
1241
+ let suggestedExtension = ".png";
1242
+ const fileName = parsed.searchParams.get("fileName");
1243
+ if (fileName?.includes(".")) {
1244
+ suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
1245
+ }
1246
+ return { url, sourceField, guid, suggestedExtension };
1247
+ }
1248
+ function extractImageReferences(content, sourceField) {
1249
+ if (!content) return [];
1250
+ const references = [];
1251
+ const seen = /* @__PURE__ */ new Set();
1252
+ const add = (rawUrl) => {
1253
+ const reference = parseAttachmentReference(rawUrl, sourceField);
1254
+ if (reference && !seen.has(reference.guid)) {
1255
+ seen.add(reference.guid);
1256
+ references.push(reference);
1257
+ }
1258
+ };
1259
+ const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
1260
+ let match;
1261
+ while ((match = imgRegex.exec(content)) !== null) {
1262
+ add(match[1]);
1263
+ }
1264
+ const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
1265
+ while ((match = markdownRegex.exec(content)) !== null) {
1266
+ add(match[1]);
1267
+ }
1268
+ return references;
1269
+ }
1270
+ function addImageDownloadOptions(command) {
1271
+ 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)");
1272
+ }
1273
+ function resolveImageDownloadOptionsOrExit(flags) {
1274
+ try {
1275
+ return resolveImageDownloadOptions(flags);
1276
+ } catch (err) {
1277
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
1278
+ `);
1279
+ process.exit(1);
1280
+ }
1281
+ }
1282
+ function resolveImageDownloadOptions(flags) {
1283
+ const wantsResize = flags.resizeImages !== void 0;
1284
+ const enabled = Boolean(flags.downloadImages) || wantsResize;
1285
+ let maxWidth;
1286
+ if (wantsResize) {
1287
+ const parsed = Number(flags.resizeImages);
1288
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1289
+ throw new Error(
1290
+ `Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
1291
+ );
1292
+ }
1293
+ maxWidth = parsed;
1294
+ }
1295
+ const outputDir = flags.imagesPath ?? tmpdir();
1296
+ if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
1297
+ throw new Error(`Images path "${outputDir}" does not exist.`);
1298
+ }
1299
+ return { enabled, maxWidth, outputDir };
1300
+ }
1301
+ function buildImageFileName(workItemId, index, reference, resizing) {
1302
+ const ext = resizing ? ".png" : reference.suggestedExtension;
1303
+ return `wi-${workItemId}-${index}${ext}`;
1304
+ }
1305
+ async function processImageBytes(bytes, maxWidth) {
1306
+ if (maxWidth === void 0) {
1307
+ return { buffer: Buffer.from(bytes), resized: false, format: "original" };
1308
+ }
1309
+ const image = await Jimp.read(Buffer.from(bytes));
1310
+ let resized = false;
1311
+ if (image.bitmap.width > maxWidth) {
1312
+ image.resize({ w: maxWidth });
1313
+ resized = true;
1314
+ }
1315
+ const buffer = await image.getBuffer("image/png");
1316
+ return { buffer, resized, format: "png" };
1317
+ }
1318
+ async function downloadImagesFromFields(fields, args, credential) {
1319
+ const { workItemId, options } = args;
1320
+ const resizing = options.maxWidth !== void 0;
1321
+ const seen = /* @__PURE__ */ new Set();
1322
+ const references = [];
1323
+ for (const field of fields) {
1324
+ for (const reference of extractImageReferences(field.content, field.field)) {
1325
+ if (!seen.has(reference.guid)) {
1326
+ seen.add(reference.guid);
1327
+ references.push(reference);
1328
+ }
1329
+ }
1330
+ }
1331
+ const results = [];
1332
+ let index = 0;
1333
+ for (const reference of references) {
1334
+ index += 1;
1335
+ try {
1336
+ const bytes = await downloadAttachment(reference.url, credential);
1337
+ const processed = await processImageBytes(bytes, options.maxWidth);
1338
+ const fileName = buildImageFileName(workItemId, index, reference, resizing);
1339
+ const outputPath = join2(options.outputDir, fileName);
1340
+ await writeFile(outputPath, processed.buffer);
1341
+ results.push({
1342
+ reference,
1343
+ path: outputPath,
1344
+ resized: processed.resized,
1345
+ format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
1346
+ });
1347
+ } catch (err) {
1348
+ results.push({
1349
+ reference,
1350
+ resized: false,
1351
+ format: reference.suggestedExtension.replace(/^\./, ""),
1352
+ error: err instanceof Error ? err.message : String(err)
1353
+ });
1354
+ }
1355
+ }
1356
+ return results;
1357
+ }
1358
+ async function runImageDownload(fields, args, credential) {
1359
+ const results = await downloadImagesFromFields(fields, args, credential);
1360
+ process.stdout.write(formatImageSummary(results) + "\n");
1361
+ for (const result of results) {
1362
+ if (result.error) {
1363
+ process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
1364
+ `);
1365
+ }
1366
+ }
1367
+ }
1368
+ function formatImageSummary(results) {
1369
+ if (results.length === 0) {
1370
+ return "Images: no images found in rich-text fields";
1371
+ }
1372
+ const saved = results.filter((r) => r.path);
1373
+ const lines = [`Images: ${saved.length} downloaded`];
1374
+ for (const result of saved) {
1375
+ lines.push(` ${result.path}`);
1376
+ }
1377
+ return lines.join("\n");
1378
+ }
1379
+
1056
1380
  // src/commands/get-item.ts
1057
1381
  function parseRequestedFields(raw) {
1058
1382
  if (raw === void 0) return void 0;
@@ -1176,19 +1500,32 @@ function formatWorkItem(workItem, short, markdown = false) {
1176
1500
  }
1177
1501
  function createGetItemCommand() {
1178
1502
  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(
1503
+ 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");
1504
+ addImageDownloadOptions(command);
1505
+ command.action(
1180
1506
  async (idStr, options) => {
1181
1507
  const id = parseWorkItemId(idStr);
1182
1508
  validateOrgProjectPair(options);
1509
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1183
1510
  let context;
1184
1511
  try {
1185
1512
  context = resolveContext(options);
1186
1513
  const credential = await requireAuthCredential(context.org);
1187
- const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
1514
+ const scopedCfg = resolveScopedConfig(context.org);
1515
+ const fieldsList = options.fields === void 0 ? parseRequestedFields(scopedCfg.fields) : parseRequestedFields(options.fields);
1188
1516
  const workItem = await getWorkItem(context, id, credential, fieldsList);
1189
- const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
1517
+ const markdownEnabled = options.markdown ?? scopedCfg.markdown ?? false;
1190
1518
  const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
1191
1519
  process.stdout.write(output + "\n");
1520
+ if (imageOptions.enabled) {
1521
+ const fields = [{ content: workItem.description ?? "", field: "Description" }];
1522
+ if (workItem.extraFields) {
1523
+ for (const [name, value] of Object.entries(workItem.extraFields)) {
1524
+ fields.push({ content: value, field: name });
1525
+ }
1526
+ }
1527
+ await runImageDownload(fields, { workItemId: id, options: imageOptions }, credential);
1528
+ }
1192
1529
  } catch (err) {
1193
1530
  handleCommandError(err, id, context, "read", false);
1194
1531
  }
@@ -1657,18 +1994,47 @@ function formatConfigValue(value, unsetFallback = "") {
1657
1994
  }
1658
1995
  return Array.isArray(value) ? value.join(",") : value;
1659
1996
  }
1997
+ function buildConfigListEntries(cfg) {
1998
+ const entries = SETTINGS.map((s) => ({
1999
+ scope: "default",
2000
+ key: s.key,
2001
+ value: cfg[s.key]
2002
+ }));
2003
+ for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
2004
+ for (const [k, v] of Object.entries(scope)) {
2005
+ entries.push({ scope: orgName, key: k, value: v });
2006
+ }
2007
+ }
2008
+ return entries;
2009
+ }
1660
2010
  function writeConfigList(cfg) {
1661
2011
  const keyWidth = 10;
1662
2012
  const valueWidth = 30;
2013
+ const scopeWidth = 12;
2014
+ process.stdout.write(
2015
+ `${"scope".padEnd(scopeWidth)}${"key".padEnd(keyWidth)}${"value".padEnd(valueWidth)}description
2016
+ `
2017
+ );
1663
2018
  for (const setting of SETTINGS) {
1664
2019
  const raw = cfg[setting.key];
1665
2020
  const value = formatConfigValue(raw, "(not set)");
1666
2021
  const marker = raw === void 0 && setting.required ? " *" : "";
1667
2022
  process.stdout.write(
1668
- `${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
2023
+ `${"default".padEnd(scopeWidth)}${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
1669
2024
  `
1670
2025
  );
1671
2026
  }
2027
+ for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
2028
+ const orgScope = scope;
2029
+ const scopedSettings = Object.entries(orgScope);
2030
+ for (const [k, v] of scopedSettings) {
2031
+ const value = formatConfigValue(v, "(not set)");
2032
+ process.stdout.write(
2033
+ `${orgName.padEnd(scopeWidth)}${k.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}
2034
+ `
2035
+ );
2036
+ }
2037
+ }
1672
2038
  const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
1673
2039
  if (hasUnset) {
1674
2040
  process.stdout.write(
@@ -1712,17 +2078,22 @@ function createConfigCommand() {
1712
2078
  const config = new Command4("config");
1713
2079
  config.description("Manage CLI settings");
1714
2080
  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) => {
2081
+ 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
2082
  try {
1717
- setConfigValue(key, value);
2083
+ if (options.org) {
2084
+ setOrgScopedValue(options.org, key, value);
2085
+ } else {
2086
+ setConfigValue(key, value);
2087
+ }
1718
2088
  if (options.json) {
1719
- const output = { key, value };
2089
+ const output = { key, value, scope: options.org ?? "default" };
1720
2090
  if (key === "fields") {
1721
2091
  output.value = value.split(",").map((s) => s.trim());
1722
2092
  }
1723
2093
  process.stdout.write(JSON.stringify(output) + "\n");
1724
2094
  } else {
1725
- process.stdout.write(`Set "${key}" to "${value}"
2095
+ const scopeTag = options.org ? ` (org: ${options.org})` : "";
2096
+ process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
1726
2097
  `);
1727
2098
  }
1728
2099
  } catch (err) {
@@ -1733,12 +2104,12 @@ function createConfigCommand() {
1733
2104
  }
1734
2105
  });
1735
2106
  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) => {
2107
+ 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
2108
  try {
1738
- const value = getConfigValue(key);
2109
+ const value = options.org ? getOrgScopedValue(options.org, key) : getConfigValue(key);
1739
2110
  if (options.json) {
1740
2111
  process.stdout.write(
1741
- JSON.stringify({ key, value: value ?? null }) + "\n"
2112
+ JSON.stringify({ key, value: value ?? null, scope: options.org ?? "default" }) + "\n"
1742
2113
  );
1743
2114
  } else if (value === void 0) {
1744
2115
  process.stdout.write(`Setting "${key}" is not configured.
@@ -1746,7 +2117,7 @@ function createConfigCommand() {
1746
2117
  } else if (Array.isArray(value)) {
1747
2118
  process.stdout.write(value.join(",") + "\n");
1748
2119
  } else {
1749
- process.stdout.write(value + "\n");
2120
+ process.stdout.write(String(value) + "\n");
1750
2121
  }
1751
2122
  } catch (err) {
1752
2123
  const message = err instanceof Error ? err.message : String(err);
@@ -1759,19 +2130,25 @@ function createConfigCommand() {
1759
2130
  list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
1760
2131
  const cfg = loadConfig();
1761
2132
  if (options.json) {
1762
- process.stdout.write(JSON.stringify(cfg) + "\n");
2133
+ const entries = buildConfigListEntries(cfg);
2134
+ process.stdout.write(JSON.stringify(entries) + "\n");
1763
2135
  return;
1764
2136
  }
1765
2137
  writeConfigList(cfg);
1766
2138
  });
1767
2139
  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) => {
2140
+ 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
2141
  try {
1770
- unsetConfigValue(key);
2142
+ if (options.org) {
2143
+ unsetOrgScopedValue(options.org, key);
2144
+ } else {
2145
+ unsetConfigValue(key);
2146
+ }
1771
2147
  if (options.json) {
1772
- process.stdout.write(JSON.stringify({ key, unset: true }) + "\n");
2148
+ process.stdout.write(JSON.stringify({ key, unset: true, scope: options.org ?? "default" }) + "\n");
1773
2149
  } else {
1774
- process.stdout.write(`Unset "${key}"
2150
+ const scopeTag = options.org ? ` (org: ${options.org})` : "";
2151
+ process.stdout.write(`Unset "${key}"${scopeTag}
1775
2152
  `);
1776
2153
  }
1777
2154
  } catch (err) {
@@ -1803,10 +2180,52 @@ function createConfigCommand() {
1803
2180
  rl.close();
1804
2181
  process.stderr.write("Configuration complete!\n");
1805
2182
  });
2183
+ const orgCopy = new Command4("org-copy");
2184
+ 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) => {
2185
+ try {
2186
+ copyOrgScope(from, to, options.force ?? false);
2187
+ process.stdout.write(`Copied scope "${from}" to "${to}"
2188
+ `);
2189
+ } catch (err) {
2190
+ const message = err instanceof Error ? err.message : String(err);
2191
+ process.stderr.write(`Error: ${message}
2192
+ `);
2193
+ process.exit(1);
2194
+ }
2195
+ });
2196
+ const orgMove = new Command4("org-move");
2197
+ 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) => {
2198
+ try {
2199
+ moveOrgScope(from, to, options.force ?? false);
2200
+ process.stdout.write(`Moved scope "${from}" to "${to}"
2201
+ `);
2202
+ } catch (err) {
2203
+ const message = err instanceof Error ? err.message : String(err);
2204
+ process.stderr.write(`Error: ${message}
2205
+ `);
2206
+ process.exit(1);
2207
+ }
2208
+ });
2209
+ const orgDelete = new Command4("org-delete");
2210
+ orgDelete.description("Delete an org-scoped configuration").argument("<name>", "org name").action((name) => {
2211
+ try {
2212
+ deleteOrgScope(name);
2213
+ process.stdout.write(`Deleted scope "${name}"
2214
+ `);
2215
+ } catch (err) {
2216
+ const message = err instanceof Error ? err.message : String(err);
2217
+ process.stderr.write(`Error: ${message}
2218
+ `);
2219
+ process.exit(1);
2220
+ }
2221
+ });
1806
2222
  config.addCommand(set);
1807
2223
  config.addCommand(get);
1808
2224
  config.addCommand(list);
1809
2225
  config.addCommand(unset);
2226
+ config.addCommand(orgCopy);
2227
+ config.addCommand(orgMove);
2228
+ config.addCommand(orgDelete);
1810
2229
  config.addCommand(wizard);
1811
2230
  return config;
1812
2231
  }
@@ -1943,10 +2362,13 @@ function createSetFieldCommand() {
1943
2362
  import { Command as Command8 } from "commander";
1944
2363
  function createGetMdFieldCommand() {
1945
2364
  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(
2365
+ 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");
2366
+ addImageDownloadOptions(command);
2367
+ command.action(
1947
2368
  async (idStr, field, options) => {
1948
2369
  const id = parseWorkItemId(idStr);
1949
2370
  validateOrgProjectPair(options);
2371
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1950
2372
  let context;
1951
2373
  try {
1952
2374
  context = resolveContext(options);
@@ -1957,6 +2379,13 @@ function createGetMdFieldCommand() {
1957
2379
  } else {
1958
2380
  process.stdout.write(toMarkdown(value) + "\n");
1959
2381
  }
2382
+ if (imageOptions.enabled) {
2383
+ await runImageDownload(
2384
+ [{ content: value ?? "", field }],
2385
+ { workItemId: id, options: imageOptions },
2386
+ credential
2387
+ );
2388
+ }
1960
2389
  } catch (err) {
1961
2390
  handleCommandError(err, id, context, "read");
1962
2391
  }
@@ -1966,7 +2395,7 @@ function createGetMdFieldCommand() {
1966
2395
  }
1967
2396
 
1968
2397
  // src/commands/set-md-field.ts
1969
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
2398
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1970
2399
  import { Command as Command9 } from "commander";
1971
2400
  function fail(message) {
1972
2401
  process.stderr.write(`Error: ${message}
@@ -1986,7 +2415,7 @@ function resolveContent(inlineContent, options) {
1986
2415
  return null;
1987
2416
  }
1988
2417
  function readFileContent(filePath) {
1989
- if (!existsSync2(filePath)) {
2418
+ if (!existsSync3(filePath)) {
1990
2419
  fail(`File not found: ${filePath}`);
1991
2420
  }
1992
2421
  try {
@@ -2054,7 +2483,7 @@ function createSetMdFieldCommand() {
2054
2483
  }
2055
2484
 
2056
2485
  // src/commands/upsert.ts
2057
- import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
2486
+ import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
2058
2487
  import { Command as Command10 } from "commander";
2059
2488
 
2060
2489
  // src/services/task-document.ts
@@ -2218,7 +2647,7 @@ function loadSourceContent(options) {
2218
2647
  return { content: options.content };
2219
2648
  }
2220
2649
  const filePath = options.file;
2221
- if (!existsSync3(filePath)) {
2650
+ if (!existsSync4(filePath)) {
2222
2651
  fail2(`File not found: ${filePath}`);
2223
2652
  }
2224
2653
  try {
@@ -2463,6 +2892,31 @@ function buildPullRequestStatusesUrl(context, repo, prId) {
2463
2892
  url.searchParams.set("api-version", "7.1");
2464
2893
  return url;
2465
2894
  }
2895
+ function buildProjectUrl(context) {
2896
+ const url = new URL(
2897
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/projects/${encodeURIComponent(context.project)}`
2898
+ );
2899
+ url.searchParams.set("api-version", "7.1");
2900
+ return url;
2901
+ }
2902
+ function buildPolicyEvaluationsUrl(context, projectId, prId) {
2903
+ const url = new URL(
2904
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/policy/evaluations`
2905
+ );
2906
+ url.searchParams.set("api-version", "7.1");
2907
+ url.searchParams.set("artifactId", `vstfs:///CodeReview/CodeReviewId/${projectId}/${prId}`);
2908
+ return url;
2909
+ }
2910
+ function buildPullRequestBuildsUrl(context, prId) {
2911
+ const url = new URL(
2912
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/build/builds`
2913
+ );
2914
+ url.searchParams.set("branchName", `refs/pull/${prId}/merge`);
2915
+ url.searchParams.set("queryOrder", "queueTimeDescending");
2916
+ url.searchParams.set("$top", "50");
2917
+ url.searchParams.set("api-version", "7.1");
2918
+ return url;
2919
+ }
2466
2920
  function mapPullRequest(repo, pullRequest) {
2467
2921
  return {
2468
2922
  id: pullRequest.pullRequestId,
@@ -2501,7 +2955,66 @@ function mapPullRequestCheck(status2) {
2501
2955
  targetUrl: status2.targetUrl ?? null,
2502
2956
  createdBy: status2.createdBy?.displayName ?? null,
2503
2957
  createdAt: status2.creationDate ?? null,
2504
- updatedAt: status2.updatedDate ?? null
2958
+ updatedAt: status2.updatedDate ?? null,
2959
+ source: "status"
2960
+ };
2961
+ }
2962
+ function mapPolicyEvaluationState(status2) {
2963
+ switch (status2) {
2964
+ case "approved":
2965
+ return "succeeded";
2966
+ case "rejected":
2967
+ return "failed";
2968
+ case "running":
2969
+ case "queued":
2970
+ return "pending";
2971
+ case "notApplicable":
2972
+ case "notSet":
2973
+ case void 0:
2974
+ return null;
2975
+ default:
2976
+ return status2;
2977
+ }
2978
+ }
2979
+ function mapBuildToCheckState(build) {
2980
+ if (build.status !== "completed") {
2981
+ return "pending";
2982
+ }
2983
+ switch (build.result) {
2984
+ case "succeeded":
2985
+ case "partiallySucceeded":
2986
+ return "succeeded";
2987
+ case "failed":
2988
+ return "failed";
2989
+ case "canceled":
2990
+ return "error";
2991
+ default:
2992
+ return "pending";
2993
+ }
2994
+ }
2995
+ function mapPolicyEvaluationName(evaluation) {
2996
+ const display = evaluation.configuration?.settings?.displayName?.trim() || evaluation.configuration?.type?.displayName?.trim();
2997
+ if (display) {
2998
+ return display;
2999
+ }
3000
+ return `Policy ${evaluation.configuration?.id ?? evaluation.evaluationId ?? "?"}`;
3001
+ }
3002
+ function mapPolicyEvaluationCheck(evaluation) {
3003
+ const state = mapPolicyEvaluationState(evaluation.status);
3004
+ if (state === null) {
3005
+ return null;
3006
+ }
3007
+ return {
3008
+ id: evaluation.configuration?.id ?? 0,
3009
+ state,
3010
+ name: mapPolicyEvaluationName(evaluation),
3011
+ description: null,
3012
+ targetUrl: null,
3013
+ createdBy: null,
3014
+ createdAt: null,
3015
+ updatedAt: null,
3016
+ source: "policy",
3017
+ isBlocking: evaluation.configuration?.isBlocking ?? null
2505
3018
  };
2506
3019
  }
2507
3020
  function mapComment(comment) {
@@ -2521,18 +3034,22 @@ function mapThread(thread) {
2521
3034
  if (comments.length === 0) {
2522
3035
  return null;
2523
3036
  }
3037
+ const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
2524
3038
  return {
2525
3039
  id: thread.id,
2526
- status: thread.status,
3040
+ status: thread.status ?? "unknown",
2527
3041
  threadContext: thread.threadContext?.filePath ?? null,
3042
+ line,
2528
3043
  comments
2529
3044
  };
2530
3045
  }
2531
3046
  function toActiveCommentThread(thread) {
3047
+ const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
2532
3048
  return {
2533
3049
  id: thread.id,
2534
- status: thread.status,
3050
+ status: thread.status ?? "unknown",
2535
3051
  threadContext: thread.threadContext?.filePath ?? null,
3052
+ line,
2536
3053
  comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
2537
3054
  };
2538
3055
  }
@@ -2587,6 +3104,39 @@ async function getPullRequestChecks(context, repo, cred, prId) {
2587
3104
  const data = await readJsonResponse(response);
2588
3105
  return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
2589
3106
  }
3107
+ async function resolveProjectId(context, cred) {
3108
+ const response = await fetchWithErrors(buildProjectUrl(context).toString(), {
3109
+ headers: authHeaders(cred)
3110
+ });
3111
+ const data = await readJsonResponse(response);
3112
+ return data.id;
3113
+ }
3114
+ async function getPullRequestPolicyEvaluations(context, cred, projectId, prId) {
3115
+ const response = await fetchWithErrors(
3116
+ buildPolicyEvaluationsUrl(context, projectId, prId).toString(),
3117
+ { headers: authHeaders(cred) }
3118
+ );
3119
+ const data = await readJsonResponse(response);
3120
+ return data.value.map(mapPolicyEvaluationCheck).filter((check) => check !== null);
3121
+ }
3122
+ async function getPullRequestBuilds(context, cred, prId) {
3123
+ const response = await fetchWithErrors(buildPullRequestBuildsUrl(context, prId).toString(), {
3124
+ headers: authHeaders(cred)
3125
+ });
3126
+ const data = await readJsonResponse(response);
3127
+ return data.value.map((build) => ({
3128
+ id: build.id,
3129
+ state: mapBuildToCheckState(build),
3130
+ name: build.definition?.name ?? `Build #${build.id}`,
3131
+ description: null,
3132
+ targetUrl: build._links?.web?.href ?? null,
3133
+ createdBy: null,
3134
+ createdAt: build.queueTime ?? null,
3135
+ updatedAt: build.finishTime ?? null,
3136
+ source: "build",
3137
+ isBlocking: null
3138
+ }));
3139
+ }
2590
3140
  async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
2591
3141
  const existing = await listPullRequests(context, repo, cred, sourceBranch, {
2592
3142
  status: "active",
@@ -2695,34 +3245,101 @@ function handlePrCommandError(err, context, mode = "read") {
2695
3245
  }
2696
3246
  writeError(error.message);
2697
3247
  }
2698
- function formatPullRequestChecks(checks) {
3248
+ function formatPullRequestChecks(checks, checksError) {
3249
+ if (checksError) {
3250
+ return [`Checks: unable to retrieve (${checksError})`];
3251
+ }
2699
3252
  if (checks.length === 0) {
2700
3253
  return ["Checks: none reported by Azure DevOps"];
2701
3254
  }
2702
3255
  const lines = ["Checks:"];
2703
3256
  for (const check of checks) {
2704
- lines.push(`- [${check.state}] ${check.name}`);
3257
+ const optionalTag = check.isBlocking === false ? " [optional]" : "";
3258
+ lines.push(`- [${check.state}] ${check.name}${optionalTag}`);
2705
3259
  if ((check.state === "failed" || check.state === "error") && check.description) {
2706
3260
  lines.push(` Detail: ${check.description}`);
2707
3261
  }
2708
3262
  }
2709
3263
  return lines;
2710
3264
  }
3265
+ function countCodeComments(threads) {
3266
+ let open = 0;
3267
+ let closed = 0;
3268
+ for (const thread of threads) {
3269
+ if (thread.threadContext === null) {
3270
+ continue;
3271
+ }
3272
+ if (isThreadResolved(thread.status)) {
3273
+ closed += 1;
3274
+ } else {
3275
+ open += 1;
3276
+ }
3277
+ }
3278
+ return { open, closed };
3279
+ }
3280
+ function formatCodeCommentCounts(counts) {
3281
+ return `Code comments: ${counts.open} open, ${counts.closed} closed`;
3282
+ }
2711
3283
  function formatPullRequestBlock(pullRequest) {
2712
3284
  return [
2713
3285
  `#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
2714
3286
  `${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
2715
3287
  pullRequest.url ?? "\u2014",
2716
- ...formatPullRequestChecks(pullRequest.checks)
3288
+ ...formatPullRequestChecks(pullRequest.checks, pullRequest.checksError),
3289
+ formatCodeCommentCounts(pullRequest.codeCommentCounts)
2717
3290
  ].join("\n");
2718
3291
  }
3292
+ async function buildPullRequestStatusEntry(context, repo, cred, pullRequest, projectId) {
3293
+ let statusChecks = [];
3294
+ let statusOk = true;
3295
+ try {
3296
+ statusChecks = await getPullRequestChecks(context, repo, cred, pullRequest.id);
3297
+ } catch {
3298
+ statusOk = false;
3299
+ }
3300
+ let policyChecks = [];
3301
+ let policyOk = true;
3302
+ if (projectId === null) {
3303
+ policyOk = false;
3304
+ } else {
3305
+ try {
3306
+ policyChecks = await getPullRequestPolicyEvaluations(context, cred, projectId, pullRequest.id);
3307
+ } catch {
3308
+ policyOk = false;
3309
+ }
3310
+ }
3311
+ let buildChecks = [];
3312
+ let buildsOk = true;
3313
+ try {
3314
+ buildChecks = await getPullRequestBuilds(context, cred, pullRequest.id);
3315
+ } catch {
3316
+ buildsOk = false;
3317
+ }
3318
+ let codeCommentCounts;
3319
+ try {
3320
+ const threads = await getPullRequestThreads(context, repo, cred, pullRequest.id);
3321
+ codeCommentCounts = countCodeComments(threads);
3322
+ } catch {
3323
+ codeCommentCounts = { open: 0, closed: 0 };
3324
+ }
3325
+ const checks = [...statusChecks, ...policyChecks, ...buildChecks];
3326
+ const checksError = checks.length === 0 && (!statusOk || !policyOk || !buildsOk) ? "Azure DevOps request failed" : null;
3327
+ return {
3328
+ ...pullRequest,
3329
+ checks,
3330
+ codeCommentCounts,
3331
+ checksError
3332
+ };
3333
+ }
2719
3334
  function threadStatusLabel(status2) {
2720
3335
  return isThreadResolved(status2) ? "resolved" : status2;
2721
3336
  }
2722
3337
  function formatThreads(prId, title, threads) {
2723
3338
  const lines = [`Comment threads for pull request #${prId}: ${title}`];
2724
3339
  for (const thread of threads) {
2725
- lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${thread.threadContext ?? "(general)"}`);
3340
+ const lineSuffix = thread.line === null ? "" : `:${thread.line}`;
3341
+ const location = thread.threadContext ? `${thread.threadContext}${lineSuffix}` : "(general)";
3342
+ lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${location}`);
2726
3343
  for (const comment of thread.comments) {
2727
3344
  lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
2728
3345
  }
@@ -2752,11 +3369,16 @@ function createPrStatusCommand() {
2752
3369
  context = resolved.context;
2753
3370
  const branch = resolved.branch;
2754
3371
  const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
3372
+ let projectId = null;
3373
+ try {
3374
+ projectId = await resolveProjectId(resolved.context, resolved.pat);
3375
+ } catch {
3376
+ projectId = null;
3377
+ }
2755
3378
  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
- }))
3379
+ pullRequests.map(
3380
+ async (pullRequest) => buildPullRequestStatusEntry(resolved.context, resolved.repo, resolved.pat, pullRequest, projectId)
3381
+ )
2760
3382
  );
2761
3383
  const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
2762
3384
  if (options.json) {
@@ -2837,7 +3459,7 @@ ${result.pullRequest.url ?? "\u2014"}
2837
3459
  }
2838
3460
  function createPrCommentsCommand() {
2839
3461
  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) => {
3462
+ 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
3463
  validateOrgProjectPair(options);
2842
3464
  let context;
2843
3465
  let explicitPrId = null;
@@ -2879,8 +3501,12 @@ function createPrCommentsCommand() {
2879
3501
  pullRequest = pullRequests[0];
2880
3502
  branchLabel = resolved.branch;
2881
3503
  }
3504
+ const hideResolved = options.hideResolved === true || options.excludeResolved === true;
3505
+ const codeRelatedOnly = options.codeRelatedOnly === true;
2882
3506
  const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
2883
- const threads = options.hideResolved ? allThreads.filter((thread) => !isThreadResolved(thread.status)) : allThreads;
3507
+ const threads = allThreads.filter(
3508
+ (thread) => (!hideResolved || !isThreadResolved(thread.status)) && (!codeRelatedOnly || thread.threadContext !== null)
3509
+ );
2884
3510
  const result = { branch: branchLabel, pullRequest, threads };
2885
3511
  if (options.json) {
2886
3512
  process.stdout.write(`${JSON.stringify(result, null, 2)}
@@ -2888,9 +3514,18 @@ function createPrCommentsCommand() {
2888
3514
  return;
2889
3515
  }
2890
3516
  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
- `);
3517
+ if (allThreads.length > 0 && (hideResolved || codeRelatedOnly)) {
3518
+ const filters = [];
3519
+ if (codeRelatedOnly) {
3520
+ filters.push("code-related");
3521
+ }
3522
+ if (hideResolved) {
3523
+ filters.push("unresolved");
3524
+ }
3525
+ process.stdout.write(
3526
+ `Pull request #${pullRequest.id} has no ${filters.join(" ")} comment threads (filtered from ${allThreads.length} thread${allThreads.length === 1 ? "" : "s"}).
3527
+ `
3528
+ );
2894
3529
  } else {
2895
3530
  process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
2896
3531
  `);
@@ -3032,28 +3667,940 @@ function createPrCommand() {
3032
3667
  return command;
3033
3668
  }
3034
3669
 
3035
- // src/commands/comments.ts
3670
+ // src/commands/pipeline.ts
3036
3671
  import { Command as Command13 } from "commander";
3037
- function writeError2(message) {
3038
- process.stderr.write(`Error: ${message}
3039
- `);
3040
- process.exit(1);
3672
+
3673
+ // src/services/pipeline-client.ts
3674
+ var API_VERSION = "7.1";
3675
+ function orgProjectBase(context) {
3676
+ return `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}`;
3041
3677
  }
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}`;
3678
+ function withApiVersion(url) {
3679
+ url.searchParams.set("api-version", API_VERSION);
3680
+ return url;
3046
3681
  }
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);
3682
+ async function readJsonResponse2(response) {
3683
+ if (!response.ok) {
3684
+ throw new Error(`HTTP_${response.status}`);
3685
+ }
3686
+ return await response.json();
3687
+ }
3688
+ function mapPipeline(pipeline) {
3689
+ return {
3690
+ id: pipeline.id,
3691
+ name: pipeline.name,
3692
+ folder: pipeline.folder?.trim() ? pipeline.folder : null
3693
+ };
3694
+ }
3695
+ function mapRunState(state) {
3696
+ if (state === "inProgress" || state === "completed") {
3697
+ return state;
3698
+ }
3699
+ return "unknown";
3700
+ }
3701
+ function mapRunResult(result) {
3702
+ if (result === "succeeded" || result === "failed" || result === "canceled") {
3703
+ return result;
3704
+ }
3705
+ if (result === "partiallySucceeded") {
3706
+ return "failed";
3707
+ }
3708
+ return null;
3709
+ }
3710
+ function mapBuildState(status2) {
3711
+ if (status2 === "completed") return "completed";
3712
+ if (status2 === void 0 || status2 === "none") return "unknown";
3713
+ return "inProgress";
3714
+ }
3715
+ function mapBuildSummary(build) {
3716
+ return {
3717
+ id: build.id,
3718
+ name: build.buildNumber ?? null,
3719
+ state: mapBuildState(build.status),
3720
+ result: mapRunResult(build.result),
3721
+ createdDate: build.queueTime ?? build.startTime ?? null,
3722
+ finishedDate: build.finishTime ?? null,
3723
+ sourceBranch: build.sourceBranch ?? null,
3724
+ sourceCommit: build.sourceVersion ?? null
3725
+ };
3726
+ }
3727
+ function normalizeRef(branch) {
3728
+ return branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
3729
+ }
3730
+ async function getPipelineDefinitions(context, cred) {
3731
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/pipelines`));
3732
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3733
+ const data = await readJsonResponse2(response);
3734
+ return data.value.map(mapPipeline);
3735
+ }
3736
+ var COMMIT_LOOKBACK = 200;
3737
+ async function getPipelineRuns(context, cred, query) {
3738
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds`));
3739
+ if (query.definitionId !== void 0) {
3740
+ url.searchParams.set("definitions", String(query.definitionId));
3741
+ }
3742
+ if (query.prNumber !== void 0) {
3743
+ url.searchParams.set("branchName", `refs/pull/${query.prNumber}/merge`);
3744
+ } else if (query.branch) {
3745
+ url.searchParams.set("branchName", normalizeRef(query.branch));
3746
+ }
3747
+ url.searchParams.set("queryOrder", "queueTimeDescending");
3748
+ url.searchParams.set("$top", String(query.commit ? COMMIT_LOOKBACK : query.top));
3749
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3750
+ const data = await readJsonResponse2(response);
3751
+ let runs = data.value.map(mapBuildSummary);
3752
+ if (query.commit) {
3753
+ const needle = query.commit.toLowerCase();
3754
+ runs = runs.filter((run) => run.sourceCommit?.toLowerCase().startsWith(needle));
3755
+ }
3756
+ return runs.slice(0, query.top);
3757
+ }
3758
+ async function runPipeline(context, cred, pipelineId, opts) {
3759
+ const url = withApiVersion(
3760
+ new URL(`${orgProjectBase(context)}/_apis/pipelines/${pipelineId}/runs`)
3761
+ );
3762
+ const body = {};
3763
+ if (opts.branch) {
3764
+ const refName = opts.branch.startsWith("refs/") ? opts.branch : `refs/heads/${opts.branch}`;
3765
+ body.resources = { repositories: { self: { refName } } };
3766
+ }
3767
+ if (opts.parameters && Object.keys(opts.parameters).length > 0) {
3768
+ body.templateParameters = opts.parameters;
3769
+ }
3770
+ const response = await fetchWithErrors(url.toString(), {
3771
+ method: "POST",
3772
+ headers: { ...authHeaders(cred), "Content-Type": "application/json" },
3773
+ body: JSON.stringify(body)
3774
+ });
3775
+ const data = await readJsonResponse2(response);
3776
+ return {
3777
+ id: data.id,
3778
+ state: mapRunState(data.state),
3779
+ webUrl: data._links?.web?.href ?? null
3780
+ };
3781
+ }
3782
+ function buildUrl(context, buildId) {
3783
+ return withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}`));
3784
+ }
3785
+ async function getBuild(context, cred, buildId) {
3786
+ const response = await fetchWithErrors(buildUrl(context, buildId).toString(), {
3787
+ headers: authHeaders(cred)
3788
+ });
3789
+ return readJsonResponse2(response);
3790
+ }
3791
+ async function getBuildStatus(context, cred, buildId) {
3792
+ const build = await getBuild(context, cred, buildId);
3793
+ return { state: mapBuildState(build.status), result: mapRunResult(build.result) };
3794
+ }
3795
+ async function getBuildTimeline(context, cred, buildId) {
3796
+ const url = withApiVersion(
3797
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/timeline`)
3798
+ );
3799
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3800
+ const data = await readJsonResponse2(response);
3801
+ const records = data.records ?? [];
3802
+ const errors = [];
3803
+ const stages = [];
3804
+ const jobs = [];
3805
+ const logSteps = /* @__PURE__ */ new Map();
3806
+ for (const record of records) {
3807
+ for (const issue of record.issues ?? []) {
3808
+ if (issue.type === "error" && issue.message) {
3809
+ errors.push({ message: issue.message, source: record.name ?? null });
3810
+ }
3811
+ }
3812
+ if (record.name && record.log?.id !== void 0) {
3813
+ logSteps.set(record.log.id, record.name);
3814
+ }
3815
+ if (!record.name) continue;
3816
+ const status2 = {
3817
+ name: record.name,
3818
+ state: record.state ?? "unknown",
3819
+ result: record.result ?? null
3820
+ };
3821
+ if (record.type === "Stage") {
3822
+ stages.push(status2);
3823
+ } else if (record.type === "Job") {
3824
+ jobs.push({ startTime: record.startTime, status: status2 });
3825
+ }
3826
+ }
3827
+ jobs.sort((a, b) => {
3828
+ if (a.startTime === b.startTime) return 0;
3829
+ if (a.startTime === void 0) return 1;
3830
+ if (b.startTime === void 0) return -1;
3831
+ return a.startTime < b.startTime ? -1 : 1;
3832
+ });
3833
+ return { errors, stages, jobs: jobs.map((j) => j.status), logSteps };
3834
+ }
3835
+ async function listTestRuns(context, cred, buildId) {
3836
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/test/runs`));
3837
+ url.searchParams.set("buildUri", `vstfs:///Build/Build/${buildId}`);
3838
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3839
+ const data = await readJsonResponse2(response);
3840
+ return data.value;
3841
+ }
3842
+ async function getTestSummary(context, cred, buildId) {
3843
+ const testRuns = await listTestRuns(context, cred, buildId);
3844
+ let total = 0;
3845
+ let failed = 0;
3846
+ for (const run of testRuns) {
3847
+ const runTotal = run.totalTests ?? 0;
3848
+ total += runTotal;
3849
+ const passedOrSkipped = (run.passedTests ?? 0) + (run.notApplicableTests ?? 0) + (run.incompleteTests ?? 0);
3850
+ failed += Math.max(0, runTotal - passedOrSkipped);
3851
+ }
3852
+ if (total === 0) {
3853
+ return { present: false, total: 0, failed: 0, failedTests: [] };
3854
+ }
3855
+ return { present: true, total, failed, failedTests: [] };
3856
+ }
3857
+ var MAX_FAILED_TESTS = 50;
3858
+ async function getFailedTests(context, cred, buildId) {
3859
+ const testRuns = await listTestRuns(context, cred, buildId);
3860
+ const failed = [];
3861
+ for (const testRun of testRuns) {
3862
+ if (failed.length >= MAX_FAILED_TESTS) break;
3863
+ const resultsUrl = withApiVersion(
3864
+ new URL(`${orgProjectBase(context)}/_apis/test/runs/${testRun.id}/results`)
3865
+ );
3866
+ resultsUrl.searchParams.set("outcomes", "Failed");
3867
+ resultsUrl.searchParams.set("$top", String(MAX_FAILED_TESTS - failed.length));
3868
+ const resultsResponse = await fetchWithErrors(resultsUrl.toString(), {
3869
+ headers: authHeaders(cred)
3870
+ });
3871
+ const resultsData = await readJsonResponse2(resultsResponse);
3872
+ for (const result of resultsData.value) {
3873
+ failed.push({
3874
+ name: result.testCaseTitle ?? result.automatedTestName ?? "(unnamed test)",
3875
+ errorMessage: result.errorMessage ?? null
3876
+ });
3877
+ }
3878
+ }
3879
+ return failed;
3880
+ }
3881
+ function secondsBetween(start, finish) {
3882
+ if (!start || !finish) return null;
3883
+ const ms = Date.parse(finish) - Date.parse(start);
3884
+ return Number.isFinite(ms) ? Math.round(ms / 1e3) : null;
3885
+ }
3886
+ async function getRunDetail(context, cred, buildId) {
3887
+ const build = await getBuild(context, cred, buildId);
3888
+ let errors = [];
3889
+ let stages = [];
3890
+ let jobs = [];
3891
+ let errorsAvailable = true;
3892
+ try {
3893
+ const timeline = await getBuildTimeline(context, cred, buildId);
3894
+ errors = timeline.errors;
3895
+ stages = timeline.stages;
3896
+ jobs = timeline.jobs;
3897
+ } catch {
3898
+ errorsAvailable = false;
3899
+ }
3900
+ let tests = { present: false, total: 0, failed: 0, failedTests: [] };
3901
+ let testsAvailable = true;
3902
+ try {
3903
+ tests = await getTestSummary(context, cred, buildId);
3904
+ if (tests.failed > 0) {
3905
+ try {
3906
+ tests = { ...tests, failedTests: await getFailedTests(context, cred, buildId) };
3907
+ } catch {
3908
+ }
3909
+ }
3910
+ } catch {
3911
+ testsAvailable = false;
3912
+ }
3913
+ return {
3914
+ id: build.id,
3915
+ name: build.buildNumber ?? null,
3916
+ state: mapBuildState(build.status),
3917
+ result: mapRunResult(build.result),
3918
+ // createdDate is the queue time, matching the run-list mapping.
3919
+ createdDate: build.queueTime ?? build.startTime ?? null,
3920
+ startedDate: build.startTime ?? null,
3921
+ finishedDate: build.finishTime ?? null,
3922
+ durationSeconds: secondsBetween(build.startTime, build.finishTime),
3923
+ reason: build.reason ?? null,
3924
+ requestedFor: build.requestedFor?.displayName ?? null,
3925
+ sourceBranch: build.sourceBranch ?? null,
3926
+ sourceCommit: build.sourceVersion ?? null,
3927
+ webUrl: build._links?.web?.href ?? null,
3928
+ errors,
3929
+ errorsAvailable,
3930
+ stages,
3931
+ jobs,
3932
+ tests,
3933
+ testsAvailable
3934
+ };
3935
+ }
3936
+ async function getRunLogs(context, cred, buildId) {
3937
+ const url = withApiVersion(
3938
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs`)
3939
+ );
3940
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3941
+ const data = await readJsonResponse2(response);
3942
+ let logSteps = /* @__PURE__ */ new Map();
3943
+ try {
3944
+ logSteps = (await getBuildTimeline(context, cred, buildId)).logSteps;
3945
+ } catch {
3946
+ }
3947
+ return data.value.map((log) => ({
3948
+ id: log.id,
3949
+ createdOn: log.createdOn ?? null,
3950
+ lineCount: log.lineCount ?? null,
3951
+ step: logSteps.get(log.id) ?? null
3952
+ }));
3953
+ }
3954
+ async function getRunLog(context, cred, buildId, logId) {
3955
+ const url = withApiVersion(
3956
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs/${logId}`)
3957
+ );
3958
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3959
+ if (!response.ok) {
3960
+ throw new Error(`HTTP_${response.status}`);
3961
+ }
3962
+ return response.text();
3963
+ }
3964
+
3965
+ // src/commands/pipeline.ts
3966
+ var EXIT_FAILED = 1;
3967
+ var EXIT_CANCELED = 2;
3968
+ var EXIT_TIMEOUT = 124;
3969
+ function writeError2(message) {
3970
+ process.stderr.write(`Error: ${message}
3971
+ `);
3972
+ process.exitCode = 1;
3973
+ }
3974
+ function handlePipelineError(err, context) {
3975
+ const error = err instanceof Error ? err : new Error(String(err));
3976
+ if (error.message === "AUTH_FAILED") {
3977
+ writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
3978
+ return;
3979
+ }
3980
+ if (error.message === "PERMISSION_DENIED") {
3981
+ writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
3982
+ return;
3983
+ }
3984
+ if (error.message === "NETWORK_ERROR") {
3985
+ writeError2("Could not connect to Azure DevOps. Check your network connection.");
3986
+ return;
3987
+ }
3988
+ if (error.message.startsWith("NOT_FOUND")) {
3989
+ writeError2(`Resource not found in ${context?.org}/${context?.project}.`);
3990
+ return;
3991
+ }
3992
+ if (error.message.startsWith("HTTP_")) {
3993
+ writeError2(`Azure DevOps request failed with ${error.message}.`);
3994
+ return;
3995
+ }
3996
+ writeError2(error.message);
3997
+ }
3998
+ function parsePositiveId(raw) {
3999
+ if (!/^\d+$/.test(raw)) return null;
4000
+ const n = Number.parseInt(raw, 10);
4001
+ return Number.isFinite(n) && n > 0 ? n : null;
4002
+ }
4003
+ function parseOptionalCount(value, flag) {
4004
+ if (value === void 0) return void 0;
4005
+ const parsed = parsePositiveId(value);
4006
+ if (parsed === null) {
4007
+ writeError2(`Invalid ${flag} "${value}"; expected a positive integer.`);
4008
+ return null;
4009
+ }
4010
+ return parsed;
4011
+ }
4012
+ function formatBranchName2(refName) {
4013
+ if (!refName) return "\u2014";
4014
+ return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
4015
+ }
4016
+ async function resolvePipelineContext(options) {
4017
+ const context = resolveContext(options);
4018
+ const cred = await requireAuthCredential(context.org);
4019
+ return { context, cred };
4020
+ }
4021
+ function sleep(ms) {
4022
+ return new Promise((resolve2) => {
4023
+ setTimeout(resolve2, ms);
4024
+ });
4025
+ }
4026
+ function formatTable(rows, rightAlign = /* @__PURE__ */ new Set()) {
4027
+ const widths = [];
4028
+ for (const row of rows) {
4029
+ row.forEach((cell, i) => {
4030
+ widths[i] = Math.max(widths[i] ?? 0, cell.length);
4031
+ });
4032
+ }
4033
+ return rows.map(
4034
+ (row) => row.map((cell, i) => rightAlign.has(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i])).join(" ").trimEnd()
4035
+ ).join("\n");
4036
+ }
4037
+ function createPipelineListCommand() {
4038
+ const command = new Command13("list");
4039
+ 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) => {
4040
+ validateOrgProjectPair(options);
4041
+ let context;
4042
+ try {
4043
+ const resolved = await resolvePipelineContext(options);
4044
+ context = resolved.context;
4045
+ let definitions = await getPipelineDefinitions(resolved.context, resolved.cred);
4046
+ if (options.filter) {
4047
+ const needle = options.filter.toLowerCase();
4048
+ definitions = definitions.filter((d) => d.name.toLowerCase().includes(needle));
4049
+ }
4050
+ if (options.json) {
4051
+ process.stdout.write(`${JSON.stringify(definitions, null, 2)}
4052
+ `);
4053
+ return;
4054
+ }
4055
+ if (definitions.length === 0) {
4056
+ process.stdout.write("No pipelines found.\n");
4057
+ return;
4058
+ }
4059
+ const hasFolder = definitions.some((d) => d.folder);
4060
+ const rows = definitions.map(
4061
+ (d) => hasFolder ? [String(d.id), d.name, d.folder ?? ""] : [String(d.id), d.name]
4062
+ );
4063
+ process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
4064
+ `);
4065
+ } catch (err) {
4066
+ handlePipelineError(err, context);
4067
+ }
4068
+ });
4069
+ return command;
4070
+ }
4071
+ function runRow(run) {
4072
+ const status2 = run.result ? `${run.state}/${run.result}` : run.state;
4073
+ return [
4074
+ String(run.id),
4075
+ `[${status2}]`,
4076
+ run.createdDate ?? "\u2014",
4077
+ formatBranchName2(run.sourceBranch),
4078
+ run.sourceCommit ? run.sourceCommit.slice(0, 8) : "\u2014"
4079
+ ];
4080
+ }
4081
+ var COMMIT_SHA_PATTERN = /^[0-9a-f]{6,40}$/i;
4082
+ function parseGetRunsInputs(defIdRaw, options) {
4083
+ let defId;
4084
+ if (defIdRaw !== void 0) {
4085
+ const parsed = parsePositiveId(defIdRaw);
4086
+ if (parsed === null) {
4087
+ writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
4088
+ return null;
4089
+ }
4090
+ defId = parsed;
4091
+ } else if (options.commit === void 0 && options.pr === void 0) {
4092
+ writeError2("Definition id is required unless --commit or --pr is given.");
4093
+ return null;
4094
+ }
4095
+ const limit = parseOptionalCount(options.limit, "--limit");
4096
+ if (limit === null) return null;
4097
+ const prNumber = parseOptionalCount(options.pr, "--pr");
4098
+ if (prNumber === null) return null;
4099
+ if (options.commit !== void 0 && !COMMIT_SHA_PATTERN.test(options.commit)) {
4100
+ writeError2(`Invalid --commit "${options.commit}"; expected 6-40 hex characters.`);
4101
+ return null;
4102
+ }
4103
+ if (options.branch !== void 0 && prNumber !== void 0) {
4104
+ writeError2("Use either --branch or --pr, not both.");
4105
+ return null;
4106
+ }
4107
+ return { defId, limit: limit ?? 10, prNumber };
4108
+ }
4109
+ function createPipelineGetRunsCommand() {
4110
+ const command = new Command13("get-runs");
4111
+ 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) => {
4112
+ validateOrgProjectPair(options);
4113
+ const inputs = parseGetRunsInputs(defIdRaw, options);
4114
+ if (inputs === null) {
4115
+ return;
4116
+ }
4117
+ let context;
4118
+ try {
4119
+ const resolved = await resolvePipelineContext(options);
4120
+ context = resolved.context;
4121
+ const runs = await getPipelineRuns(resolved.context, resolved.cred, {
4122
+ definitionId: inputs.defId,
4123
+ branch: options.branch,
4124
+ prNumber: inputs.prNumber,
4125
+ commit: options.commit,
4126
+ top: inputs.limit
4127
+ });
4128
+ if (options.json) {
4129
+ process.stdout.write(`${JSON.stringify(runs, null, 2)}
4130
+ `);
4131
+ return;
4132
+ }
4133
+ if (runs.length === 0) {
4134
+ process.stdout.write(
4135
+ inputs.defId === void 0 ? "No runs found matching the filters.\n" : `No runs found for pipeline ${inputs.defId}.
4136
+ `
4137
+ );
4138
+ return;
4139
+ }
4140
+ process.stdout.write(`${formatTable(runs.map(runRow), /* @__PURE__ */ new Set([0]))}
4141
+ `);
4142
+ } catch (err) {
4143
+ handlePipelineError(err, context);
4144
+ }
4145
+ });
4146
+ return command;
4147
+ }
4148
+ function applyWaitExitCode(result) {
4149
+ if (result.timedOut) {
4150
+ process.exitCode = EXIT_TIMEOUT;
4151
+ return;
4152
+ }
4153
+ switch (result.result) {
4154
+ case "succeeded":
4155
+ return;
4156
+ case "canceled":
4157
+ process.exitCode = EXIT_CANCELED;
4158
+ return;
4159
+ case "failed":
4160
+ default:
4161
+ process.exitCode = EXIT_FAILED;
4162
+ }
4163
+ }
4164
+ function createPipelineWaitCommand() {
4165
+ const command = new Command13("wait");
4166
+ 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) => {
4167
+ validateOrgProjectPair(options);
4168
+ const runId = parsePositiveId(runIdRaw);
4169
+ if (runId === null) {
4170
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4171
+ return;
4172
+ }
4173
+ const timeoutSec = options.timeout === void 0 ? 1800 : Number(options.timeout);
4174
+ const pollSec = options.pollInterval === void 0 ? 5 : Number(options.pollInterval);
4175
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
4176
+ writeError2(`Invalid --timeout "${options.timeout}"; expected a non-negative number.`);
4177
+ return;
4178
+ }
4179
+ if (!Number.isFinite(pollSec) || pollSec <= 0) {
4180
+ writeError2(`Invalid --poll-interval "${options.pollInterval}"; expected a positive number.`);
4181
+ return;
4182
+ }
4183
+ let context;
4184
+ try {
4185
+ const resolved = await resolvePipelineContext(options);
4186
+ context = resolved.context;
4187
+ const deadline = Date.now() + timeoutSec * 1e3;
4188
+ let waitResult = null;
4189
+ for (; ; ) {
4190
+ const status2 = await getBuildStatus(resolved.context, resolved.cred, runId);
4191
+ if (status2.state === "completed") {
4192
+ waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: false };
4193
+ break;
4194
+ }
4195
+ if (Date.now() >= deadline) {
4196
+ waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: true };
4197
+ break;
4198
+ }
4199
+ await sleep(pollSec * 1e3);
4200
+ }
4201
+ applyWaitExitCode(waitResult);
4202
+ if (options.json) {
4203
+ process.stdout.write(`${JSON.stringify(waitResult, null, 2)}
4204
+ `);
4205
+ return;
4206
+ }
4207
+ if (waitResult.timedOut) {
4208
+ process.stdout.write(`Run ${runId} did not finish within ${timeoutSec}s (still ${waitResult.state}).
4209
+ `);
4210
+ } else {
4211
+ process.stdout.write(`Run ${runId} finished: ${waitResult.result ?? waitResult.state}.
4212
+ `);
4213
+ }
4214
+ } catch (err) {
4215
+ handlePipelineError(err, context);
4216
+ }
4217
+ });
4218
+ return command;
4219
+ }
4220
+ function timelineRows(items, available) {
4221
+ if (!available) {
4222
+ return [" unavailable"];
4223
+ }
4224
+ if (items.length === 0) {
4225
+ return [" (none)"];
4226
+ }
4227
+ return items.map((item) => ` - ${item.name} [${item.result ?? item.state}]`);
4228
+ }
4229
+ function formatDuration(totalSeconds) {
4230
+ const h = Math.floor(totalSeconds / 3600);
4231
+ const m = Math.floor(totalSeconds % 3600 / 60);
4232
+ const s = totalSeconds % 60;
4233
+ if (h > 0) return `${h}h${m}m${s}s`;
4234
+ if (m > 0) return `${m}m${s}s`;
4235
+ return `${s}s`;
4236
+ }
4237
+ function errorRows(detail) {
4238
+ if (!detail.errorsAvailable) {
4239
+ return [" unavailable"];
4240
+ }
4241
+ if (detail.errors.length === 0) {
4242
+ return [" (none)"];
4243
+ }
4244
+ return detail.errors.map((error) => {
4245
+ const source = error.source ? `[${error.source}] ` : "";
4246
+ return ` - ${source}${error.message}`;
4247
+ });
4248
+ }
4249
+ function failedTestRow(test) {
4250
+ if (!test.errorMessage) {
4251
+ return ` - ${test.name}`;
4252
+ }
4253
+ const firstLine = test.errorMessage.split("\n", 1)[0].trim();
4254
+ return ` - ${test.name}: ${firstLine}`;
4255
+ }
4256
+ function testRows(detail) {
4257
+ if (!detail.testsAvailable) {
4258
+ return [" unavailable"];
4259
+ }
4260
+ if (detail.tests.present) {
4261
+ return [
4262
+ ` ${detail.tests.failed} failing of ${detail.tests.total}`,
4263
+ ...detail.tests.failedTests.map(failedTestRow)
4264
+ ];
4265
+ }
4266
+ return [" no tests present"];
4267
+ }
4268
+ function formatRunDetail(detail) {
4269
+ const status2 = detail.result ? `${detail.state}/${detail.result}` : detail.state;
4270
+ const name = detail.name ? ` ${detail.name}` : "";
4271
+ const duration = detail.durationSeconds == null ? "\u2014" : formatDuration(detail.durationSeconds);
4272
+ return [
4273
+ `Run #${detail.id} [${status2}]${name}`,
4274
+ `Queued: ${detail.createdDate ?? "\u2014"} Started: ${detail.startedDate ?? "\u2014"} Finished: ${detail.finishedDate ?? "\u2014"}`,
4275
+ `Duration: ${duration} Reason: ${detail.reason ?? "\u2014"} Requested for: ${detail.requestedFor ?? "\u2014"}`,
4276
+ `Branch: ${formatBranchName2(detail.sourceBranch)} Commit: ${detail.sourceCommit ?? "unavailable"}`,
4277
+ ...detail.webUrl ? [`Link: ${detail.webUrl}`] : [],
4278
+ "",
4279
+ "Stages:",
4280
+ ...timelineRows(detail.stages, detail.errorsAvailable),
4281
+ "",
4282
+ "Jobs:",
4283
+ ...timelineRows(detail.jobs, detail.errorsAvailable),
4284
+ "",
4285
+ "Errors:",
4286
+ ...errorRows(detail),
4287
+ "",
4288
+ "Tests:",
4289
+ ...testRows(detail)
4290
+ ].join("\n");
4291
+ }
4292
+ function createPipelineGetRunDetailCommand() {
4293
+ const command = new Command13("get-run-detail");
4294
+ 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) => {
4295
+ validateOrgProjectPair(options);
4296
+ const runId = parsePositiveId(runIdRaw);
4297
+ if (runId === null) {
4298
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4299
+ return;
4300
+ }
4301
+ let context;
4302
+ try {
4303
+ const resolved = await resolvePipelineContext(options);
4304
+ context = resolved.context;
4305
+ const detail = await getRunDetail(resolved.context, resolved.cred, runId);
4306
+ if (options.json) {
4307
+ process.stdout.write(`${JSON.stringify(detail, null, 2)}
4308
+ `);
4309
+ return;
4310
+ }
4311
+ process.stdout.write(`${formatRunDetail(detail)}
4312
+ `);
4313
+ } catch (err) {
4314
+ handlePipelineError(err, context);
4315
+ }
4316
+ });
4317
+ return command;
4318
+ }
4319
+ function grepWithContext(lines, grep, context) {
4320
+ const include = /* @__PURE__ */ new Set();
4321
+ lines.forEach((line, i) => {
4322
+ if (grep.test(line)) {
4323
+ for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) {
4324
+ include.add(j);
4325
+ }
4326
+ }
4327
+ });
4328
+ const selected = [];
4329
+ let prev = -1;
4330
+ for (const i of [...include].sort((a, b) => a - b)) {
4331
+ if (selected.length > 0 && i > prev + 1) {
4332
+ selected.push("--");
4333
+ }
4334
+ selected.push(lines[i]);
4335
+ prev = i;
4336
+ }
4337
+ return selected;
4338
+ }
4339
+ function filterLogLines(content, grep, tail, context) {
4340
+ let lines = content.split("\n");
4341
+ if (lines.at(-1) === "") {
4342
+ lines.pop();
4343
+ }
4344
+ if (grep) {
4345
+ lines = context > 0 ? grepWithContext(lines, grep, context) : lines.filter((line) => grep.test(line));
4346
+ }
4347
+ if (tail !== void 0 && lines.length > tail) {
4348
+ lines = lines.slice(-tail);
4349
+ }
4350
+ return lines;
4351
+ }
4352
+ function parseLogFilters(options) {
4353
+ if (options.logId !== void 0 && options.step !== void 0) {
4354
+ writeError2("Use either --log-id or --step, not both.");
4355
+ return null;
4356
+ }
4357
+ const selectsSingleLog = options.logId !== void 0 || options.step !== void 0;
4358
+ const slices = options.tail !== void 0 || options.grep !== void 0 || options.context !== void 0;
4359
+ if (slices && !selectsSingleLog) {
4360
+ writeError2("--tail, --grep, and --context require --log-id or --step.");
4361
+ return null;
4362
+ }
4363
+ if (options.context !== void 0 && options.grep === void 0) {
4364
+ writeError2("--context requires --grep.");
4365
+ return null;
4366
+ }
4367
+ const tail = parseOptionalCount(options.tail, "--tail");
4368
+ if (tail === null) return null;
4369
+ const contextLines = parseOptionalCount(options.context, "--context");
4370
+ if (contextLines === null) return null;
4371
+ let grep;
4372
+ if (options.grep !== void 0) {
4373
+ try {
4374
+ grep = new RegExp(options.grep);
4375
+ } catch {
4376
+ writeError2(`Invalid --grep "${options.grep}"; expected a valid regular expression.`);
4377
+ return null;
4378
+ }
4379
+ }
4380
+ return { tail, contextLines: contextLines ?? 0, grep };
4381
+ }
4382
+ function chooseStepLog(logs, step, runId) {
4383
+ const needle = step.toLowerCase();
4384
+ const matches = logs.filter((l) => l.step?.toLowerCase().includes(needle));
4385
+ const exact = matches.filter((l) => l.step?.toLowerCase() === needle);
4386
+ const chosen = exact.length === 1 ? exact : matches;
4387
+ if (chosen.length === 0) {
4388
+ writeError2(`No log matches step "${step}" in run ${runId}.`);
4389
+ return null;
4390
+ }
4391
+ if (chosen.length > 1) {
4392
+ const candidates = chosen.map((l) => `${l.id} (${l.step})`).join(", ");
4393
+ writeError2(`Step "${step}" matches multiple logs: ${candidates}. Be more specific or use --log-id.`);
4394
+ return null;
4395
+ }
4396
+ return chosen[0].id;
4397
+ }
4398
+ async function resolveRequestedLogId(resolved, runId, options) {
4399
+ if (options.logId !== void 0) {
4400
+ const parsed = parsePositiveId(options.logId);
4401
+ if (parsed === null) {
4402
+ writeError2(`Invalid --log-id "${options.logId}"; expected a positive integer.`);
4403
+ return null;
4404
+ }
4405
+ return parsed;
4406
+ }
4407
+ if (options.step === void 0) {
4408
+ return void 0;
4409
+ }
4410
+ const allLogs = await getRunLogs(resolved.context, resolved.cred, runId);
4411
+ return chooseStepLog(allLogs, options.step, runId);
4412
+ }
4413
+ function printSingleLog(content, filters) {
4414
+ if (filters.grep !== void 0 || filters.tail !== void 0) {
4415
+ const lines = filterLogLines(content, filters.grep, filters.tail, filters.contextLines);
4416
+ if (lines.length > 0) {
4417
+ process.stdout.write(`${lines.join("\n")}
4418
+ `);
4419
+ }
4420
+ return;
4421
+ }
4422
+ process.stdout.write(content.endsWith("\n") ? content : `${content}
4423
+ `);
4424
+ }
4425
+ function createPipelineLogsCommand() {
4426
+ const command = new Command13("logs");
4427
+ 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) => {
4428
+ validateOrgProjectPair(options);
4429
+ const runId = parsePositiveId(runIdRaw);
4430
+ if (runId === null) {
4431
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4432
+ return;
4433
+ }
4434
+ const filters = parseLogFilters(options);
4435
+ if (filters === null) {
4436
+ return;
4437
+ }
4438
+ let context;
4439
+ try {
4440
+ const resolved = await resolvePipelineContext(options);
4441
+ context = resolved.context;
4442
+ const logId = await resolveRequestedLogId(resolved, runId, options);
4443
+ if (logId === null) {
4444
+ return;
4445
+ }
4446
+ if (logId !== void 0) {
4447
+ const content = await getRunLog(resolved.context, resolved.cred, runId, logId);
4448
+ printSingleLog(content, filters);
4449
+ return;
4450
+ }
4451
+ const logs = await getRunLogs(resolved.context, resolved.cred, runId);
4452
+ if (options.json) {
4453
+ process.stdout.write(`${JSON.stringify(logs, null, 2)}
4454
+ `);
4455
+ return;
4456
+ }
4457
+ if (logs.length === 0) {
4458
+ process.stdout.write(`No logs found for run ${runId}.
4459
+ `);
4460
+ return;
4461
+ }
4462
+ const rows = logs.map((l) => [
4463
+ String(l.id),
4464
+ l.createdOn ?? "\u2014",
4465
+ l.lineCount == null ? "" : `${l.lineCount} lines`,
4466
+ l.step ?? ""
4467
+ ]);
4468
+ process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
4469
+ `);
4470
+ } catch (err) {
4471
+ handlePipelineError(err, context);
4472
+ }
4473
+ });
4474
+ return command;
4475
+ }
4476
+ function createPipelineTestsCommand() {
4477
+ const command = new Command13("tests");
4478
+ 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) => {
4479
+ validateOrgProjectPair(options);
4480
+ const runId = parsePositiveId(runIdRaw);
4481
+ if (runId === null) {
4482
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4483
+ return;
4484
+ }
4485
+ let context;
4486
+ try {
4487
+ const resolved = await resolvePipelineContext(options);
4488
+ context = resolved.context;
4489
+ const summary = await getTestSummary(resolved.context, resolved.cred, runId);
4490
+ const failedTests = summary.failed > 0 ? await getFailedTests(resolved.context, resolved.cred, runId) : [];
4491
+ if (options.json) {
4492
+ process.stdout.write(`${JSON.stringify({ ...summary, failedTests }, null, 2)}
4493
+ `);
4494
+ return;
4495
+ }
4496
+ if (!summary.present) {
4497
+ process.stdout.write(`No test results published for run ${runId}.
4498
+ `);
4499
+ return;
4500
+ }
4501
+ if (!options.failed) {
4502
+ process.stdout.write(`Run #${runId}: ${summary.failed} failing of ${summary.total} tests
4503
+ `);
4504
+ }
4505
+ if (failedTests.length > 0) {
4506
+ process.stdout.write(`${failedTests.map(failedTestRow).join("\n")}
4507
+ `);
4508
+ } else if (options.failed) {
4509
+ process.stdout.write("No failing tests.\n");
4510
+ }
4511
+ } catch (err) {
4512
+ handlePipelineError(err, context);
4513
+ }
4514
+ });
4515
+ return command;
4516
+ }
4517
+ function parseParameters(values) {
4518
+ const result = {};
4519
+ for (const entry of values ?? []) {
4520
+ const eq = entry.indexOf("=");
4521
+ if (eq <= 0) {
4522
+ return null;
4523
+ }
4524
+ const key = entry.slice(0, eq);
4525
+ const value = entry.slice(eq + 1);
4526
+ result[key] = value;
4527
+ }
4528
+ return result;
4529
+ }
4530
+ function collectParameter(value, previous) {
4531
+ return previous.concat([value]);
4532
+ }
4533
+ function createPipelineStartCommand() {
4534
+ const command = new Command13("start");
4535
+ 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) => {
4536
+ validateOrgProjectPair(options);
4537
+ const defId = parsePositiveId(defIdRaw);
4538
+ if (defId === null) {
4539
+ writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
4540
+ return;
4541
+ }
4542
+ const parameters = parseParameters(options.parameter);
4543
+ if (parameters === null) {
4544
+ writeError2("Invalid --parameter; expected key=value.");
4545
+ return;
4546
+ }
4547
+ let context;
4548
+ try {
4549
+ const resolved = await resolvePipelineContext(options);
4550
+ context = resolved.context;
4551
+ const result = await runPipeline(resolved.context, resolved.cred, defId, {
4552
+ branch: options.branch,
4553
+ parameters
4554
+ });
4555
+ if (options.json) {
4556
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4557
+ `);
4558
+ return;
4559
+ }
4560
+ process.stdout.write(`Queued run #${result.id} [${result.state}]
4561
+ ${result.webUrl ?? "\u2014"}
4562
+ `);
4563
+ } catch (err) {
4564
+ handlePipelineError(err, context);
4565
+ }
4566
+ });
4567
+ return command;
4568
+ }
4569
+ function createPipelineCommand() {
4570
+ const command = new Command13("pipeline");
4571
+ command.description("Manage Azure DevOps pipelines");
4572
+ command.addCommand(createPipelineListCommand());
4573
+ command.addCommand(createPipelineGetRunsCommand());
4574
+ command.addCommand(createPipelineWaitCommand());
4575
+ command.addCommand(createPipelineGetRunDetailCommand());
4576
+ command.addCommand(createPipelineLogsCommand());
4577
+ command.addCommand(createPipelineTestsCommand());
4578
+ command.addCommand(createPipelineStartCommand());
4579
+ return command;
4580
+ }
4581
+
4582
+ // src/commands/comments.ts
4583
+ import { Command as Command14 } from "commander";
4584
+ function writeError3(message) {
4585
+ process.stderr.write(`Error: ${message}
4586
+ `);
4587
+ process.exit(1);
4588
+ }
4589
+ function formatCommentHeader(comment) {
4590
+ const author = comment.author ?? "Unknown";
4591
+ const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
4592
+ return `Comment #${comment.id} by ${author} at ${timestamp}`;
4593
+ }
4594
+ function formatComments(result, convertMarkdown) {
4595
+ const lines = [`Comments for work item #${result.workItemId}`];
4596
+ for (const comment of result.comments) {
4597
+ const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
4598
+ lines.push("", formatCommentHeader(comment), text);
3052
4599
  }
3053
4600
  return lines.join("\n");
3054
4601
  }
3055
4602
  function createCommentsListCommand() {
3056
- const command = new Command13("list");
4603
+ const command = new Command14("list");
3057
4604
  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
4605
  validateOrgProjectPair(options);
3059
4606
  const id = parseWorkItemId(idStr);
@@ -3081,12 +4628,12 @@ function createCommentsListCommand() {
3081
4628
  return command;
3082
4629
  }
3083
4630
  function createCommentsAddCommand() {
3084
- const command = new Command13("add");
4631
+ const command = new Command14("add");
3085
4632
  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
4633
  validateOrgProjectPair(options);
3087
4634
  const id = parseWorkItemId(idStr);
3088
4635
  if (text.trim() === "") {
3089
- writeError2("Comment text must be a non-empty string.");
4636
+ writeError3("Comment text must be a non-empty string.");
3090
4637
  }
3091
4638
  let context;
3092
4639
  try {
@@ -3108,7 +4655,7 @@ function createCommentsAddCommand() {
3108
4655
  return command;
3109
4656
  }
3110
4657
  function createCommentsCommand() {
3111
- const command = new Command13("comments");
4658
+ const command = new Command14("comments");
3112
4659
  command.description("Manage Azure DevOps work item comments");
3113
4660
  command.addCommand(createCommentsListCommand());
3114
4661
  command.addCommand(createCommentsAddCommand());
@@ -3116,12 +4663,12 @@ function createCommentsCommand() {
3116
4663
  }
3117
4664
 
3118
4665
  // src/commands/download-attachment.ts
3119
- 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";
4666
+ import { Command as Command15 } from "commander";
4667
+ import { writeFile as writeFile2 } from "fs/promises";
4668
+ import { existsSync as existsSync5 } from "fs";
4669
+ import { join as join3 } from "path";
3123
4670
  function createDownloadAttachmentCommand() {
3124
- const command = new Command14("download-attachment");
4671
+ const command = new Command15("download-attachment");
3125
4672
  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
4673
  async (idStr, filename, options) => {
3127
4674
  const id = parseWorkItemId(idStr);
@@ -3131,7 +4678,7 @@ function createDownloadAttachmentCommand() {
3131
4678
  context = resolveContext(options);
3132
4679
  const credential = await requireAuthCredential(context.org);
3133
4680
  const outputDir = options.output ?? ".";
3134
- if (!existsSync4(outputDir)) {
4681
+ if (!existsSync5(outputDir)) {
3135
4682
  process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
3136
4683
  `);
3137
4684
  process.exit(1);
@@ -3148,8 +4695,8 @@ function createDownloadAttachmentCommand() {
3148
4695
  process.exit(1);
3149
4696
  }
3150
4697
  const data = await downloadAttachment(attachment.url, credential);
3151
- const outputPath = join2(outputDir, filename);
3152
- await writeFile(outputPath, Buffer.from(data));
4698
+ const outputPath = join3(outputDir, filename);
4699
+ await writeFile2(outputPath, Buffer.from(data));
3153
4700
  process.stdout.write(
3154
4701
  `Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
3155
4702
  `
@@ -3162,9 +4709,415 @@ function createDownloadAttachmentCommand() {
3162
4709
  return command;
3163
4710
  }
3164
4711
 
4712
+ // src/commands/relations.ts
4713
+ import { Command as Command16 } from "commander";
4714
+
4715
+ // src/services/relations-client.ts
4716
+ var API_VERSION2 = "7.1";
4717
+ function buildRelationTypesUrl(context) {
4718
+ const url = new URL(
4719
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workitemrelationtypes`
4720
+ );
4721
+ url.searchParams.set("api-version", API_VERSION2);
4722
+ return url;
4723
+ }
4724
+ function buildWorkItemUrl2(context, id, expand) {
4725
+ const url = new URL(
4726
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
4727
+ );
4728
+ url.searchParams.set("api-version", API_VERSION2);
4729
+ if (expand) url.searchParams.set("$expand", expand);
4730
+ return url;
4731
+ }
4732
+ function buildBatchWorkItemsUrl(context, ids) {
4733
+ const url = new URL(
4734
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems`
4735
+ );
4736
+ url.searchParams.set("ids", ids.join(","));
4737
+ url.searchParams.set("fields", "System.Id,System.Title");
4738
+ url.searchParams.set("api-version", API_VERSION2);
4739
+ return url;
4740
+ }
4741
+ function mapRelationType(raw) {
4742
+ return {
4743
+ referenceName: raw.referenceName,
4744
+ name: raw.name,
4745
+ usage: raw.attributes?.usage ?? "unknown",
4746
+ enabled: raw.attributes?.enabled !== false,
4747
+ directional: raw.attributes?.directional ?? null
4748
+ };
4749
+ }
4750
+ async function readJsonResponse3(response) {
4751
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
4752
+ return await response.json();
4753
+ }
4754
+ function parseTargetId(url) {
4755
+ const match = /\/workItems\/(\d+)$/i.exec(url);
4756
+ return match ? Number(match[1]) : null;
4757
+ }
4758
+ async function getWorkItemRelationTypes(context, cred) {
4759
+ const url = buildRelationTypesUrl(context);
4760
+ const response = await fetchWithErrors(url.toString(), {
4761
+ headers: authHeaders(cred)
4762
+ });
4763
+ const body = await readJsonResponse3(response);
4764
+ return (body.value ?? []).map(mapRelationType).filter((t) => t.usage === "workItemLink" && t.enabled);
4765
+ }
4766
+ async function resolveRelationType(context, cred, alias) {
4767
+ const types = await getWorkItemRelationTypes(context, cred);
4768
+ const lower = alias.toLowerCase();
4769
+ const match = types.find((t) => t.name.toLowerCase() === lower);
4770
+ if (!match) throw new Error(`UNKNOWN_RELATION_TYPE:${alias}`);
4771
+ return match;
4772
+ }
4773
+ async function getWorkItemWithRelations(context, cred, id) {
4774
+ const url = buildWorkItemUrl2(context, id, "relations");
4775
+ const response = await fetchWithErrors(url.toString(), {
4776
+ headers: authHeaders(cred)
4777
+ });
4778
+ return readJsonResponse3(response);
4779
+ }
4780
+ async function addWorkItemRelation(context, cred, type, id1, id2) {
4781
+ if (id1 === id2) throw new Error("SELF_RELATION");
4782
+ const relType = await resolveRelationType(context, cred, type);
4783
+ const workItem = await getWorkItemWithRelations(context, cred, id1);
4784
+ const targetUrl = `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workItems/${id2}`;
4785
+ const existing = (workItem.relations ?? []).find(
4786
+ (r) => r.rel === relType.referenceName && r.url.toLowerCase() === targetUrl.toLowerCase()
4787
+ );
4788
+ if (existing) {
4789
+ return { status: "already_exists", type: relType.name, referenceName: relType.referenceName, id1, id2 };
4790
+ }
4791
+ const patchUrl = buildWorkItemUrl2(context, id1);
4792
+ const response = await fetchWithErrors(patchUrl.toString(), {
4793
+ method: "PATCH",
4794
+ headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
4795
+ body: JSON.stringify([
4796
+ { op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
4797
+ ])
4798
+ });
4799
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
4800
+ return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
4801
+ }
4802
+ async function removeWorkItemRelation(context, cred, type, id1, id2) {
4803
+ if (id1 === id2) throw new Error("SELF_RELATION");
4804
+ const relType = await resolveRelationType(context, cred, type);
4805
+ const workItem = await getWorkItemWithRelations(context, cred, id1);
4806
+ const relations = workItem.relations ?? [];
4807
+ const index = relations.findIndex(
4808
+ (r) => r.rel === relType.referenceName && parseTargetId(r.url) === id2
4809
+ );
4810
+ if (index === -1) {
4811
+ return { status: "not_found", type: relType.name, referenceName: relType.referenceName, id1, id2 };
4812
+ }
4813
+ const patchUrl = buildWorkItemUrl2(context, id1);
4814
+ const response = await fetchWithErrors(patchUrl.toString(), {
4815
+ method: "PATCH",
4816
+ headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
4817
+ body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
4818
+ });
4819
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
4820
+ return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
4821
+ }
4822
+ async function listWorkItemRelations(context, cred, id) {
4823
+ const workItem = await getWorkItemWithRelations(context, cred, id);
4824
+ const allRelations = workItem.relations ?? [];
4825
+ const wiRelations = allRelations.filter(
4826
+ (r) => !r.rel.startsWith("AttachedFile") && !r.rel.startsWith("Hyperlink") && !r.rel.startsWith("ArtifactLink")
4827
+ );
4828
+ if (wiRelations.length === 0) {
4829
+ return { workItemId: id, relations: [] };
4830
+ }
4831
+ const types = await getWorkItemRelationTypes(context, cred);
4832
+ const typeNameMap = new Map(types.map((t) => [t.referenceName, t.name]));
4833
+ const targetIds = wiRelations.map((r) => parseTargetId(r.url)).filter((n) => n !== null);
4834
+ const titleMap = /* @__PURE__ */ new Map();
4835
+ if (targetIds.length > 0) {
4836
+ try {
4837
+ const batchUrl = buildBatchWorkItemsUrl(context, targetIds);
4838
+ const batchResponse = await fetchWithErrors(batchUrl.toString(), {
4839
+ headers: authHeaders(cred)
4840
+ });
4841
+ const batchBody = await readJsonResponse3(batchResponse);
4842
+ for (const item of batchBody.value ?? []) {
4843
+ titleMap.set(item.id, item.fields["System.Title"] ?? "");
4844
+ }
4845
+ } catch {
4846
+ }
4847
+ }
4848
+ const relations = wiRelations.map((r) => {
4849
+ const targetId = parseTargetId(r.url);
4850
+ return {
4851
+ rel: r.rel,
4852
+ relName: typeNameMap.get(r.rel) ?? r.rel,
4853
+ targetId: targetId ?? 0,
4854
+ targetTitle: targetId !== null ? titleMap.get(targetId) ?? null : null,
4855
+ targetUrl: r.url,
4856
+ comment: r.attributes?.comment ?? null
4857
+ };
4858
+ });
4859
+ return { workItemId: id, relations };
4860
+ }
4861
+
4862
+ // src/commands/relations.ts
4863
+ function addCommonOptions(cmd) {
4864
+ return cmd.option("--json", "Output as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
4865
+ }
4866
+ async function resolveCredentials(opts) {
4867
+ const context = resolveContext(opts);
4868
+ const cred = await requireAuthCredential(context.org);
4869
+ return { context, cred };
4870
+ }
4871
+ function formatRelationTypes(types) {
4872
+ if (types.length === 0) return "No work item relation types found.";
4873
+ const nameWidth = Math.max(...types.map((t) => t.name.length), 4);
4874
+ const lines = ["Available work item relation types:", ""];
4875
+ for (const t of types) {
4876
+ lines.push(`${t.name.padEnd(nameWidth + 2)}${t.referenceName}`);
4877
+ }
4878
+ return lines.join("\n");
4879
+ }
4880
+ function formatRelationsList(workItemId, relations) {
4881
+ if (relations.length === 0) return `Work item #${workItemId} has no relations.`;
4882
+ const typeWidth = Math.max(...relations.map((r) => r.relName.length), 4) + 2;
4883
+ const idWidth = Math.max(...relations.map((r) => String(r.targetId).length), 4) + 1;
4884
+ const lines = [`Relations for work item #${workItemId}:`, ""];
4885
+ for (const r of relations) {
4886
+ const typeLabel = `[${r.relName}]`.padEnd(typeWidth);
4887
+ const idLabel = `#${r.targetId}`.padEnd(idWidth);
4888
+ lines.push(`${typeLabel} ${idLabel} ${r.targetTitle ?? "(unknown)"}`);
4889
+ }
4890
+ return lines.join("\n");
4891
+ }
4892
+ function handleRelationError(err, id1) {
4893
+ const msg = err instanceof Error ? err.message : String(err);
4894
+ if (msg === "SELF_RELATION") {
4895
+ process.stderr.write(`Error: cannot relate a work item to itself (#${id1}).
4896
+ `);
4897
+ } else if (msg.startsWith("UNKNOWN_RELATION_TYPE:")) {
4898
+ const name = msg.replace("UNKNOWN_RELATION_TYPE:", "");
4899
+ process.stderr.write(
4900
+ `Error: unknown relation type "${name}". Run 'azdo relations types' to see valid names.
4901
+ `
4902
+ );
4903
+ } else if (msg.startsWith("NOT_FOUND")) {
4904
+ const target = id1 !== void 0 ? id1 : "unknown";
4905
+ process.stderr.write(`Error: work item #${target} not found.
4906
+ `);
4907
+ } else if (msg === "AUTH_FAILED") {
4908
+ process.stderr.write(
4909
+ `Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
4910
+ `
4911
+ );
4912
+ } else {
4913
+ process.stderr.write(`Error: ${msg}
4914
+ `);
4915
+ }
4916
+ process.exit(1);
4917
+ }
4918
+ function parsePositiveInt(value, label) {
4919
+ const n = Number(value);
4920
+ if (!Number.isInteger(n) || n <= 0) {
4921
+ process.stderr.write(`Error: ${label} must be a positive integer.
4922
+ `);
4923
+ process.exit(1);
4924
+ }
4925
+ return n;
4926
+ }
4927
+ function createRelationsCommand() {
4928
+ const relations = new Command16("relations").description("Manage work item relations");
4929
+ addCommonOptions(
4930
+ relations.command("types").description("List all available work item relation types")
4931
+ ).action(async (opts) => {
4932
+ try {
4933
+ const { context, cred } = await resolveCredentials(opts);
4934
+ const types = await getWorkItemRelationTypes(context, cred);
4935
+ if (opts.json) {
4936
+ process.stdout.write(JSON.stringify(types, null, 2) + "\n");
4937
+ } else {
4938
+ process.stdout.write(formatRelationTypes(types) + "\n");
4939
+ }
4940
+ } catch (err) {
4941
+ handleRelationError(err);
4942
+ }
4943
+ });
4944
+ addCommonOptions(
4945
+ relations.command("add <type> <id1> <id2>").description("Add a directed relation from work item <id1> to <id2>")
4946
+ ).action(async (type, id1Str, id2Str, opts) => {
4947
+ const id1 = parsePositiveInt(id1Str, "id1");
4948
+ const id2 = parsePositiveInt(id2Str, "id2");
4949
+ try {
4950
+ const { context, cred } = await resolveCredentials(opts);
4951
+ const result = await addWorkItemRelation(context, cred, type, id1, id2);
4952
+ if (opts.json) {
4953
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
4954
+ } else if (result.status === "already_exists") {
4955
+ process.stdout.write(`Relation already exists: #${id1} --[${result.type}]--> #${id2}
4956
+ `);
4957
+ } else {
4958
+ process.stdout.write(`Added relation: #${id1} --[${result.type}]--> #${id2}
4959
+ `);
4960
+ }
4961
+ } catch (err) {
4962
+ handleRelationError(err, id1);
4963
+ }
4964
+ });
4965
+ addCommonOptions(
4966
+ relations.command("remove <type> <id1> <id2>").description("Remove a directed relation of <type> from work item <id1> to <id2>")
4967
+ ).action(async (type, id1Str, id2Str, opts) => {
4968
+ const id1 = parsePositiveInt(id1Str, "id1");
4969
+ const id2 = parsePositiveInt(id2Str, "id2");
4970
+ try {
4971
+ const { context, cred } = await resolveCredentials(opts);
4972
+ const result = await removeWorkItemRelation(context, cred, type, id1, id2);
4973
+ if (opts.json) {
4974
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
4975
+ } else if (result.status === "not_found") {
4976
+ process.stdout.write(`No relation of type '${result.type}' found between #${id1} and #${id2}
4977
+ `);
4978
+ } else {
4979
+ process.stdout.write(`Removed relation: #${id1} --[${result.type}]--> #${id2}
4980
+ `);
4981
+ }
4982
+ } catch (err) {
4983
+ handleRelationError(err, id1);
4984
+ }
4985
+ });
4986
+ addCommonOptions(
4987
+ relations.command("list <id>").description("List all work item link relations on a work item")
4988
+ ).action(async (idStr, opts) => {
4989
+ const id = parsePositiveInt(idStr, "id");
4990
+ try {
4991
+ const { context, cred } = await resolveCredentials(opts);
4992
+ const result = await listWorkItemRelations(context, cred, id);
4993
+ if (opts.json) {
4994
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
4995
+ } else {
4996
+ process.stdout.write(formatRelationsList(result.workItemId, result.relations) + "\n");
4997
+ }
4998
+ } catch (err) {
4999
+ handleRelationError(err, id);
5000
+ }
5001
+ });
5002
+ return relations;
5003
+ }
5004
+
5005
+ // src/services/update-check.ts
5006
+ import fs2 from "fs";
5007
+ import path2 from "path";
5008
+ import os from "os";
5009
+ var THROTTLE_MS = 10 * 60 * 1e3;
5010
+ var FETCH_TIMEOUT_MS = 1500;
5011
+ var REGISTRY_URL = "https://registry.npmjs.org/azdo-cli/latest";
5012
+ function getCachePath() {
5013
+ return path2.join(os.homedir(), ".azdo", "update-check.json");
5014
+ }
5015
+ function defaultReadCache() {
5016
+ try {
5017
+ return fs2.readFileSync(getCachePath(), "utf-8");
5018
+ } catch {
5019
+ return null;
5020
+ }
5021
+ }
5022
+ function defaultWriteCache(data) {
5023
+ try {
5024
+ const cachePath = getCachePath();
5025
+ fs2.mkdirSync(path2.dirname(cachePath), { recursive: true });
5026
+ fs2.writeFileSync(cachePath, data);
5027
+ } catch {
5028
+ }
5029
+ }
5030
+ function parseCache(raw) {
5031
+ if (!raw) return null;
5032
+ let parsed;
5033
+ try {
5034
+ parsed = JSON.parse(raw);
5035
+ } catch {
5036
+ return null;
5037
+ }
5038
+ if (typeof parsed !== "object" || parsed === null) return null;
5039
+ const { lastCheck, latestVersion } = parsed;
5040
+ if (typeof lastCheck !== "number" || !Number.isFinite(lastCheck)) return null;
5041
+ if (typeof latestVersion !== "string" || latestVersion.length === 0) return null;
5042
+ return { lastCheck, latestVersion };
5043
+ }
5044
+ async function defaultFetchLatest() {
5045
+ const controller = new AbortController();
5046
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
5047
+ try {
5048
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
5049
+ if (!res.ok) return null;
5050
+ const body = await res.json();
5051
+ if (typeof body !== "object" || body === null) return null;
5052
+ const v = body.version;
5053
+ return typeof v === "string" && v.length > 0 ? v : null;
5054
+ } catch {
5055
+ return null;
5056
+ } finally {
5057
+ clearTimeout(timer);
5058
+ }
5059
+ }
5060
+ function parseVersion(v) {
5061
+ if (typeof v !== "string") return null;
5062
+ const trimmed = v.trim().replace(/^v/, "");
5063
+ const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(trimmed);
5064
+ if (!match) return null;
5065
+ const release = [Number(match[1]), Number(match[2]), Number(match[3])];
5066
+ if (!release.every(Number.isFinite)) return null;
5067
+ return { release, prerelease: match[4] !== void 0 };
5068
+ }
5069
+ function isNewer(latest, current) {
5070
+ const a = parseVersion(latest);
5071
+ const b = parseVersion(current);
5072
+ if (!a || !b) return false;
5073
+ for (let i = 0; i < 3; i++) {
5074
+ if (a.release[i] > b.release[i]) return true;
5075
+ if (a.release[i] < b.release[i]) return false;
5076
+ }
5077
+ if (a.prerelease && !b.prerelease) return false;
5078
+ if (!a.prerelease && b.prerelease) return true;
5079
+ return false;
5080
+ }
5081
+ async function getUpdateNotice(opts) {
5082
+ try {
5083
+ const {
5084
+ enabled = true,
5085
+ now = Date.now,
5086
+ readCache = defaultReadCache,
5087
+ writeCache = defaultWriteCache,
5088
+ fetchLatest = defaultFetchLatest,
5089
+ isTTY = () => Boolean(process.stderr.isTTY),
5090
+ currentVersion = version
5091
+ } = opts ?? {};
5092
+ if (enabled === false) return null;
5093
+ if (!isTTY()) return null;
5094
+ const cache = parseCache(readCache());
5095
+ const lastCheck = cache?.lastCheck ?? 0;
5096
+ if (now() - lastCheck < THROTTLE_MS) return null;
5097
+ const latest = await fetchLatest();
5098
+ if (!latest) return null;
5099
+ writeCache(JSON.stringify({ lastCheck: now(), latestVersion: latest }));
5100
+ if (isNewer(latest, currentVersion)) {
5101
+ return `A new version of azdo-cli is available: ${currentVersion} \u2192 ${latest}. Run \`npm i -g azdo-cli\` to update.`;
5102
+ }
5103
+ return null;
5104
+ } catch {
5105
+ return null;
5106
+ }
5107
+ }
5108
+
3165
5109
  // src/index.ts
3166
- var program = new Command15();
5110
+ function exitOnEpipe(err) {
5111
+ if (err.code === "EPIPE") {
5112
+ process.exit(0);
5113
+ }
5114
+ throw err;
5115
+ }
5116
+ process.stdout.on("error", exitOnEpipe);
5117
+ process.stderr.on("error", exitOnEpipe);
5118
+ var program = new Command17();
3167
5119
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
5120
+ program.option("--no-update-check", "Skip the check for a newer published version");
3168
5121
  program.addCommand(createGetItemCommand());
3169
5122
  program.addCommand(createAuthCommand());
3170
5123
  program.addCommand(createClearPatCommand());
@@ -3177,10 +5130,21 @@ program.addCommand(createSetMdFieldCommand());
3177
5130
  program.addCommand(createUpsertCommand());
3178
5131
  program.addCommand(createListFieldsCommand());
3179
5132
  program.addCommand(createPrCommand());
5133
+ program.addCommand(createPipelineCommand());
3180
5134
  program.addCommand(createCommentsCommand());
3181
5135
  program.addCommand(createDownloadAttachmentCommand());
5136
+ program.addCommand(createRelationsCommand());
3182
5137
  program.showHelpAfterError();
3183
- program.parse();
3184
- if (process.argv.length <= 2) {
3185
- program.help();
5138
+ program.hook("postAction", async () => {
5139
+ const notice = await getUpdateNotice({ enabled: program.opts().updateCheck });
5140
+ if (notice) {
5141
+ process.stderr.write(notice + "\n");
5142
+ }
5143
+ });
5144
+ async function main() {
5145
+ await program.parseAsync();
5146
+ if (process.argv.length <= 2) {
5147
+ program.help();
5148
+ }
3186
5149
  }
5150
+ void main();