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