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
|
-
|
|
36
|
+
unsetConfigValue,
|
|
37
|
+
unsetOrgScopedValue
|
|
38
|
+
} from "./chunk-XVXMDWQE.js";
|
|
32
39
|
|
|
33
40
|
// src/index.ts
|
|
34
|
-
import { Command as
|
|
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
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 (
|
|
259
|
-
|
|
275
|
+
if (fields["Microsoft.VSTS.TCM.ReproSteps"]) {
|
|
276
|
+
parts.push({ label: "Repro Steps", value: fields["Microsoft.VSTS.TCM.ReproSteps"] });
|
|
260
277
|
}
|
|
261
|
-
if (
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
798
|
-
function
|
|
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
|
-
|
|
803
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
|
967
|
+
return lines.join("\n");
|
|
813
968
|
}
|
|
814
969
|
function detectAzdoContext() {
|
|
815
|
-
let
|
|
970
|
+
let configContent;
|
|
816
971
|
try {
|
|
817
|
-
|
|
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
|
|
822
|
-
|
|
823
|
-
|
|
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
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
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")
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
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 (!
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2757
|
-
|
|
2758
|
-
|
|
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 =
|
|
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 (
|
|
2892
|
-
|
|
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/
|
|
3670
|
+
// src/commands/pipeline.ts
|
|
3036
3671
|
import { Command as Command13 } from "commander";
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
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
|
|
3043
|
-
|
|
3044
|
-
|
|
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
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
3120
|
-
import { writeFile } from "fs/promises";
|
|
3121
|
-
import { existsSync as
|
|
3122
|
-
import { join as
|
|
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
|
|
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 (!
|
|
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 =
|
|
3152
|
-
await
|
|
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
|
-
|
|
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.
|
|
3184
|
-
|
|
3185
|
-
|
|
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();
|