azdo-cli 0.5.0-feature-fixer-and-gitflow-skills.261 → 0.5.0-feature-tech-stack-030-auth-diagnostics.546
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/README.md +36 -4
- package/dist/chunk-XVXMDWQE.js +1218 -0
- package/dist/index.js +3036 -638
- package/dist/oauth-token-refresh-7FQ4VAIS.js +15 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,7 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
AZDO_RESOURCE_ID,
|
|
4
|
+
CredentialMissingError,
|
|
5
|
+
CredentialStoreUnavailableError,
|
|
6
|
+
SETTINGS,
|
|
7
|
+
appendAuthAuditEvent,
|
|
8
|
+
buildScopeString,
|
|
9
|
+
copyOrgScope,
|
|
10
|
+
defaultScopes,
|
|
11
|
+
deleteOrgScope,
|
|
12
|
+
deletePat,
|
|
13
|
+
firstPartyShippedScopes,
|
|
14
|
+
getConfigValue,
|
|
15
|
+
getOrgScopedValue,
|
|
16
|
+
getPat,
|
|
17
|
+
getStoredCredential,
|
|
18
|
+
listOrgsWithStoredPat,
|
|
19
|
+
loadConfig,
|
|
20
|
+
maskedDisplay,
|
|
21
|
+
moveOrgScope,
|
|
22
|
+
normalizePat,
|
|
23
|
+
openUrl,
|
|
24
|
+
probeBackend,
|
|
25
|
+
readAuditEvents,
|
|
26
|
+
readTokenResponse,
|
|
27
|
+
refreshIfNeeded,
|
|
28
|
+
resolveOAuthConfig,
|
|
29
|
+
resolveScopedConfig,
|
|
30
|
+
runAuthCodeFlow,
|
|
31
|
+
setConfigValue,
|
|
32
|
+
setOrgScopedValue,
|
|
33
|
+
storeOAuthCredential,
|
|
34
|
+
storePat,
|
|
35
|
+
tokenResponseToCredential,
|
|
36
|
+
unsetConfigValue,
|
|
37
|
+
unsetOrgScopedValue
|
|
38
|
+
} from "./chunk-XVXMDWQE.js";
|
|
2
39
|
|
|
3
40
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
41
|
+
import { Command as Command17 } from "commander";
|
|
5
42
|
|
|
6
43
|
// src/version.ts
|
|
7
44
|
import { readFileSync } from "fs";
|
|
@@ -14,6 +51,78 @@ var version = pkg.version;
|
|
|
14
51
|
// src/commands/get-item.ts
|
|
15
52
|
import { Command } from "commander";
|
|
16
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
|
+
|
|
17
126
|
// src/services/azdo-client.ts
|
|
18
127
|
var DEFAULT_FIELDS = [
|
|
19
128
|
"System.Title",
|
|
@@ -26,16 +135,59 @@ var DEFAULT_FIELDS = [
|
|
|
26
135
|
"System.AreaPath",
|
|
27
136
|
"System.IterationPath"
|
|
28
137
|
];
|
|
29
|
-
function authHeaders(
|
|
30
|
-
|
|
138
|
+
function authHeaders(credentialOrPat) {
|
|
139
|
+
if (typeof credentialOrPat === "string") {
|
|
140
|
+
const token2 = Buffer.from(`:${credentialOrPat}`).toString("base64");
|
|
141
|
+
return { Authorization: `Basic ${token2}` };
|
|
142
|
+
}
|
|
143
|
+
if (credentialOrPat.kind === "oauth") {
|
|
144
|
+
return { Authorization: `Bearer ${credentialOrPat.pat}` };
|
|
145
|
+
}
|
|
146
|
+
const token = Buffer.from(`:${credentialOrPat.pat}`).toString("base64");
|
|
31
147
|
return { Authorization: `Basic ${token}` };
|
|
32
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
|
+
}
|
|
33
159
|
async function fetchWithErrors(url, init) {
|
|
160
|
+
const writer = getActiveTraceWriter();
|
|
34
161
|
let response;
|
|
35
162
|
try {
|
|
36
163
|
response = await fetch(url, init);
|
|
37
|
-
} catch {
|
|
38
|
-
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);
|
|
39
191
|
}
|
|
40
192
|
if (response.status === 401) throw new Error("AUTH_FAILED");
|
|
41
193
|
if (response.status === 403) throw new Error("PERMISSION_DENIED");
|
|
@@ -48,6 +200,10 @@ async function fetchWithErrors(url, init) {
|
|
|
48
200
|
}
|
|
49
201
|
throw new Error(`NOT_FOUND${detail}`);
|
|
50
202
|
}
|
|
203
|
+
const contentType = response.headers?.get("content-type") ?? "";
|
|
204
|
+
if (contentType.toLowerCase().startsWith("text/html")) {
|
|
205
|
+
throw new Error("AUTH_FAILED");
|
|
206
|
+
}
|
|
51
207
|
return response;
|
|
52
208
|
}
|
|
53
209
|
async function readResponseMessage(response) {
|
|
@@ -90,9 +246,9 @@ function buildExtraFields(fields, requested) {
|
|
|
90
246
|
}
|
|
91
247
|
return Object.keys(result).length > 0 ? result : null;
|
|
92
248
|
}
|
|
93
|
-
function writeHeaders(
|
|
249
|
+
function writeHeaders(cred) {
|
|
94
250
|
return {
|
|
95
|
-
...authHeaders(
|
|
251
|
+
...authHeaders(cred),
|
|
96
252
|
"Content-Type": "application/json-patch+json"
|
|
97
253
|
};
|
|
98
254
|
}
|
|
@@ -147,13 +303,13 @@ async function readWriteResponse(response, errorCode) {
|
|
|
147
303
|
fields: data.fields
|
|
148
304
|
};
|
|
149
305
|
}
|
|
150
|
-
async function getWorkItemFields(context, id,
|
|
306
|
+
async function getWorkItemFields(context, id, cred) {
|
|
151
307
|
const url = new URL(
|
|
152
308
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
153
309
|
);
|
|
154
310
|
url.searchParams.set("api-version", "7.1");
|
|
155
311
|
url.searchParams.set("$expand", "all");
|
|
156
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
312
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
157
313
|
if (response.status === 400) {
|
|
158
314
|
const serverMessage = await readResponseMessage(response);
|
|
159
315
|
if (serverMessage) {
|
|
@@ -188,10 +344,10 @@ function buildWorkItemUrl(context, id, options = {}) {
|
|
|
188
344
|
}
|
|
189
345
|
return url;
|
|
190
346
|
}
|
|
191
|
-
async function fetchWorkItemResponse(context, id,
|
|
347
|
+
async function fetchWorkItemResponse(context, id, cred, options = {}) {
|
|
192
348
|
const response = await fetchWithErrors(
|
|
193
349
|
buildWorkItemUrl(context, id, options).toString(),
|
|
194
|
-
{ headers: authHeaders(
|
|
350
|
+
{ headers: authHeaders(cred) }
|
|
195
351
|
);
|
|
196
352
|
if (response.status === 400) {
|
|
197
353
|
const serverMessage = await readResponseMessage(response);
|
|
@@ -204,28 +360,66 @@ async function fetchWorkItemResponse(context, id, pat, options = {}) {
|
|
|
204
360
|
}
|
|
205
361
|
return await response.json();
|
|
206
362
|
}
|
|
207
|
-
async function
|
|
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"] });
|
|
379
|
+
}
|
|
380
|
+
if (fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
|
|
381
|
+
parts.push({ label: "Acceptance Criteria", value: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
|
|
382
|
+
}
|
|
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 };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async function getWorkItem(context, id, cred, extraFields) {
|
|
208
414
|
const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
|
|
216
|
-
}
|
|
217
|
-
if (data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
|
|
218
|
-
descriptionParts.push({ label: "Acceptance Criteria", value: data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
|
|
219
|
-
}
|
|
220
|
-
if (data.fields["Microsoft.VSTS.TCM.ReproSteps"]) {
|
|
221
|
-
descriptionParts.push({ label: "Repro Steps", value: data.fields["Microsoft.VSTS.TCM.ReproSteps"] });
|
|
222
|
-
}
|
|
223
|
-
let combinedDescription = null;
|
|
224
|
-
if (descriptionParts.length === 1) {
|
|
225
|
-
combinedDescription = descriptionParts.at(0)?.value ?? null;
|
|
226
|
-
} else if (descriptionParts.length > 1) {
|
|
227
|
-
combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
|
|
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 });
|
|
228
421
|
}
|
|
422
|
+
const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
|
|
229
423
|
return {
|
|
230
424
|
id: data.id,
|
|
231
425
|
rev: data.rev,
|
|
@@ -233,21 +427,21 @@ async function getWorkItem(context, id, pat, extraFields) {
|
|
|
233
427
|
state: data.fields["System.State"],
|
|
234
428
|
type: data.fields["System.WorkItemType"],
|
|
235
429
|
assignedTo: data.fields["System.AssignedTo"]?.displayName ?? null,
|
|
236
|
-
description:
|
|
430
|
+
description: buildCombinedDescription(data.fields),
|
|
237
431
|
areaPath: data.fields["System.AreaPath"],
|
|
238
432
|
iterationPath: data.fields["System.IterationPath"],
|
|
239
433
|
url: data._links.html.href,
|
|
240
|
-
extraFields:
|
|
434
|
+
extraFields: effectiveExtraFields.length > 0 ? buildExtraFields(data.fields, effectiveExtraFields) : null,
|
|
241
435
|
attachments: extractAttachments(relationsData.relations)
|
|
242
436
|
};
|
|
243
437
|
}
|
|
244
|
-
async function getWorkItemFieldValue(context, id,
|
|
438
|
+
async function getWorkItemFieldValue(context, id, cred, fieldName) {
|
|
245
439
|
const url = new URL(
|
|
246
440
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
247
441
|
);
|
|
248
442
|
url.searchParams.set("api-version", "7.1");
|
|
249
443
|
url.searchParams.set("fields", fieldName);
|
|
250
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
444
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
251
445
|
if (response.status === 400) {
|
|
252
446
|
const serverMessage = await readResponseMessage(response);
|
|
253
447
|
if (serverMessage) {
|
|
@@ -264,13 +458,13 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
|
|
|
264
458
|
}
|
|
265
459
|
return stringifyFieldValue(value);
|
|
266
460
|
}
|
|
267
|
-
async function listWorkItemComments(context, id,
|
|
461
|
+
async function listWorkItemComments(context, id, cred) {
|
|
268
462
|
const comments = [];
|
|
269
463
|
let continuationToken = null;
|
|
270
464
|
do {
|
|
271
465
|
const response = await fetchWithErrors(
|
|
272
466
|
buildWorkItemCommentsListUrl(context, id, continuationToken ?? void 0).toString(),
|
|
273
|
-
{ headers: authHeaders(
|
|
467
|
+
{ headers: authHeaders(cred) }
|
|
274
468
|
);
|
|
275
469
|
if (!response.ok) {
|
|
276
470
|
throw new Error(`HTTP_${response.status}`);
|
|
@@ -287,13 +481,13 @@ async function listWorkItemComments(context, id, pat) {
|
|
|
287
481
|
comments
|
|
288
482
|
};
|
|
289
483
|
}
|
|
290
|
-
async function addWorkItemComment(context, id,
|
|
484
|
+
async function addWorkItemComment(context, id, cred, text, format = "html") {
|
|
291
485
|
const url = buildWorkItemCommentsUrl(context, id);
|
|
292
486
|
url.searchParams.set("format", format);
|
|
293
487
|
const response = await fetchWithErrors(url.toString(), {
|
|
294
488
|
method: "POST",
|
|
295
489
|
headers: {
|
|
296
|
-
...authHeaders(
|
|
490
|
+
...authHeaders(cred),
|
|
297
491
|
"Content-Type": "application/json"
|
|
298
492
|
},
|
|
299
493
|
body: JSON.stringify({ text })
|
|
@@ -315,8 +509,8 @@ async function addWorkItemComment(context, id, pat, text, format = "html") {
|
|
|
315
509
|
url: data.url ?? null
|
|
316
510
|
};
|
|
317
511
|
}
|
|
318
|
-
async function updateWorkItem(context, id,
|
|
319
|
-
const result = await applyWorkItemPatch(context, id,
|
|
512
|
+
async function updateWorkItem(context, id, cred, fieldName, operations) {
|
|
513
|
+
const result = await applyWorkItemPatch(context, id, cred, operations);
|
|
320
514
|
const title = result.fields["System.Title"];
|
|
321
515
|
const lastOp = operations.at(-1);
|
|
322
516
|
const fieldValue = lastOp?.value ?? null;
|
|
@@ -328,32 +522,32 @@ async function updateWorkItem(context, id, pat, fieldName, operations) {
|
|
|
328
522
|
fieldValue
|
|
329
523
|
};
|
|
330
524
|
}
|
|
331
|
-
async function createWorkItem(context, workItemType,
|
|
525
|
+
async function createWorkItem(context, workItemType, cred, operations) {
|
|
332
526
|
const url = new URL(
|
|
333
527
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
|
|
334
528
|
);
|
|
335
529
|
url.searchParams.set("api-version", "7.1");
|
|
336
530
|
const response = await fetchWithErrors(url.toString(), {
|
|
337
531
|
method: "POST",
|
|
338
|
-
headers: writeHeaders(
|
|
532
|
+
headers: writeHeaders(cred),
|
|
339
533
|
body: JSON.stringify(operations)
|
|
340
534
|
});
|
|
341
535
|
return readWriteResponse(response, "CREATE_REJECTED");
|
|
342
536
|
}
|
|
343
|
-
async function applyWorkItemPatch(context, id,
|
|
537
|
+
async function applyWorkItemPatch(context, id, cred, operations) {
|
|
344
538
|
const url = new URL(
|
|
345
539
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
346
540
|
);
|
|
347
541
|
url.searchParams.set("api-version", "7.1");
|
|
348
542
|
const response = await fetchWithErrors(url.toString(), {
|
|
349
543
|
method: "PATCH",
|
|
350
|
-
headers: writeHeaders(
|
|
544
|
+
headers: writeHeaders(cred),
|
|
351
545
|
body: JSON.stringify(operations)
|
|
352
546
|
});
|
|
353
547
|
return readWriteResponse(response, "UPDATE_REJECTED");
|
|
354
548
|
}
|
|
355
|
-
async function downloadAttachment(url,
|
|
356
|
-
const response = await fetchWithErrors(url, { headers: authHeaders(
|
|
549
|
+
async function downloadAttachment(url, cred) {
|
|
550
|
+
const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
|
|
357
551
|
if (!response.ok) {
|
|
358
552
|
throw new Error(`HTTP_${response.status}`);
|
|
359
553
|
}
|
|
@@ -365,323 +559,124 @@ import { createInterface } from "readline";
|
|
|
365
559
|
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
366
560
|
import { dirname as dirname2, join } from "path";
|
|
367
561
|
|
|
368
|
-
// src/services/
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
super(`OS secret backend unavailable (${backend}). Install the platform's credential service and try again.`);
|
|
376
|
-
this.name = "CredentialStoreUnavailableError";
|
|
377
|
-
this.backend = backend;
|
|
562
|
+
// src/services/oauth-device-code.ts
|
|
563
|
+
var DeviceCodeFlowError = class extends Error {
|
|
564
|
+
reason;
|
|
565
|
+
constructor(reason, message, cause) {
|
|
566
|
+
super(message);
|
|
567
|
+
this.name = "DeviceCodeFlowError";
|
|
568
|
+
this.reason = reason;
|
|
378
569
|
if (cause instanceof Error) {
|
|
379
570
|
this.cause = cause;
|
|
380
571
|
}
|
|
381
572
|
}
|
|
382
573
|
};
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
574
|
+
var MIN_INTERVAL_SEC = 5;
|
|
575
|
+
async function defaultSleep(ms) {
|
|
576
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
577
|
+
}
|
|
578
|
+
async function requestDeviceCode(oauthConfig, fetchFn) {
|
|
579
|
+
const body = new URLSearchParams({
|
|
580
|
+
client_id: oauthConfig.clientId,
|
|
581
|
+
scope: buildScopeString(oauthConfig.scopes)
|
|
582
|
+
});
|
|
583
|
+
const response = await fetchFn(oauthConfig.deviceCodeEndpoint, {
|
|
584
|
+
method: "POST",
|
|
585
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
586
|
+
body: body.toString()
|
|
587
|
+
});
|
|
588
|
+
const text = await response.text();
|
|
589
|
+
let parsed;
|
|
396
590
|
try {
|
|
397
|
-
|
|
591
|
+
parsed = JSON.parse(text);
|
|
398
592
|
} catch {
|
|
593
|
+
throw new DeviceCodeFlowError("idp-error", `device-code endpoint returned non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
399
594
|
}
|
|
595
|
+
if (!response.ok) {
|
|
596
|
+
const err = parsed;
|
|
597
|
+
const code = err.error ?? "unknown";
|
|
598
|
+
const desc = err.error_description ? `: ${err.error_description}` : "";
|
|
599
|
+
throw new DeviceCodeFlowError(
|
|
600
|
+
"idp-error",
|
|
601
|
+
`device-code endpoint rejected request (${response.status}): ${code}${desc}`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
return parsed;
|
|
400
605
|
}
|
|
401
|
-
function
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
return;
|
|
606
|
+
async function classifyDeviceTokenResponse(response) {
|
|
607
|
+
if (response.ok) {
|
|
608
|
+
return { kind: "success", token: await readTokenResponse(response) };
|
|
405
609
|
}
|
|
610
|
+
const text = await response.text();
|
|
611
|
+
let parsed;
|
|
406
612
|
try {
|
|
407
|
-
|
|
613
|
+
parsed = JSON.parse(text);
|
|
408
614
|
} catch {
|
|
615
|
+
throw new DeviceCodeFlowError("idp-error", `non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
409
616
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
ensureFileWithPerms(auditLog);
|
|
416
|
-
const record = {
|
|
417
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
418
|
-
event: input.event,
|
|
419
|
-
org: input.org,
|
|
420
|
-
backend: input.backend,
|
|
421
|
-
...input.masked_pat !== void 0 ? { masked_pat: input.masked_pat } : {}
|
|
422
|
-
};
|
|
423
|
-
fs.appendFileSync(auditLog, `${JSON.stringify(record)}
|
|
424
|
-
`);
|
|
425
|
-
}
|
|
426
|
-
function readAuditEvents() {
|
|
427
|
-
const auditLog = getAuditLogPath();
|
|
428
|
-
if (!fs.existsSync(auditLog)) {
|
|
429
|
-
return [];
|
|
430
|
-
}
|
|
431
|
-
const contents = fs.readFileSync(auditLog, "utf8");
|
|
432
|
-
const out = [];
|
|
433
|
-
for (const line of contents.split("\n")) {
|
|
434
|
-
const trimmed = line.trim();
|
|
435
|
-
if (!trimmed) continue;
|
|
436
|
-
try {
|
|
437
|
-
const parsed = JSON.parse(trimmed);
|
|
438
|
-
if (parsed && typeof parsed === "object" && typeof parsed.event === "string") {
|
|
439
|
-
out.push(parsed);
|
|
440
|
-
}
|
|
441
|
-
} catch {
|
|
442
|
-
}
|
|
617
|
+
const errCode = parsed.error ?? "";
|
|
618
|
+
if (errCode === "authorization_pending") return { kind: "pending" };
|
|
619
|
+
if (errCode === "slow_down") return { kind: "slow_down" };
|
|
620
|
+
if (errCode === "expired_token") {
|
|
621
|
+
throw new DeviceCodeFlowError("expired_token", "device code expired before authorisation completed");
|
|
443
622
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
// src/services/config-store.ts
|
|
448
|
-
import fs2 from "fs";
|
|
449
|
-
import path2 from "path";
|
|
450
|
-
import os2 from "os";
|
|
451
|
-
var SETTINGS = [
|
|
452
|
-
{
|
|
453
|
-
key: "org",
|
|
454
|
-
description: "Azure DevOps organization name",
|
|
455
|
-
type: "string",
|
|
456
|
-
example: "mycompany",
|
|
457
|
-
required: true
|
|
458
|
-
},
|
|
459
|
-
{
|
|
460
|
-
key: "project",
|
|
461
|
-
description: "Azure DevOps project name",
|
|
462
|
-
type: "string",
|
|
463
|
-
example: "MyProject",
|
|
464
|
-
required: true
|
|
465
|
-
},
|
|
466
|
-
{
|
|
467
|
-
key: "fields",
|
|
468
|
-
description: "Extra work item fields to include (comma-separated reference names)",
|
|
469
|
-
type: "string[]",
|
|
470
|
-
example: "System.Tags,Custom.Priority",
|
|
471
|
-
required: false
|
|
472
|
-
},
|
|
473
|
-
{
|
|
474
|
-
key: "markdown",
|
|
475
|
-
description: "Convert rich text fields to markdown on display",
|
|
476
|
-
type: "boolean",
|
|
477
|
-
example: "true",
|
|
478
|
-
required: false
|
|
623
|
+
if (errCode === "access_denied") {
|
|
624
|
+
throw new DeviceCodeFlowError("access_denied", "authorisation denied by user");
|
|
479
625
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
626
|
+
const desc = parsed.error_description ? `: ${parsed.error_description}` : "";
|
|
627
|
+
throw new DeviceCodeFlowError(
|
|
628
|
+
"idp-error",
|
|
629
|
+
`IdP rejected device-token poll (${response.status}): ${errCode}${desc}`
|
|
630
|
+
);
|
|
484
631
|
}
|
|
485
|
-
function
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (
|
|
492
|
-
|
|
632
|
+
async function pollForDeviceToken(deviceCode, oauthConfig, initialIntervalSec, expiresAtMs, deps) {
|
|
633
|
+
const fetchFn = deps.fetch ?? fetch;
|
|
634
|
+
const now = deps.now ?? (() => Date.now());
|
|
635
|
+
const sleep2 = deps.sleep ?? defaultSleep;
|
|
636
|
+
let intervalSec = Math.max(MIN_INTERVAL_SEC, initialIntervalSec);
|
|
637
|
+
for (; ; ) {
|
|
638
|
+
if (now() >= expiresAtMs) {
|
|
639
|
+
throw new DeviceCodeFlowError("expired_token", "device-code flow expired before authorisation completed");
|
|
493
640
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
510
|
-
function validateKey(key) {
|
|
511
|
-
if (!VALID_KEYS.includes(key)) {
|
|
512
|
-
throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
function getConfigValue(key) {
|
|
516
|
-
validateKey(key);
|
|
517
|
-
const config = loadConfig();
|
|
518
|
-
return config[key];
|
|
519
|
-
}
|
|
520
|
-
function setConfigValue(key, value) {
|
|
521
|
-
validateKey(key);
|
|
522
|
-
const config = loadConfig();
|
|
523
|
-
if (value === "") {
|
|
524
|
-
delete config[key];
|
|
525
|
-
} else if (key === "markdown") {
|
|
526
|
-
if (value !== "true" && value !== "false") {
|
|
527
|
-
throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
|
|
641
|
+
await sleep2(intervalSec * 1e3);
|
|
642
|
+
const body = new URLSearchParams({
|
|
643
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
644
|
+
client_id: oauthConfig.clientId,
|
|
645
|
+
device_code: deviceCode
|
|
646
|
+
});
|
|
647
|
+
const response = await fetchFn(oauthConfig.tokenEndpoint, {
|
|
648
|
+
method: "POST",
|
|
649
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
650
|
+
body: body.toString()
|
|
651
|
+
});
|
|
652
|
+
const outcome = await classifyDeviceTokenResponse(response);
|
|
653
|
+
if (outcome.kind === "success") return outcome.token;
|
|
654
|
+
if (outcome.kind === "slow_down") {
|
|
655
|
+
intervalSec += 5;
|
|
528
656
|
}
|
|
529
|
-
config.markdown = value === "true";
|
|
530
|
-
} else if (key === "fields") {
|
|
531
|
-
config.fields = value.split(",").map((s) => s.trim());
|
|
532
|
-
} else {
|
|
533
|
-
config[key] = value;
|
|
534
657
|
}
|
|
535
|
-
saveConfig(config);
|
|
536
|
-
}
|
|
537
|
-
function unsetConfigValue(key) {
|
|
538
|
-
validateKey(key);
|
|
539
|
-
const config = loadConfig();
|
|
540
|
-
delete config[key];
|
|
541
|
-
saveConfig(config);
|
|
542
658
|
}
|
|
659
|
+
async function runDeviceCodeFlow(org, oauthConfig, deps = {}) {
|
|
660
|
+
const fetchFn = deps.fetch ?? fetch;
|
|
661
|
+
const now = deps.now ?? (() => Date.now());
|
|
662
|
+
const writePrompt = deps.writePrompt ?? ((m) => {
|
|
663
|
+
process.stderr.write(m);
|
|
664
|
+
});
|
|
665
|
+
const dc = await requestDeviceCode(oauthConfig, fetchFn);
|
|
666
|
+
const expiryMin = Math.round(dc.expires_in / 60);
|
|
667
|
+
writePrompt(
|
|
668
|
+
`
|
|
669
|
+
To authenticate, open ${dc.verification_uri} in a browser and enter the code:
|
|
543
670
|
|
|
544
|
-
|
|
545
|
-
var VISIBLE_CHARS = 5;
|
|
546
|
-
function maskedDisplay(pat) {
|
|
547
|
-
if (pat.length <= VISIBLE_CHARS * 2) {
|
|
548
|
-
return pat;
|
|
549
|
-
}
|
|
550
|
-
const hiddenCount = pat.length - VISIBLE_CHARS * 2;
|
|
551
|
-
return pat.slice(0, VISIBLE_CHARS) + "*".repeat(hiddenCount) + pat.slice(-VISIBLE_CHARS);
|
|
552
|
-
}
|
|
553
|
-
function normalizePat(rawPat) {
|
|
554
|
-
const trimmedPat = rawPat.trim();
|
|
555
|
-
return trimmedPat.length > 0 ? trimmedPat : null;
|
|
556
|
-
}
|
|
671
|
+
${dc.user_code}
|
|
557
672
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
var LEGACY_ACCOUNT = "pat";
|
|
561
|
-
function accountFor(org) {
|
|
562
|
-
return `pat:${org}`;
|
|
563
|
-
}
|
|
564
|
-
function probeBackend() {
|
|
565
|
-
switch (process.platform) {
|
|
566
|
-
case "win32":
|
|
567
|
-
return "windows-credential-manager";
|
|
568
|
-
case "darwin":
|
|
569
|
-
return "macos-keychain";
|
|
570
|
-
case "linux":
|
|
571
|
-
return "linux-libsecret";
|
|
572
|
-
default:
|
|
573
|
-
return "unknown";
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
function wrapUnavailable(fn) {
|
|
577
|
-
try {
|
|
578
|
-
return fn();
|
|
579
|
-
} catch (err) {
|
|
580
|
-
throw new CredentialStoreUnavailableError(probeBackend(), err);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
var legacyUnsetNoticeEmitted = false;
|
|
584
|
-
function emitLegacyUnsetNoticeOnce() {
|
|
585
|
-
if (legacyUnsetNoticeEmitted) return;
|
|
586
|
-
legacyUnsetNoticeEmitted = true;
|
|
587
|
-
process.stderr.write(
|
|
588
|
-
'A legacy PAT exists in the OS vault from a previous azdo-cli version, but no "org" is set in config. Run `azdo auth --org <name>` to re-store it under the per-org key, then `azdo clear-pat` to remove the legacy slot.\n'
|
|
673
|
+
Waiting for authorisation (expires in ${expiryMin} min)\u2026
|
|
674
|
+
`
|
|
589
675
|
);
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
if (!config.org) {
|
|
595
|
-
let legacyExists;
|
|
596
|
-
try {
|
|
597
|
-
const legacyEntry2 = new Entry(SERVICE, LEGACY_ACCOUNT);
|
|
598
|
-
legacyExists = legacyEntry2.getPassword() !== null;
|
|
599
|
-
} catch {
|
|
600
|
-
legacyExists = false;
|
|
601
|
-
}
|
|
602
|
-
if (legacyExists) {
|
|
603
|
-
emitLegacyUnsetNoticeOnce();
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
return null;
|
|
607
|
-
}
|
|
608
|
-
const newEntry = new Entry(SERVICE, accountFor(targetOrg));
|
|
609
|
-
const existingNew = wrapUnavailable(() => newEntry.getPassword());
|
|
610
|
-
if (existingNew !== null) {
|
|
611
|
-
return null;
|
|
612
|
-
}
|
|
613
|
-
const legacyEntry = new Entry(SERVICE, LEGACY_ACCOUNT);
|
|
614
|
-
const legacy = wrapUnavailable(() => legacyEntry.getPassword());
|
|
615
|
-
if (legacy === null) {
|
|
616
|
-
return null;
|
|
617
|
-
}
|
|
618
|
-
wrapUnavailable(() => {
|
|
619
|
-
newEntry.setPassword(legacy);
|
|
620
|
-
legacyEntry.deletePassword();
|
|
621
|
-
});
|
|
622
|
-
appendAuthAuditEvent({
|
|
623
|
-
event: "auth.store",
|
|
624
|
-
org: targetOrg,
|
|
625
|
-
backend: probeBackend(),
|
|
626
|
-
masked_pat: maskedDisplay(legacy)
|
|
627
|
-
});
|
|
628
|
-
process.stderr.write(`Migrated legacy PAT to org ${targetOrg}.
|
|
629
|
-
`);
|
|
630
|
-
return legacy;
|
|
631
|
-
}
|
|
632
|
-
async function getPat(org) {
|
|
633
|
-
const entry = new Entry(SERVICE, accountFor(org));
|
|
634
|
-
const value = wrapUnavailable(() => entry.getPassword());
|
|
635
|
-
if (value !== null) {
|
|
636
|
-
return value;
|
|
637
|
-
}
|
|
638
|
-
const migrated = await maybeMigrateLegacy(org);
|
|
639
|
-
return migrated;
|
|
640
|
-
}
|
|
641
|
-
async function storePat(org, pat) {
|
|
642
|
-
const entry = new Entry(SERVICE, accountFor(org));
|
|
643
|
-
wrapUnavailable(() => entry.setPassword(pat));
|
|
644
|
-
appendAuthAuditEvent({
|
|
645
|
-
event: "auth.store",
|
|
646
|
-
org,
|
|
647
|
-
backend: probeBackend(),
|
|
648
|
-
masked_pat: maskedDisplay(pat)
|
|
649
|
-
});
|
|
650
|
-
}
|
|
651
|
-
async function deletePat(org) {
|
|
652
|
-
const entry = new Entry(SERVICE, accountFor(org));
|
|
653
|
-
const existing = wrapUnavailable(() => entry.getPassword());
|
|
654
|
-
if (existing === null) {
|
|
655
|
-
return false;
|
|
656
|
-
}
|
|
657
|
-
wrapUnavailable(() => entry.deletePassword());
|
|
658
|
-
appendAuthAuditEvent({
|
|
659
|
-
event: "auth.delete",
|
|
660
|
-
org,
|
|
661
|
-
backend: probeBackend(),
|
|
662
|
-
masked_pat: maskedDisplay(existing)
|
|
663
|
-
});
|
|
664
|
-
return true;
|
|
665
|
-
}
|
|
666
|
-
async function listOrgsWithStoredPat() {
|
|
667
|
-
const seen = /* @__PURE__ */ new Set();
|
|
668
|
-
for (const ev of readAuditEvents()) {
|
|
669
|
-
if (ev.event === "auth.store") {
|
|
670
|
-
seen.add(ev.org);
|
|
671
|
-
} else if (ev.event === "auth.delete") {
|
|
672
|
-
seen.delete(ev.org);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
const present = [];
|
|
676
|
-
for (const org of seen) {
|
|
677
|
-
const entry = new Entry(SERVICE, accountFor(org));
|
|
678
|
-
const value = wrapUnavailable(() => entry.getPassword());
|
|
679
|
-
if (value !== null) {
|
|
680
|
-
present.push(org);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
present.sort((a, b) => a.localeCompare(b));
|
|
684
|
-
return present;
|
|
676
|
+
const expiresAtMs = now() + dc.expires_in * 1e3;
|
|
677
|
+
const token = await pollForDeviceToken(dc.device_code, oauthConfig, dc.interval, expiresAtMs, deps);
|
|
678
|
+
const credential = tokenResponseToCredential(org, oauthConfig, token, now());
|
|
679
|
+
return { credential, flowUsed: "device-code" };
|
|
685
680
|
}
|
|
686
681
|
|
|
687
682
|
// src/services/auth.ts
|
|
@@ -737,7 +732,7 @@ function findDotEnvPat(startDir = process.cwd()) {
|
|
|
737
732
|
if (existsSync(envFile)) {
|
|
738
733
|
const contents = readFileSync2(envFile, "utf8");
|
|
739
734
|
for (const line of contents.split("\n")) {
|
|
740
|
-
const match = line.match(/^AZDO_PAT\s
|
|
735
|
+
const match = line.match(/^AZDO_PAT\s*=([^\n\r]+)$/);
|
|
741
736
|
if (match) {
|
|
742
737
|
const value = match[1].trim().replace(/^["']|["']$/g, "");
|
|
743
738
|
if (value.length > 0) return value;
|
|
@@ -750,29 +745,36 @@ function findDotEnvPat(startDir = process.cwd()) {
|
|
|
750
745
|
}
|
|
751
746
|
return null;
|
|
752
747
|
}
|
|
753
|
-
async function
|
|
748
|
+
async function resolveAuthCredential(org) {
|
|
754
749
|
const envPat = process.env.AZDO_PAT;
|
|
755
750
|
if (envPat && envPat.length > 0) {
|
|
756
|
-
return { pat: envPat, source: "env" };
|
|
751
|
+
return { pat: envPat, source: "env", kind: "pat" };
|
|
757
752
|
}
|
|
758
|
-
const
|
|
759
|
-
if (
|
|
760
|
-
|
|
753
|
+
const stored = await getStoredCredential(org);
|
|
754
|
+
if (stored !== null) {
|
|
755
|
+
if (stored.kind === "pat") {
|
|
756
|
+
return { pat: stored.token, source: "credential-store", kind: "pat" };
|
|
757
|
+
}
|
|
758
|
+
const fresh = await refreshIfNeeded(org, stored);
|
|
759
|
+
return {
|
|
760
|
+
pat: fresh.accessToken,
|
|
761
|
+
source: "credential-store",
|
|
762
|
+
kind: "oauth",
|
|
763
|
+
accountId: fresh.accountId
|
|
764
|
+
};
|
|
761
765
|
}
|
|
762
766
|
const dotEnvPat = findDotEnvPat();
|
|
763
767
|
if (dotEnvPat !== null) {
|
|
764
|
-
return { pat: dotEnvPat, source: "env" };
|
|
768
|
+
return { pat: dotEnvPat, source: "env", kind: "pat" };
|
|
765
769
|
}
|
|
766
770
|
return null;
|
|
767
771
|
}
|
|
768
|
-
async function
|
|
769
|
-
const cred = await
|
|
772
|
+
async function requireAuthCredential(org) {
|
|
773
|
+
const cred = await resolveAuthCredential(org);
|
|
770
774
|
if (cred !== null) {
|
|
771
775
|
return cred;
|
|
772
776
|
}
|
|
773
|
-
throw new
|
|
774
|
-
`No PAT available for org "${org}". Set AZDO_PAT environment variable or run \`azdo auth --org ${org}\`.`
|
|
775
|
-
);
|
|
777
|
+
throw new CredentialMissingError(org);
|
|
776
778
|
}
|
|
777
779
|
async function validatePatAgainstAzdo(pat, org) {
|
|
778
780
|
const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?$top=1&api-version=7.1`;
|
|
@@ -791,51 +793,309 @@ async function validatePatAgainstAzdo(pat, org) {
|
|
|
791
793
|
}
|
|
792
794
|
throw new Error(`Azure DevOps returned HTTP ${response.status} while validating PAT for org "${org}".`);
|
|
793
795
|
}
|
|
796
|
+
var LOGIN_FAILURE_REASONS = /* @__PURE__ */ new Set([
|
|
797
|
+
"user-cancelled",
|
|
798
|
+
"port-conflict",
|
|
799
|
+
"state-mismatch",
|
|
800
|
+
"redirect-mismatch",
|
|
801
|
+
"idp-error",
|
|
802
|
+
"timeout",
|
|
803
|
+
"expired_token",
|
|
804
|
+
"access_denied"
|
|
805
|
+
]);
|
|
806
|
+
function extractLoginFailureReason(err) {
|
|
807
|
+
if (typeof err === "object" && err !== null && "reason" in err) {
|
|
808
|
+
const r = err.reason;
|
|
809
|
+
if (typeof r === "string" && LOGIN_FAILURE_REASONS.has(r)) {
|
|
810
|
+
return r;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return "unknown";
|
|
814
|
+
}
|
|
815
|
+
async function loginWithOAuth(org, opts = {}) {
|
|
816
|
+
const oauthConfig = resolveOAuthConfig({
|
|
817
|
+
clientIdOverride: opts.clientIdOverride,
|
|
818
|
+
tenantIdOverride: opts.tenantIdOverride,
|
|
819
|
+
scopesOverride: opts.scopesOverride
|
|
820
|
+
});
|
|
821
|
+
const isHeadlessRuntime = () => {
|
|
822
|
+
if (opts.forceHeadless) return true;
|
|
823
|
+
if (process.platform === "linux") {
|
|
824
|
+
return !process.env.DISPLAY || process.env.DISPLAY.length === 0;
|
|
825
|
+
}
|
|
826
|
+
return false;
|
|
827
|
+
};
|
|
828
|
+
const useDeviceCode = opts.flow === "device-code" || opts.flow !== "auth-code" && isHeadlessRuntime();
|
|
829
|
+
appendAuthAuditEvent({
|
|
830
|
+
event: "oauth-login-started",
|
|
831
|
+
org,
|
|
832
|
+
backend: probeBackend(),
|
|
833
|
+
flow: useDeviceCode ? "device-code" : "auth-code",
|
|
834
|
+
clientIdSource: oauthConfig.clientIdSource
|
|
835
|
+
});
|
|
836
|
+
let credential;
|
|
837
|
+
let flowUsed;
|
|
838
|
+
try {
|
|
839
|
+
if (useDeviceCode) {
|
|
840
|
+
const r = await runDeviceCodeFlow(org, oauthConfig);
|
|
841
|
+
credential = r.credential;
|
|
842
|
+
flowUsed = "device-code";
|
|
843
|
+
} else {
|
|
844
|
+
const r = await runAuthCodeFlow(org, oauthConfig);
|
|
845
|
+
credential = r.credential;
|
|
846
|
+
flowUsed = "auth-code";
|
|
847
|
+
}
|
|
848
|
+
} catch (err) {
|
|
849
|
+
appendAuthAuditEvent({
|
|
850
|
+
event: "oauth-login-failed",
|
|
851
|
+
org,
|
|
852
|
+
backend: probeBackend(),
|
|
853
|
+
flow: useDeviceCode ? "device-code" : "auth-code",
|
|
854
|
+
reason: extractLoginFailureReason(err)
|
|
855
|
+
});
|
|
856
|
+
throw err;
|
|
857
|
+
}
|
|
858
|
+
await storeOAuthCredential(org, credential);
|
|
859
|
+
appendAuthAuditEvent({
|
|
860
|
+
event: "oauth-login-success",
|
|
861
|
+
org,
|
|
862
|
+
backend: probeBackend(),
|
|
863
|
+
flow: flowUsed,
|
|
864
|
+
clientIdSource: oauthConfig.clientIdSource,
|
|
865
|
+
accountId: credential.accountId,
|
|
866
|
+
scope: credential.scope,
|
|
867
|
+
tokenLifetimeSec: credential.expiresAt - credential.issuedAt
|
|
868
|
+
});
|
|
869
|
+
return {
|
|
870
|
+
org,
|
|
871
|
+
kind: "oauth",
|
|
872
|
+
accountId: credential.accountId,
|
|
873
|
+
expiresAt: credential.expiresAt,
|
|
874
|
+
scope: credential.scope,
|
|
875
|
+
flowUsed
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
async function logout(opts = {}) {
|
|
879
|
+
if (opts.all) {
|
|
880
|
+
const orgs = await listOrgsWithStoredPat();
|
|
881
|
+
const removed = [];
|
|
882
|
+
for (const o of orgs) {
|
|
883
|
+
const cred2 = await getStoredCredential(o);
|
|
884
|
+
const ok2 = await deletePat(o);
|
|
885
|
+
if (ok2 && cred2 !== null) {
|
|
886
|
+
removed.push({ org: o, kind: cred2.kind });
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return { removed };
|
|
890
|
+
}
|
|
891
|
+
if (!opts.org) {
|
|
892
|
+
throw new Error("logout requires an org or --all");
|
|
893
|
+
}
|
|
894
|
+
const cred = await getStoredCredential(opts.org);
|
|
895
|
+
const ok = await deletePat(opts.org);
|
|
896
|
+
return { removed: ok && cred !== null ? [{ org: opts.org, kind: cred.kind }] : [] };
|
|
897
|
+
}
|
|
898
|
+
async function status() {
|
|
899
|
+
const orgs = await listOrgsWithStoredPat();
|
|
900
|
+
const out = [];
|
|
901
|
+
for (const org of orgs) {
|
|
902
|
+
const cred = await getStoredCredential(org);
|
|
903
|
+
if (cred === null) continue;
|
|
904
|
+
if (cred.kind === "pat") {
|
|
905
|
+
out.push({ org, kind: "pat", backend: probeBackend() });
|
|
906
|
+
} else {
|
|
907
|
+
out.push({
|
|
908
|
+
org,
|
|
909
|
+
kind: "oauth",
|
|
910
|
+
accountId: cred.accountId,
|
|
911
|
+
expiresAt: cred.expiresAt,
|
|
912
|
+
scope: cred.scope,
|
|
913
|
+
backend: probeBackend()
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return { orgs: out };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// src/services/git-remote.ts
|
|
921
|
+
import { execFileSync } from "child_process";
|
|
922
|
+
import fs from "fs";
|
|
923
|
+
import path from "path";
|
|
924
|
+
|
|
925
|
+
// src/services/remote-warning.ts
|
|
926
|
+
var warned = false;
|
|
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") {
|
|
932
|
+
if (warned) {
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
warned = true;
|
|
936
|
+
try {
|
|
937
|
+
process.stderr.write(buildWarning(remoteName));
|
|
938
|
+
} catch {
|
|
939
|
+
}
|
|
940
|
+
}
|
|
794
941
|
|
|
795
942
|
// src/services/git-remote.ts
|
|
796
|
-
|
|
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
|
+
})();
|
|
797
954
|
var patterns = [
|
|
798
|
-
// HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
|
|
799
|
-
/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]
|
|
800
|
-
// HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
|
|
801
|
-
/^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]
|
|
802
|
-
// HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
|
|
803
|
-
/^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]
|
|
804
|
-
// SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
|
|
805
|
-
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]
|
|
806
|
-
// SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
|
|
807
|
-
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]
|
|
955
|
+
// HTTPS (current): https://[user[:token]@]dev.azure.com/{org}/{project}/_git/{repo}[.git]
|
|
956
|
+
/^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
|
|
957
|
+
// HTTPS (legacy + DefaultCollection): https://[user[:token]@]{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}[.git]
|
|
958
|
+
/^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
|
|
959
|
+
// HTTPS (legacy): https://[user[:token]@]{org}.visualstudio.com/{project}/_git/{repo}[.git]
|
|
960
|
+
/^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
|
|
961
|
+
// SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}[.git]
|
|
962
|
+
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/,
|
|
963
|
+
// SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}[.git]
|
|
964
|
+
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/
|
|
808
965
|
];
|
|
809
|
-
|
|
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 matchAzdoRemote(remoteName, url) {
|
|
810
979
|
for (const pattern of patterns) {
|
|
811
980
|
const match = pattern.exec(url);
|
|
812
|
-
if (match)
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
981
|
+
if (!match) continue;
|
|
982
|
+
const project = match[2];
|
|
983
|
+
if (/^DefaultCollection$/i.test(project)) return null;
|
|
984
|
+
return { remoteName, org: match[1], project, hasEmbeddedSecret: httpsEmbeddedSecret.test(url) };
|
|
985
|
+
}
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
function parseAllAzdoRemotes(output) {
|
|
989
|
+
const seen = /* @__PURE__ */ new Set();
|
|
990
|
+
const results = [];
|
|
991
|
+
for (const line of output.split("\n")) {
|
|
992
|
+
const parsed = parseSingleRemoteLine(line);
|
|
993
|
+
if (!parsed) continue;
|
|
994
|
+
const { remoteName, url } = parsed;
|
|
995
|
+
if (seen.has(remoteName)) continue;
|
|
996
|
+
const candidate = matchAzdoRemote(remoteName, url);
|
|
997
|
+
if (candidate) {
|
|
998
|
+
seen.add(remoteName);
|
|
999
|
+
results.push(candidate);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return results;
|
|
1003
|
+
}
|
|
1004
|
+
function selectRemote(candidates) {
|
|
1005
|
+
if (candidates.length === 0) {
|
|
1006
|
+
throw new Error("No Azure DevOps remote found. Provide --org and --project explicitly.");
|
|
1007
|
+
}
|
|
1008
|
+
const origin = candidates.find((c) => c.remoteName === "origin");
|
|
1009
|
+
if (origin) return origin;
|
|
1010
|
+
if (candidates.length === 1) return candidates[0];
|
|
1011
|
+
const first = candidates[0];
|
|
1012
|
+
const allSame = candidates.every(
|
|
1013
|
+
(c) => c.org.toLowerCase() === first.org.toLowerCase() && c.project.toLowerCase() === first.project.toLowerCase()
|
|
1014
|
+
);
|
|
1015
|
+
if (allSame) return first;
|
|
1016
|
+
const lines = candidates.map((c) => ` ${c.remoteName.padEnd(8)} \u2192 ${c.org}/${c.project}`).join("\n");
|
|
1017
|
+
throw new Error(
|
|
1018
|
+
`Multiple Azure DevOps remotes found with different org/project:
|
|
1019
|
+
${lines}
|
|
1020
|
+
Use --org/--project (or 'git remote rename <name> origin') to disambiguate.`
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
function readGitConfigContent() {
|
|
1024
|
+
const gitDirEnv = process.env.GIT_DIR;
|
|
1025
|
+
if (gitDirEnv) {
|
|
1026
|
+
return fs.readFileSync(path.join(gitDirEnv, "config"), "utf-8");
|
|
1027
|
+
}
|
|
1028
|
+
let dir = process.cwd();
|
|
1029
|
+
for (; ; ) {
|
|
1030
|
+
const gitPath = path.join(dir, ".git");
|
|
1031
|
+
try {
|
|
1032
|
+
const stat = fs.statSync(gitPath);
|
|
1033
|
+
if (stat.isDirectory()) {
|
|
1034
|
+
return fs.readFileSync(path.join(gitPath, "config"), "utf-8");
|
|
1035
|
+
}
|
|
1036
|
+
if (stat.isFile()) {
|
|
1037
|
+
const ref = fs.readFileSync(gitPath, "utf-8");
|
|
1038
|
+
const m = /^gitdir:[ \t]*([^\r\n]+)/m.exec(ref);
|
|
1039
|
+
if (m) {
|
|
1040
|
+
return fs.readFileSync(path.join(path.resolve(dir, m[1].trim()), "config"), "utf-8");
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
} catch {
|
|
1044
|
+
}
|
|
1045
|
+
const parent = path.dirname(dir);
|
|
1046
|
+
if (parent === dir) break;
|
|
1047
|
+
dir = parent;
|
|
1048
|
+
}
|
|
1049
|
+
throw new Error("Not in a git repository. Provide --org and --project explicitly.");
|
|
1050
|
+
}
|
|
1051
|
+
function gitConfigToRemoteLines(configContent) {
|
|
1052
|
+
const lines = [];
|
|
1053
|
+
let currentRemote = null;
|
|
1054
|
+
let emittedUrl = false;
|
|
1055
|
+
for (const line of configContent.split("\n")) {
|
|
1056
|
+
const sectionMatch = /^\[remote\s+"([^"]+)"\]/.exec(line);
|
|
1057
|
+
if (sectionMatch) {
|
|
1058
|
+
currentRemote = sectionMatch[1];
|
|
1059
|
+
emittedUrl = false;
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
if (line.startsWith("[")) {
|
|
1063
|
+
currentRemote = null;
|
|
1064
|
+
emittedUrl = false;
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (currentRemote && !emittedUrl) {
|
|
1068
|
+
const urlMatch = /^[ \t]+url[ \t]*=[ \t]*([^\r\n]+)/.exec(line);
|
|
1069
|
+
if (urlMatch) {
|
|
1070
|
+
lines.push(`${currentRemote} ${urlMatch[1].trim()} (fetch)`);
|
|
1071
|
+
emittedUrl = true;
|
|
816
1072
|
}
|
|
817
|
-
return { org: match[1], project };
|
|
818
1073
|
}
|
|
819
1074
|
}
|
|
820
|
-
return
|
|
1075
|
+
return lines.join("\n");
|
|
821
1076
|
}
|
|
822
1077
|
function detectAzdoContext() {
|
|
823
|
-
let
|
|
1078
|
+
let configContent;
|
|
824
1079
|
try {
|
|
825
|
-
|
|
1080
|
+
configContent = readGitConfigContent();
|
|
826
1081
|
} catch {
|
|
827
1082
|
throw new Error("Not in a git repository. Provide --org and --project explicitly.");
|
|
828
1083
|
}
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
1084
|
+
const remoteLines = gitConfigToRemoteLines(configContent);
|
|
1085
|
+
const candidates = parseAllAzdoRemotes(remoteLines);
|
|
1086
|
+
const selected = selectRemote(candidates);
|
|
1087
|
+
if (selected.hasEmbeddedSecret) {
|
|
1088
|
+
noticeCredentialBearingRemote(selected.remoteName);
|
|
832
1089
|
}
|
|
833
|
-
return
|
|
1090
|
+
return { org: selected.org, project: selected.project };
|
|
834
1091
|
}
|
|
835
1092
|
function parseRepoName(url) {
|
|
836
1093
|
for (const pattern of patterns) {
|
|
837
1094
|
const match = pattern.exec(url);
|
|
838
1095
|
if (match) {
|
|
1096
|
+
if (httpsEmbeddedSecret.test(url)) {
|
|
1097
|
+
noticeCredentialBearingRemote();
|
|
1098
|
+
}
|
|
839
1099
|
return match[3];
|
|
840
1100
|
}
|
|
841
1101
|
}
|
|
@@ -844,7 +1104,7 @@ function parseRepoName(url) {
|
|
|
844
1104
|
function detectRepoName() {
|
|
845
1105
|
let remoteUrl;
|
|
846
1106
|
try {
|
|
847
|
-
remoteUrl =
|
|
1107
|
+
remoteUrl = execFileSync(GIT_BINARY, ["remote", "get-url", "origin"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
848
1108
|
} catch {
|
|
849
1109
|
throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
|
|
850
1110
|
}
|
|
@@ -855,7 +1115,7 @@ function detectRepoName() {
|
|
|
855
1115
|
return repo;
|
|
856
1116
|
}
|
|
857
1117
|
function getCurrentBranch() {
|
|
858
|
-
const branch =
|
|
1118
|
+
const branch = execFileSync(GIT_BINARY, ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
859
1119
|
if (branch === "HEAD") {
|
|
860
1120
|
throw new Error("Not on a named branch. Check out a named branch and try again.");
|
|
861
1121
|
}
|
|
@@ -891,7 +1151,7 @@ function formatResolutionError() {
|
|
|
891
1151
|
return [
|
|
892
1152
|
"Could not resolve an Azure DevOps organization. Options (in priority order):",
|
|
893
1153
|
" 1. Pass --org <name> on the command line.",
|
|
894
|
-
" 2. Run this command from a git repo
|
|
1154
|
+
" 2. Run this command from a git repo that has an Azure DevOps remote.",
|
|
895
1155
|
" 3. Run `azdo config set org <name>` once to set a persistent default."
|
|
896
1156
|
].join("\n");
|
|
897
1157
|
}
|
|
@@ -904,9 +1164,9 @@ function resolveContext(options) {
|
|
|
904
1164
|
gitContext = detectAzdoContext();
|
|
905
1165
|
} catch {
|
|
906
1166
|
}
|
|
907
|
-
const config = loadConfig();
|
|
908
1167
|
const org = resolvedOrg?.org;
|
|
909
|
-
const
|
|
1168
|
+
const scopedCfg = resolveScopedConfig(org);
|
|
1169
|
+
const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || scopedCfg.project;
|
|
910
1170
|
if (org && project) {
|
|
911
1171
|
return { org, project };
|
|
912
1172
|
}
|
|
@@ -1058,12 +1318,179 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
|
|
|
1058
1318
|
}
|
|
1059
1319
|
}
|
|
1060
1320
|
|
|
1061
|
-
// src/
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1321
|
+
// src/services/image-download.ts
|
|
1322
|
+
import { Jimp } from "jimp";
|
|
1323
|
+
import { writeFile } from "fs/promises";
|
|
1324
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1325
|
+
import { tmpdir } from "os";
|
|
1326
|
+
import { join as join2 } from "path";
|
|
1327
|
+
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})/;
|
|
1328
|
+
function isAzureDevOpsAttachmentHost(hostname) {
|
|
1329
|
+
const host = hostname.toLowerCase();
|
|
1330
|
+
return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
|
|
1331
|
+
}
|
|
1332
|
+
function decodeHtmlEntities(value) {
|
|
1333
|
+
return value.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
1334
|
+
}
|
|
1335
|
+
function parseAttachmentReference(rawUrl, sourceField) {
|
|
1336
|
+
const url = decodeHtmlEntities(rawUrl.trim());
|
|
1337
|
+
let parsed;
|
|
1338
|
+
try {
|
|
1339
|
+
parsed = new URL(url);
|
|
1340
|
+
} catch {
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
|
|
1347
|
+
if (!match) return null;
|
|
1348
|
+
const guid = match[1].toLowerCase();
|
|
1349
|
+
let suggestedExtension = ".png";
|
|
1350
|
+
const fileName = parsed.searchParams.get("fileName");
|
|
1351
|
+
if (fileName?.includes(".")) {
|
|
1352
|
+
suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
|
|
1353
|
+
}
|
|
1354
|
+
return { url, sourceField, guid, suggestedExtension };
|
|
1355
|
+
}
|
|
1356
|
+
function extractImageReferences(content, sourceField) {
|
|
1357
|
+
if (!content) return [];
|
|
1358
|
+
const references = [];
|
|
1359
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1360
|
+
const add = (rawUrl) => {
|
|
1361
|
+
const reference = parseAttachmentReference(rawUrl, sourceField);
|
|
1362
|
+
if (reference && !seen.has(reference.guid)) {
|
|
1363
|
+
seen.add(reference.guid);
|
|
1364
|
+
references.push(reference);
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
|
|
1368
|
+
let match;
|
|
1369
|
+
while ((match = imgRegex.exec(content)) !== null) {
|
|
1370
|
+
add(match[1]);
|
|
1371
|
+
}
|
|
1372
|
+
const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
|
|
1373
|
+
while ((match = markdownRegex.exec(content)) !== null) {
|
|
1374
|
+
add(match[1]);
|
|
1375
|
+
}
|
|
1376
|
+
return references;
|
|
1377
|
+
}
|
|
1378
|
+
function addImageDownloadOptions(command) {
|
|
1379
|
+
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)");
|
|
1380
|
+
}
|
|
1381
|
+
function resolveImageDownloadOptionsOrExit(flags) {
|
|
1382
|
+
try {
|
|
1383
|
+
return resolveImageDownloadOptions(flags);
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1386
|
+
`);
|
|
1387
|
+
process.exit(1);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
function resolveImageDownloadOptions(flags) {
|
|
1391
|
+
const wantsResize = flags.resizeImages !== void 0;
|
|
1392
|
+
const enabled = Boolean(flags.downloadImages) || wantsResize;
|
|
1393
|
+
let maxWidth;
|
|
1394
|
+
if (wantsResize) {
|
|
1395
|
+
const parsed = Number(flags.resizeImages);
|
|
1396
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1397
|
+
throw new Error(
|
|
1398
|
+
`Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
maxWidth = parsed;
|
|
1402
|
+
}
|
|
1403
|
+
const outputDir = flags.imagesPath ?? tmpdir();
|
|
1404
|
+
if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
|
|
1405
|
+
throw new Error(`Images path "${outputDir}" does not exist.`);
|
|
1406
|
+
}
|
|
1407
|
+
return { enabled, maxWidth, outputDir };
|
|
1408
|
+
}
|
|
1409
|
+
function buildImageFileName(workItemId, index, reference, resizing) {
|
|
1410
|
+
const ext = resizing ? ".png" : reference.suggestedExtension;
|
|
1411
|
+
return `wi-${workItemId}-${index}${ext}`;
|
|
1412
|
+
}
|
|
1413
|
+
async function processImageBytes(bytes, maxWidth) {
|
|
1414
|
+
if (maxWidth === void 0) {
|
|
1415
|
+
return { buffer: Buffer.from(bytes), resized: false, format: "original" };
|
|
1416
|
+
}
|
|
1417
|
+
const image = await Jimp.read(Buffer.from(bytes));
|
|
1418
|
+
let resized = false;
|
|
1419
|
+
if (image.bitmap.width > maxWidth) {
|
|
1420
|
+
image.resize({ w: maxWidth });
|
|
1421
|
+
resized = true;
|
|
1422
|
+
}
|
|
1423
|
+
const buffer = await image.getBuffer("image/png");
|
|
1424
|
+
return { buffer, resized, format: "png" };
|
|
1425
|
+
}
|
|
1426
|
+
async function downloadImagesFromFields(fields, args, credential) {
|
|
1427
|
+
const { workItemId, options } = args;
|
|
1428
|
+
const resizing = options.maxWidth !== void 0;
|
|
1429
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1430
|
+
const references = [];
|
|
1431
|
+
for (const field of fields) {
|
|
1432
|
+
for (const reference of extractImageReferences(field.content, field.field)) {
|
|
1433
|
+
if (!seen.has(reference.guid)) {
|
|
1434
|
+
seen.add(reference.guid);
|
|
1435
|
+
references.push(reference);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
const results = [];
|
|
1440
|
+
let index = 0;
|
|
1441
|
+
for (const reference of references) {
|
|
1442
|
+
index += 1;
|
|
1443
|
+
try {
|
|
1444
|
+
const bytes = await downloadAttachment(reference.url, credential);
|
|
1445
|
+
const processed = await processImageBytes(bytes, options.maxWidth);
|
|
1446
|
+
const fileName = buildImageFileName(workItemId, index, reference, resizing);
|
|
1447
|
+
const outputPath = join2(options.outputDir, fileName);
|
|
1448
|
+
await writeFile(outputPath, processed.buffer);
|
|
1449
|
+
results.push({
|
|
1450
|
+
reference,
|
|
1451
|
+
path: outputPath,
|
|
1452
|
+
resized: processed.resized,
|
|
1453
|
+
format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
|
|
1454
|
+
});
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
results.push({
|
|
1457
|
+
reference,
|
|
1458
|
+
resized: false,
|
|
1459
|
+
format: reference.suggestedExtension.replace(/^\./, ""),
|
|
1460
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
return results;
|
|
1465
|
+
}
|
|
1466
|
+
async function runImageDownload(fields, args, credential) {
|
|
1467
|
+
const results = await downloadImagesFromFields(fields, args, credential);
|
|
1468
|
+
process.stdout.write(formatImageSummary(results) + "\n");
|
|
1469
|
+
for (const result of results) {
|
|
1470
|
+
if (result.error) {
|
|
1471
|
+
process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
|
|
1472
|
+
`);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
function formatImageSummary(results) {
|
|
1477
|
+
if (results.length === 0) {
|
|
1478
|
+
return "Images: no images found in rich-text fields";
|
|
1479
|
+
}
|
|
1480
|
+
const saved = results.filter((r) => r.path);
|
|
1481
|
+
const lines = [`Images: ${saved.length} downloaded`];
|
|
1482
|
+
for (const result of saved) {
|
|
1483
|
+
lines.push(` ${result.path}`);
|
|
1484
|
+
}
|
|
1485
|
+
return lines.join("\n");
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// src/commands/get-item.ts
|
|
1489
|
+
function parseRequestedFields(raw) {
|
|
1490
|
+
if (raw === void 0) return void 0;
|
|
1491
|
+
const source = Array.isArray(raw) ? raw : [raw];
|
|
1492
|
+
const tokens = source.flatMap((entry) => entry.split(/[,\s]+/)).map((field) => field.trim()).filter((field) => field.length > 0);
|
|
1493
|
+
if (tokens.length === 0) return void 0;
|
|
1067
1494
|
return Array.from(new Set(tokens));
|
|
1068
1495
|
}
|
|
1069
1496
|
function stripHtml(html) {
|
|
@@ -1073,12 +1500,12 @@ function stripHtml(html) {
|
|
|
1073
1500
|
text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
|
|
1074
1501
|
text = text.replaceAll(/<li>/gi, "\n");
|
|
1075
1502
|
text = removeHtmlTags(text);
|
|
1076
|
-
text = text.replaceAll("&", "&");
|
|
1077
1503
|
text = text.replaceAll("<", "<");
|
|
1078
1504
|
text = text.replaceAll(">", ">");
|
|
1079
1505
|
text = text.replaceAll(""", '"');
|
|
1080
1506
|
text = text.replaceAll("'", "'");
|
|
1081
1507
|
text = text.replaceAll(" ", " ");
|
|
1508
|
+
text = text.replaceAll("&", "&");
|
|
1082
1509
|
text = text.replaceAll(/\n{3,}/g, "\n\n");
|
|
1083
1510
|
return text.trim();
|
|
1084
1511
|
}
|
|
@@ -1181,19 +1608,32 @@ function formatWorkItem(workItem, short, markdown = false) {
|
|
|
1181
1608
|
}
|
|
1182
1609
|
function createGetItemCommand() {
|
|
1183
1610
|
const command = new Command("get-item");
|
|
1184
|
-
command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown")
|
|
1611
|
+
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");
|
|
1612
|
+
addImageDownloadOptions(command);
|
|
1613
|
+
command.action(
|
|
1185
1614
|
async (idStr, options) => {
|
|
1186
1615
|
const id = parseWorkItemId(idStr);
|
|
1187
1616
|
validateOrgProjectPair(options);
|
|
1617
|
+
const imageOptions = resolveImageDownloadOptionsOrExit(options);
|
|
1188
1618
|
let context;
|
|
1189
1619
|
try {
|
|
1190
1620
|
context = resolveContext(options);
|
|
1191
|
-
const credential = await
|
|
1192
|
-
const
|
|
1193
|
-
const
|
|
1194
|
-
const
|
|
1621
|
+
const credential = await requireAuthCredential(context.org);
|
|
1622
|
+
const scopedCfg = resolveScopedConfig(context.org);
|
|
1623
|
+
const fieldsList = options.fields === void 0 ? parseRequestedFields(scopedCfg.fields) : parseRequestedFields(options.fields);
|
|
1624
|
+
const workItem = await getWorkItem(context, id, credential, fieldsList);
|
|
1625
|
+
const markdownEnabled = options.markdown ?? scopedCfg.markdown ?? false;
|
|
1195
1626
|
const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
|
|
1196
1627
|
process.stdout.write(output + "\n");
|
|
1628
|
+
if (imageOptions.enabled) {
|
|
1629
|
+
const fields = [{ content: workItem.description ?? "", field: "Description" }];
|
|
1630
|
+
if (workItem.extraFields) {
|
|
1631
|
+
for (const [name, value] of Object.entries(workItem.extraFields)) {
|
|
1632
|
+
fields.push({ content: value, field: name });
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
await runImageDownload(fields, { workItemId: id, options: imageOptions }, credential);
|
|
1636
|
+
}
|
|
1197
1637
|
} catch (err) {
|
|
1198
1638
|
handleCommandError(err, id, context, "read", false);
|
|
1199
1639
|
}
|
|
@@ -1230,59 +1670,75 @@ function createClearPatCommand() {
|
|
|
1230
1670
|
// src/commands/auth.ts
|
|
1231
1671
|
import { Command as Command3 } from "commander";
|
|
1232
1672
|
|
|
1233
|
-
// src/services/
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1673
|
+
// src/services/auth-diagnostics.ts
|
|
1674
|
+
async function runConnectivityTest(org, cred) {
|
|
1675
|
+
const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?api-version=7.1&$top=1`;
|
|
1676
|
+
let result;
|
|
1677
|
+
try {
|
|
1678
|
+
result = await fetchRaw(url, { headers: authHeaders(cred) });
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
return { status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
1238
1681
|
}
|
|
1239
|
-
|
|
1682
|
+
if (result.status >= 200 && result.status < 300) {
|
|
1683
|
+
return { status: "ok", error: null };
|
|
1684
|
+
}
|
|
1685
|
+
let error = `HTTP ${result.status}`;
|
|
1686
|
+
try {
|
|
1687
|
+
const parsed = JSON.parse(result.body);
|
|
1688
|
+
if (typeof parsed.message === "string" && parsed.message.trim() !== "") {
|
|
1689
|
+
error = parsed.message.trim();
|
|
1690
|
+
}
|
|
1691
|
+
} catch {
|
|
1692
|
+
}
|
|
1693
|
+
return { status: "failed", error };
|
|
1240
1694
|
}
|
|
1241
|
-
function
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1695
|
+
async function diagnoseAuth(org, project, resolveCredential) {
|
|
1696
|
+
const cred = await resolveCredential(org);
|
|
1697
|
+
if (cred === null) {
|
|
1698
|
+
return {
|
|
1699
|
+
authType: "none",
|
|
1700
|
+
credentialSource: null,
|
|
1701
|
+
org,
|
|
1702
|
+
project,
|
|
1703
|
+
connectivityStatus: "no-credentials",
|
|
1704
|
+
connectivityError: null
|
|
1705
|
+
};
|
|
1251
1706
|
}
|
|
1707
|
+
const connectivity = await runConnectivityTest(org, cred);
|
|
1708
|
+
const envVarName = process.env.AZDO_PAT ? "AZDO_PAT" : "dotenv";
|
|
1709
|
+
const sourceLabel = cred.source === "env" ? `env:${envVarName}` : "credential-store";
|
|
1710
|
+
return {
|
|
1711
|
+
authType: cred.kind ?? "pat",
|
|
1712
|
+
credentialSource: sourceLabel,
|
|
1713
|
+
org,
|
|
1714
|
+
project,
|
|
1715
|
+
connectivityStatus: connectivity.status,
|
|
1716
|
+
connectivityError: connectivity.error
|
|
1717
|
+
};
|
|
1252
1718
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const forcePrint = opts.forcePrint ?? false;
|
|
1257
|
-
if (forcePrint || isHeadless(platform, hasDisplay)) {
|
|
1258
|
-
process.stderr.write(`Open this URL in your browser: ${url}
|
|
1259
|
-
`);
|
|
1260
|
-
return "printed";
|
|
1719
|
+
function formatDiagnosticReport(report, json) {
|
|
1720
|
+
if (json) {
|
|
1721
|
+
return JSON.stringify(report, null, 2);
|
|
1261
1722
|
}
|
|
1262
|
-
|
|
1263
|
-
if (
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1723
|
+
let connectivityLine;
|
|
1724
|
+
if (report.connectivityStatus === "ok") {
|
|
1725
|
+
connectivityLine = "OK";
|
|
1726
|
+
} else if (report.connectivityStatus === "no-credentials") {
|
|
1727
|
+
connectivityLine = "no credentials found";
|
|
1728
|
+
} else {
|
|
1729
|
+
connectivityLine = "FAILED";
|
|
1267
1730
|
}
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
});
|
|
1280
|
-
} catch {
|
|
1281
|
-
process.stderr.write(`Open this URL in your browser: ${url}
|
|
1282
|
-
`);
|
|
1283
|
-
resolve2("printed");
|
|
1284
|
-
}
|
|
1285
|
-
});
|
|
1731
|
+
const lines = [
|
|
1732
|
+
`Auth type: ${report.authType}`,
|
|
1733
|
+
`Source: ${report.credentialSource ?? "(none)"}`,
|
|
1734
|
+
`Org: ${report.org}`,
|
|
1735
|
+
`Project: ${report.project ?? "(not set)"}`,
|
|
1736
|
+
`Connectivity: ${connectivityLine}`
|
|
1737
|
+
];
|
|
1738
|
+
if (report.connectivityStatus === "failed" && report.connectivityError !== null) {
|
|
1739
|
+
lines.push(`Error: ${report.connectivityError}`);
|
|
1740
|
+
}
|
|
1741
|
+
return lines.join("\n");
|
|
1286
1742
|
}
|
|
1287
1743
|
|
|
1288
1744
|
// src/commands/auth.ts
|
|
@@ -1293,9 +1749,9 @@ async function readStdinToString() {
|
|
|
1293
1749
|
}
|
|
1294
1750
|
return Buffer.concat(chunks).toString("utf8");
|
|
1295
1751
|
}
|
|
1296
|
-
async function
|
|
1752
|
+
async function promptYesNo(prompt) {
|
|
1297
1753
|
if (!process.stdin.isTTY) return true;
|
|
1298
|
-
process.stderr.write(
|
|
1754
|
+
process.stderr.write(prompt);
|
|
1299
1755
|
return await new Promise((resolve2) => {
|
|
1300
1756
|
process.stdin.setEncoding("utf8");
|
|
1301
1757
|
let answered = false;
|
|
@@ -1311,7 +1767,23 @@ async function confirmOverwrite(org) {
|
|
|
1311
1767
|
process.stdin.on("data", handler);
|
|
1312
1768
|
});
|
|
1313
1769
|
}
|
|
1314
|
-
async function
|
|
1770
|
+
async function confirmOverwrite(org) {
|
|
1771
|
+
return promptYesNo(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
|
|
1772
|
+
}
|
|
1773
|
+
async function confirmOverwriteCredential(org, existingKind) {
|
|
1774
|
+
const label = existingKind === "oauth" ? "OAuth credential" : "PAT";
|
|
1775
|
+
return promptYesNo(`A ${label} is already stored for org ${org}. The new login will replace it. Continue? [y/N] `);
|
|
1776
|
+
}
|
|
1777
|
+
function rejectMutuallyExclusive(opts) {
|
|
1778
|
+
if (opts.usePat && opts.deviceCode) {
|
|
1779
|
+
return "--use-pat and --device-code are mutually exclusive (PAT has no device-code flow).";
|
|
1780
|
+
}
|
|
1781
|
+
if (opts.usePat && (opts.clientId || opts.tenantId || opts.scopes)) {
|
|
1782
|
+
return "--use-pat cannot be combined with OAuth-only flags (--client-id / --tenant-id / --scopes).";
|
|
1783
|
+
}
|
|
1784
|
+
return null;
|
|
1785
|
+
}
|
|
1786
|
+
async function handlePatLogin(options) {
|
|
1315
1787
|
const resolved = resolveOrg({ org: options.org });
|
|
1316
1788
|
if (!resolved) {
|
|
1317
1789
|
process.stderr.write(`${formatResolutionError()}
|
|
@@ -1377,7 +1849,145 @@ async function handleAuthRoot(options) {
|
|
|
1377
1849
|
process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
|
|
1378
1850
|
`);
|
|
1379
1851
|
}
|
|
1852
|
+
async function ensureOverwriteConfirmed(org) {
|
|
1853
|
+
try {
|
|
1854
|
+
const existing = await getStoredCredential(org);
|
|
1855
|
+
if (existing === null || !process.stdin.isTTY) return "ok";
|
|
1856
|
+
const ok = await confirmOverwriteCredential(org, existing.kind);
|
|
1857
|
+
return ok ? "ok" : "aborted";
|
|
1858
|
+
} catch (err) {
|
|
1859
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1860
|
+
process.stderr.write(`${err.message}
|
|
1861
|
+
`);
|
|
1862
|
+
return "unavailable";
|
|
1863
|
+
}
|
|
1864
|
+
throw err;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
function buildOAuthLoginOptions(options) {
|
|
1868
|
+
return {
|
|
1869
|
+
flow: options.deviceCode ? "device-code" : "auto",
|
|
1870
|
+
clientIdOverride: options.clientId,
|
|
1871
|
+
tenantIdOverride: options.tenantId,
|
|
1872
|
+
scopesOverride: options.scopes ? options.scopes.split(/\s+/).filter(Boolean) : void 0
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
function reportOAuthFailure(err) {
|
|
1876
|
+
const reason = typeof err === "object" && err !== null && "reason" in err ? err.reason : null;
|
|
1877
|
+
const msg = err.message;
|
|
1878
|
+
process.stderr.write(reason ? `OAuth login failed (${reason}): ${msg}
|
|
1879
|
+
` : `OAuth login failed: ${msg}
|
|
1880
|
+
`);
|
|
1881
|
+
const noDisplay = process.platform === "linux" && (!process.env.DISPLAY || process.env.DISPLAY.length === 0);
|
|
1882
|
+
if (noDisplay) {
|
|
1883
|
+
process.stderr.write("Tip: this host has no DISPLAY; pass --device-code to use the headless flow.\n");
|
|
1884
|
+
} else if (reason === "port-conflict") {
|
|
1885
|
+
process.stderr.write("Tip: another process is using the loopback callback port. Try again or pass --device-code.\n");
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
async function handleOAuthLogin(options) {
|
|
1889
|
+
const resolved = resolveOrg({ org: options.org });
|
|
1890
|
+
if (!resolved) {
|
|
1891
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1892
|
+
`);
|
|
1893
|
+
process.exitCode = 3;
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
const org = resolved.org;
|
|
1897
|
+
const confirm = await ensureOverwriteConfirmed(org);
|
|
1898
|
+
if (confirm === "aborted") {
|
|
1899
|
+
process.stderr.write("Aborted. Existing credential preserved.\n");
|
|
1900
|
+
process.exitCode = 1;
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (confirm === "unavailable") {
|
|
1904
|
+
process.exitCode = 4;
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
try {
|
|
1908
|
+
const result = await loginWithOAuth(org, buildOAuthLoginOptions(options));
|
|
1909
|
+
process.stdout.write(
|
|
1910
|
+
`Logged in to ${org} via OAuth (${result.flowUsed}). Account: ${result.accountId}; expires ${new Date(result.expiresAt * 1e3).toISOString()}.
|
|
1911
|
+
`
|
|
1912
|
+
);
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
reportOAuthFailure(err);
|
|
1915
|
+
process.exitCode = 1;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
async function handleAuthRoot(options) {
|
|
1919
|
+
const conflict = rejectMutuallyExclusive(options);
|
|
1920
|
+
if (conflict) {
|
|
1921
|
+
process.stderr.write(`${conflict}
|
|
1922
|
+
`);
|
|
1923
|
+
process.exitCode = 2;
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
await handlePatLogin(options);
|
|
1927
|
+
}
|
|
1928
|
+
async function handleLoginSubcommand(options) {
|
|
1929
|
+
const conflict = rejectMutuallyExclusive(options);
|
|
1930
|
+
if (conflict) {
|
|
1931
|
+
process.stderr.write(`${conflict}
|
|
1932
|
+
`);
|
|
1933
|
+
process.exitCode = 2;
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
if (options.fromStdin || options.usePat) {
|
|
1937
|
+
await handlePatLogin(options);
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
await handleOAuthLogin(options);
|
|
1941
|
+
}
|
|
1942
|
+
async function handleStatusJson() {
|
|
1943
|
+
try {
|
|
1944
|
+
const report = await status();
|
|
1945
|
+
process.stdout.write(`${JSON.stringify(report)}
|
|
1946
|
+
`);
|
|
1947
|
+
} catch (err) {
|
|
1948
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1949
|
+
process.stderr.write(`${err.message}
|
|
1950
|
+
`);
|
|
1951
|
+
process.exitCode = 4;
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
throw err;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1380
1957
|
async function handleStatus(options, org) {
|
|
1958
|
+
if (options.json) {
|
|
1959
|
+
let backend2;
|
|
1960
|
+
let value2;
|
|
1961
|
+
try {
|
|
1962
|
+
backend2 = probeBackend();
|
|
1963
|
+
value2 = await getPat(org);
|
|
1964
|
+
} catch (err) {
|
|
1965
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1966
|
+
process.stderr.write(`${err.message}
|
|
1967
|
+
`);
|
|
1968
|
+
process.exitCode = 4;
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
throw err;
|
|
1972
|
+
}
|
|
1973
|
+
const storedEvents2 = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
|
|
1974
|
+
const last2 = storedEvents2.at(-1);
|
|
1975
|
+
const updatedAt2 = last2?.ts ?? null;
|
|
1976
|
+
if (!value2) {
|
|
1977
|
+
process.stdout.write(
|
|
1978
|
+
`${JSON.stringify({ org, backend: backend2, stored: false, masked: null, updated_at: updatedAt2 })}
|
|
1979
|
+
`
|
|
1980
|
+
);
|
|
1981
|
+
process.exitCode = 1;
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
const masked2 = maskedDisplay(value2);
|
|
1985
|
+
process.stdout.write(
|
|
1986
|
+
`${JSON.stringify({ org, backend: backend2, stored: true, masked: masked2, updated_at: updatedAt2 })}
|
|
1987
|
+
`
|
|
1988
|
+
);
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1381
1991
|
let backend;
|
|
1382
1992
|
let value;
|
|
1383
1993
|
try {
|
|
@@ -1393,39 +2003,25 @@ async function handleStatus(options, org) {
|
|
|
1393
2003
|
throw err;
|
|
1394
2004
|
}
|
|
1395
2005
|
const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
|
|
1396
|
-
const last = storedEvents
|
|
2006
|
+
const last = storedEvents.at(-1);
|
|
1397
2007
|
const updatedAt = last?.ts ?? null;
|
|
1398
2008
|
if (!value) {
|
|
1399
|
-
|
|
1400
|
-
process.stdout.write(
|
|
1401
|
-
`${JSON.stringify({ org, backend, stored: false, masked: null, updated_at: updatedAt })}
|
|
1402
|
-
`
|
|
1403
|
-
);
|
|
1404
|
-
} else {
|
|
1405
|
-
process.stdout.write(`Organization: ${org}
|
|
2009
|
+
process.stdout.write(`Organization: ${org}
|
|
1406
2010
|
Backend: ${backend}
|
|
1407
2011
|
Stored: no
|
|
1408
2012
|
`);
|
|
1409
|
-
}
|
|
1410
2013
|
process.exitCode = 1;
|
|
1411
2014
|
return;
|
|
1412
2015
|
}
|
|
1413
2016
|
const masked = maskedDisplay(value);
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
`${JSON.stringify({ org, backend, stored: true, masked, updated_at: updatedAt })}
|
|
1417
|
-
`
|
|
1418
|
-
);
|
|
1419
|
-
} else {
|
|
1420
|
-
process.stdout.write(
|
|
1421
|
-
`Organization: ${org}
|
|
2017
|
+
process.stdout.write(
|
|
2018
|
+
`Organization: ${org}
|
|
1422
2019
|
Backend: ${backend}
|
|
1423
2020
|
Stored: yes
|
|
1424
2021
|
Identifier: ${masked}
|
|
1425
2022
|
` + (updatedAt ? `Last updated: ${updatedAt}
|
|
1426
2023
|
` : "")
|
|
1427
|
-
|
|
1428
|
-
}
|
|
2024
|
+
);
|
|
1429
2025
|
}
|
|
1430
2026
|
async function handleLogout(options, orgFromGlobal) {
|
|
1431
2027
|
if (options.all && orgFromGlobal) {
|
|
@@ -1434,9 +2030,16 @@ async function handleLogout(options, orgFromGlobal) {
|
|
|
1434
2030
|
return;
|
|
1435
2031
|
}
|
|
1436
2032
|
if (options.all) {
|
|
1437
|
-
let orgs;
|
|
1438
2033
|
try {
|
|
1439
|
-
|
|
2034
|
+
const result = await logout({ all: true });
|
|
2035
|
+
if (result.removed.length === 0) {
|
|
2036
|
+
process.stdout.write("No stored credentials to remove.\n");
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
for (const r of result.removed) {
|
|
2040
|
+
process.stdout.write(`Removed ${r.kind} credential for org ${r.org}.
|
|
2041
|
+
`);
|
|
2042
|
+
}
|
|
1440
2043
|
} catch (err) {
|
|
1441
2044
|
if (err instanceof CredentialStoreUnavailableError) {
|
|
1442
2045
|
process.stderr.write(`${err.message}
|
|
@@ -1444,22 +2047,9 @@ async function handleLogout(options, orgFromGlobal) {
|
|
|
1444
2047
|
process.exitCode = 4;
|
|
1445
2048
|
return;
|
|
1446
2049
|
}
|
|
1447
|
-
|
|
1448
|
-
}
|
|
1449
|
-
if (orgs.length === 0) {
|
|
1450
|
-
process.stdout.write("No stored PATs to remove.\n");
|
|
1451
|
-
return;
|
|
1452
|
-
}
|
|
1453
|
-
for (const org of orgs) {
|
|
1454
|
-
try {
|
|
1455
|
-
await deletePat(org);
|
|
1456
|
-
process.stdout.write(`PAT removed for org ${org}.
|
|
1457
|
-
`);
|
|
1458
|
-
} catch (err) {
|
|
1459
|
-
process.stderr.write(`Failed to remove PAT for org ${org}: ${err.message}
|
|
2050
|
+
process.stderr.write(`Failed to remove credentials: ${err.message}
|
|
1460
2051
|
`);
|
|
1461
|
-
|
|
1462
|
-
}
|
|
2052
|
+
process.exitCode = 1;
|
|
1463
2053
|
}
|
|
1464
2054
|
return;
|
|
1465
2055
|
}
|
|
@@ -1473,10 +2063,10 @@ async function handleLogout(options, orgFromGlobal) {
|
|
|
1473
2063
|
try {
|
|
1474
2064
|
const removed = await deletePat(resolved.org);
|
|
1475
2065
|
if (removed) {
|
|
1476
|
-
process.stdout.write(`
|
|
2066
|
+
process.stdout.write(`Credential removed for org ${resolved.org}.
|
|
1477
2067
|
`);
|
|
1478
2068
|
} else {
|
|
1479
|
-
process.stdout.write(`No stored
|
|
2069
|
+
process.stdout.write(`No stored credential for org ${resolved.org}.
|
|
1480
2070
|
`);
|
|
1481
2071
|
}
|
|
1482
2072
|
} catch (err) {
|
|
@@ -1491,14 +2081,70 @@ async function handleLogout(options, orgFromGlobal) {
|
|
|
1491
2081
|
}
|
|
1492
2082
|
function createAuthCommand() {
|
|
1493
2083
|
const command = new Command3("auth");
|
|
1494
|
-
command.description(
|
|
1495
|
-
|
|
1496
|
-
|
|
2084
|
+
command.description(
|
|
2085
|
+
"Manage Azure DevOps authentication. Use `azdo auth login` for OAuth (default); the bare `azdo auth` form preserves the legacy PAT-prompt path for back-compat."
|
|
2086
|
+
);
|
|
2087
|
+
command.option("--org <name>", "Azure DevOps organization (flag wins over auto-detect / config)").option("--from-stdin", "read PAT from stdin instead of prompting (implies --use-pat)", false).option("--no-browser", "do not open the Azure DevOps PAT page in a browser (PAT path only)").option("--use-pat", "use Personal Access Token instead of OAuth (legacy path)", false).option("--device-code", "use OAuth device-code flow (headless hosts; OAuth only)", false).option("--client-id <id>", "override the default OAuth client id (FR-013 override path)").option("--tenant-id <id>", "override the default OAuth tenant id (default: common)").option("--scopes <scopes>", "space-separated OAuth scope override (advanced; default mirrors PAT scope table)");
|
|
2088
|
+
command.addHelpText(
|
|
2089
|
+
"after",
|
|
2090
|
+
`
|
|
2091
|
+
Default flow (OAuth, browser-based):
|
|
2092
|
+
azdo auth login --org <name>
|
|
2093
|
+
\u2192 opens the default browser for OAuth (Microsoft Entra v2 + PKCE).
|
|
2094
|
+
|
|
2095
|
+
Headless / no-browser:
|
|
2096
|
+
azdo auth login --org <name> --device-code
|
|
2097
|
+
|
|
2098
|
+
PAT path (legacy, opt-in):
|
|
2099
|
+
azdo auth login --org <name> --use-pat
|
|
2100
|
+
azdo auth --org <name> # back-compat alias of the above
|
|
2101
|
+
|
|
2102
|
+
OAuth scope set \u2014 shipped first-party client (default install):
|
|
2103
|
+
${firstPartyShippedScopes().join("\n ")}
|
|
2104
|
+
(uses ${AZDO_RESOURCE_ID}/.default \u2014 per-scope consent is unavailable
|
|
2105
|
+
against a client we do not own; .default grants the VS client's
|
|
2106
|
+
pre-authorized AzDO permissions in one step.)
|
|
2107
|
+
|
|
2108
|
+
OAuth scope set \u2014 self-registered apps (--client-id / AZDO_OAUTH_CLIENT_ID):
|
|
2109
|
+
${defaultScopes().join("\n ")}
|
|
2110
|
+
(FR-016, mirrors the PAT scope table \u2014 see docs/oauth-app-registration.md)
|
|
2111
|
+
|
|
2112
|
+
For self-registered OAuth apps (locked-down tenants), see docs/oauth-app-registration.md
|
|
2113
|
+
\u2014 that same guide is the maintainer reference for the project's shared client id.
|
|
2114
|
+
|
|
2115
|
+
Note: stored credentials may coexist as 'pat' or 'oauth' across orgs (FR-007).
|
|
2116
|
+
Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
|
|
2117
|
+
\`azdo auth login\` is the spec-canonical name and defaults to OAuth.`
|
|
2118
|
+
).action(async (options) => {
|
|
1497
2119
|
await handleAuthRoot(options);
|
|
1498
2120
|
});
|
|
1499
|
-
const
|
|
2121
|
+
const loginCmd = command.command("login").description("Authenticate against Azure DevOps (OAuth default; --use-pat for PAT)").option("--org <name>", "Azure DevOps organization (defaults: git remote \u2192 config)");
|
|
2122
|
+
loginCmd.action(async () => {
|
|
2123
|
+
const merged = loginCmd.optsWithGlobals();
|
|
2124
|
+
await handleLoginSubcommand(merged);
|
|
2125
|
+
});
|
|
2126
|
+
const statusCmd = command.command("status").description("Report stored credentials (kind, org, account/expiry, backend) \u2014 never the token").option("--json", "emit JSON", false);
|
|
1500
2127
|
statusCmd.action(async (options) => {
|
|
1501
2128
|
const globals = statusCmd.optsWithGlobals();
|
|
2129
|
+
if (!globals.org) {
|
|
2130
|
+
if (options.json) {
|
|
2131
|
+
await handleStatusJson();
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
const report = await status();
|
|
2135
|
+
if (report.orgs.length === 0) {
|
|
2136
|
+
process.stdout.write("No stored credentials.\n");
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
for (const e of report.orgs) {
|
|
2140
|
+
const expiry = e.expiresAt ? new Date(e.expiresAt * 1e3).toISOString() : "n/a";
|
|
2141
|
+
process.stdout.write(
|
|
2142
|
+
`${e.org} ${e.kind} ${e.accountId ?? ""} ${expiry}
|
|
2143
|
+
`
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
1502
2148
|
const resolved = resolveOrg({ org: globals.org });
|
|
1503
2149
|
if (!resolved) {
|
|
1504
2150
|
process.stderr.write(`${formatResolutionError()}
|
|
@@ -1508,11 +2154,34 @@ function createAuthCommand() {
|
|
|
1508
2154
|
}
|
|
1509
2155
|
await handleStatus(options, resolved.org);
|
|
1510
2156
|
});
|
|
1511
|
-
|
|
2157
|
+
statusCmd.addHelpText(
|
|
2158
|
+
"after",
|
|
2159
|
+
"\nStored credentials may be of kind `pat` or `oauth` and may coexist across orgs (FR-007).\n"
|
|
2160
|
+
);
|
|
2161
|
+
const logoutCmd = command.command("logout").description("Remove the stored credential for an org (or all orgs with --all)").option("--all", "remove every stored credential (PAT and OAuth)", false);
|
|
1512
2162
|
logoutCmd.action(async (options) => {
|
|
1513
2163
|
const globals = logoutCmd.optsWithGlobals();
|
|
1514
2164
|
await handleLogout(options, globals.org);
|
|
1515
2165
|
});
|
|
2166
|
+
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);
|
|
2167
|
+
diagnoseCmd.action(async (options) => {
|
|
2168
|
+
const globals = diagnoseCmd.optsWithGlobals();
|
|
2169
|
+
const orgName = options.org ?? globals.org;
|
|
2170
|
+
const resolved = resolveOrg({ org: orgName });
|
|
2171
|
+
if (!resolved) {
|
|
2172
|
+
process.stderr.write(`${formatResolutionError()}
|
|
2173
|
+
`);
|
|
2174
|
+
process.exitCode = 1;
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
const report = await diagnoseAuth(resolved.org, options.project ?? null, resolveAuthCredential);
|
|
2178
|
+
const output = formatDiagnosticReport(report, options.json ?? false);
|
|
2179
|
+
process.stdout.write(`${output}
|
|
2180
|
+
`);
|
|
2181
|
+
if (report.connectivityStatus === "failed") {
|
|
2182
|
+
process.exitCode = 1;
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
1516
2185
|
return command;
|
|
1517
2186
|
}
|
|
1518
2187
|
|
|
@@ -1525,21 +2194,50 @@ function formatConfigValue(value, unsetFallback = "") {
|
|
|
1525
2194
|
}
|
|
1526
2195
|
return Array.isArray(value) ? value.join(",") : value;
|
|
1527
2196
|
}
|
|
2197
|
+
function buildConfigListEntries(cfg) {
|
|
2198
|
+
const entries = SETTINGS.map((s) => ({
|
|
2199
|
+
scope: "default",
|
|
2200
|
+
key: s.key,
|
|
2201
|
+
value: cfg[s.key]
|
|
2202
|
+
}));
|
|
2203
|
+
for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
|
|
2204
|
+
for (const [k, v] of Object.entries(scope)) {
|
|
2205
|
+
entries.push({ scope: orgName, key: k, value: v });
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
return entries;
|
|
2209
|
+
}
|
|
1528
2210
|
function writeConfigList(cfg) {
|
|
1529
2211
|
const keyWidth = 10;
|
|
1530
2212
|
const valueWidth = 30;
|
|
2213
|
+
const scopeWidth = 12;
|
|
2214
|
+
process.stdout.write(
|
|
2215
|
+
`${"scope".padEnd(scopeWidth)}${"key".padEnd(keyWidth)}${"value".padEnd(valueWidth)}description
|
|
2216
|
+
`
|
|
2217
|
+
);
|
|
1531
2218
|
for (const setting of SETTINGS) {
|
|
1532
2219
|
const raw = cfg[setting.key];
|
|
1533
2220
|
const value = formatConfigValue(raw, "(not set)");
|
|
1534
2221
|
const marker = raw === void 0 && setting.required ? " *" : "";
|
|
1535
2222
|
process.stdout.write(
|
|
1536
|
-
`${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
2223
|
+
`${"default".padEnd(scopeWidth)}${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
1537
2224
|
`
|
|
1538
2225
|
);
|
|
1539
2226
|
}
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
|
|
2227
|
+
for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
|
|
2228
|
+
const orgScope = scope;
|
|
2229
|
+
const scopedSettings = Object.entries(orgScope);
|
|
2230
|
+
for (const [k, v] of scopedSettings) {
|
|
2231
|
+
const value = formatConfigValue(v, "(not set)");
|
|
2232
|
+
process.stdout.write(
|
|
2233
|
+
`${orgName.padEnd(scopeWidth)}${k.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}
|
|
2234
|
+
`
|
|
2235
|
+
);
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
|
|
2239
|
+
if (hasUnset) {
|
|
2240
|
+
process.stdout.write(
|
|
1543
2241
|
'\n* = required but not configured. Run "azdo config wizard" to set up.\n'
|
|
1544
2242
|
);
|
|
1545
2243
|
}
|
|
@@ -1580,17 +2278,22 @@ function createConfigCommand() {
|
|
|
1580
2278
|
const config = new Command4("config");
|
|
1581
2279
|
config.description("Manage CLI settings");
|
|
1582
2280
|
const set = new Command4("set");
|
|
1583
|
-
set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields)").argument("<value>", "setting value").option("--json", "output in JSON format").action((key, value, options) => {
|
|
2281
|
+
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) => {
|
|
1584
2282
|
try {
|
|
1585
|
-
|
|
2283
|
+
if (options.org) {
|
|
2284
|
+
setOrgScopedValue(options.org, key, value);
|
|
2285
|
+
} else {
|
|
2286
|
+
setConfigValue(key, value);
|
|
2287
|
+
}
|
|
1586
2288
|
if (options.json) {
|
|
1587
|
-
const output = { key, value };
|
|
2289
|
+
const output = { key, value, scope: options.org ?? "default" };
|
|
1588
2290
|
if (key === "fields") {
|
|
1589
2291
|
output.value = value.split(",").map((s) => s.trim());
|
|
1590
2292
|
}
|
|
1591
2293
|
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1592
2294
|
} else {
|
|
1593
|
-
|
|
2295
|
+
const scopeTag = options.org ? ` (org: ${options.org})` : "";
|
|
2296
|
+
process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
|
|
1594
2297
|
`);
|
|
1595
2298
|
}
|
|
1596
2299
|
} catch (err) {
|
|
@@ -1601,12 +2304,12 @@ function createConfigCommand() {
|
|
|
1601
2304
|
}
|
|
1602
2305
|
});
|
|
1603
2306
|
const get = new Command4("get");
|
|
1604
|
-
get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
2307
|
+
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) => {
|
|
1605
2308
|
try {
|
|
1606
|
-
const value = getConfigValue(key);
|
|
2309
|
+
const value = options.org ? getOrgScopedValue(options.org, key) : getConfigValue(key);
|
|
1607
2310
|
if (options.json) {
|
|
1608
2311
|
process.stdout.write(
|
|
1609
|
-
JSON.stringify({ key, value: value ?? null }) + "\n"
|
|
2312
|
+
JSON.stringify({ key, value: value ?? null, scope: options.org ?? "default" }) + "\n"
|
|
1610
2313
|
);
|
|
1611
2314
|
} else if (value === void 0) {
|
|
1612
2315
|
process.stdout.write(`Setting "${key}" is not configured.
|
|
@@ -1614,7 +2317,7 @@ function createConfigCommand() {
|
|
|
1614
2317
|
} else if (Array.isArray(value)) {
|
|
1615
2318
|
process.stdout.write(value.join(",") + "\n");
|
|
1616
2319
|
} else {
|
|
1617
|
-
process.stdout.write(value + "\n");
|
|
2320
|
+
process.stdout.write(String(value) + "\n");
|
|
1618
2321
|
}
|
|
1619
2322
|
} catch (err) {
|
|
1620
2323
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1627,19 +2330,25 @@ function createConfigCommand() {
|
|
|
1627
2330
|
list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
|
|
1628
2331
|
const cfg = loadConfig();
|
|
1629
2332
|
if (options.json) {
|
|
1630
|
-
|
|
2333
|
+
const entries = buildConfigListEntries(cfg);
|
|
2334
|
+
process.stdout.write(JSON.stringify(entries) + "\n");
|
|
1631
2335
|
return;
|
|
1632
2336
|
}
|
|
1633
2337
|
writeConfigList(cfg);
|
|
1634
2338
|
});
|
|
1635
2339
|
const unset = new Command4("unset");
|
|
1636
|
-
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
2340
|
+
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) => {
|
|
1637
2341
|
try {
|
|
1638
|
-
|
|
2342
|
+
if (options.org) {
|
|
2343
|
+
unsetOrgScopedValue(options.org, key);
|
|
2344
|
+
} else {
|
|
2345
|
+
unsetConfigValue(key);
|
|
2346
|
+
}
|
|
1639
2347
|
if (options.json) {
|
|
1640
|
-
process.stdout.write(JSON.stringify({ key, unset: true }) + "\n");
|
|
2348
|
+
process.stdout.write(JSON.stringify({ key, unset: true, scope: options.org ?? "default" }) + "\n");
|
|
1641
2349
|
} else {
|
|
1642
|
-
|
|
2350
|
+
const scopeTag = options.org ? ` (org: ${options.org})` : "";
|
|
2351
|
+
process.stdout.write(`Unset "${key}"${scopeTag}
|
|
1643
2352
|
`);
|
|
1644
2353
|
}
|
|
1645
2354
|
} catch (err) {
|
|
@@ -1671,10 +2380,52 @@ function createConfigCommand() {
|
|
|
1671
2380
|
rl.close();
|
|
1672
2381
|
process.stderr.write("Configuration complete!\n");
|
|
1673
2382
|
});
|
|
2383
|
+
const orgCopy = new Command4("org-copy");
|
|
2384
|
+
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) => {
|
|
2385
|
+
try {
|
|
2386
|
+
copyOrgScope(from, to, options.force ?? false);
|
|
2387
|
+
process.stdout.write(`Copied scope "${from}" to "${to}"
|
|
2388
|
+
`);
|
|
2389
|
+
} catch (err) {
|
|
2390
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2391
|
+
process.stderr.write(`Error: ${message}
|
|
2392
|
+
`);
|
|
2393
|
+
process.exit(1);
|
|
2394
|
+
}
|
|
2395
|
+
});
|
|
2396
|
+
const orgMove = new Command4("org-move");
|
|
2397
|
+
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) => {
|
|
2398
|
+
try {
|
|
2399
|
+
moveOrgScope(from, to, options.force ?? false);
|
|
2400
|
+
process.stdout.write(`Moved scope "${from}" to "${to}"
|
|
2401
|
+
`);
|
|
2402
|
+
} catch (err) {
|
|
2403
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2404
|
+
process.stderr.write(`Error: ${message}
|
|
2405
|
+
`);
|
|
2406
|
+
process.exit(1);
|
|
2407
|
+
}
|
|
2408
|
+
});
|
|
2409
|
+
const orgDelete = new Command4("org-delete");
|
|
2410
|
+
orgDelete.description("Delete an org-scoped configuration").argument("<name>", "org name").action((name) => {
|
|
2411
|
+
try {
|
|
2412
|
+
deleteOrgScope(name);
|
|
2413
|
+
process.stdout.write(`Deleted scope "${name}"
|
|
2414
|
+
`);
|
|
2415
|
+
} catch (err) {
|
|
2416
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2417
|
+
process.stderr.write(`Error: ${message}
|
|
2418
|
+
`);
|
|
2419
|
+
process.exit(1);
|
|
2420
|
+
}
|
|
2421
|
+
});
|
|
1674
2422
|
config.addCommand(set);
|
|
1675
2423
|
config.addCommand(get);
|
|
1676
2424
|
config.addCommand(list);
|
|
1677
2425
|
config.addCommand(unset);
|
|
2426
|
+
config.addCommand(orgCopy);
|
|
2427
|
+
config.addCommand(orgMove);
|
|
2428
|
+
config.addCommand(orgDelete);
|
|
1678
2429
|
config.addCommand(wizard);
|
|
1679
2430
|
return config;
|
|
1680
2431
|
}
|
|
@@ -1690,11 +2441,11 @@ function createSetStateCommand() {
|
|
|
1690
2441
|
let context;
|
|
1691
2442
|
try {
|
|
1692
2443
|
context = resolveContext(options);
|
|
1693
|
-
const credential = await
|
|
2444
|
+
const credential = await requireAuthCredential(context.org);
|
|
1694
2445
|
const operations = [
|
|
1695
2446
|
{ op: "add", path: "/fields/System.State", value: state }
|
|
1696
2447
|
];
|
|
1697
|
-
const result = await updateWorkItem(context, id, credential
|
|
2448
|
+
const result = await updateWorkItem(context, id, credential, "System.State", operations);
|
|
1698
2449
|
if (options.json) {
|
|
1699
2450
|
process.stdout.write(
|
|
1700
2451
|
JSON.stringify({
|
|
@@ -1740,12 +2491,12 @@ function createAssignCommand() {
|
|
|
1740
2491
|
let context;
|
|
1741
2492
|
try {
|
|
1742
2493
|
context = resolveContext(options);
|
|
1743
|
-
const credential = await
|
|
2494
|
+
const credential = await requireAuthCredential(context.org);
|
|
1744
2495
|
const value = options.unassign ? "" : name;
|
|
1745
2496
|
const operations = [
|
|
1746
2497
|
{ op: "add", path: "/fields/System.AssignedTo", value }
|
|
1747
2498
|
];
|
|
1748
|
-
const result = await updateWorkItem(context, id, credential
|
|
2499
|
+
const result = await updateWorkItem(context, id, credential, "System.AssignedTo", operations);
|
|
1749
2500
|
if (options.json) {
|
|
1750
2501
|
process.stdout.write(
|
|
1751
2502
|
JSON.stringify({
|
|
@@ -1780,11 +2531,11 @@ function createSetFieldCommand() {
|
|
|
1780
2531
|
let context;
|
|
1781
2532
|
try {
|
|
1782
2533
|
context = resolveContext(options);
|
|
1783
|
-
const credential = await
|
|
2534
|
+
const credential = await requireAuthCredential(context.org);
|
|
1784
2535
|
const operations = [
|
|
1785
2536
|
{ op: "add", path: `/fields/${field}`, value }
|
|
1786
2537
|
];
|
|
1787
|
-
const result = await updateWorkItem(context, id, credential
|
|
2538
|
+
const result = await updateWorkItem(context, id, credential, field, operations);
|
|
1788
2539
|
if (options.json) {
|
|
1789
2540
|
process.stdout.write(
|
|
1790
2541
|
JSON.stringify({
|
|
@@ -1811,20 +2562,30 @@ function createSetFieldCommand() {
|
|
|
1811
2562
|
import { Command as Command8 } from "commander";
|
|
1812
2563
|
function createGetMdFieldCommand() {
|
|
1813
2564
|
const command = new Command8("get-md-field");
|
|
1814
|
-
command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project")
|
|
2565
|
+
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");
|
|
2566
|
+
addImageDownloadOptions(command);
|
|
2567
|
+
command.action(
|
|
1815
2568
|
async (idStr, field, options) => {
|
|
1816
2569
|
const id = parseWorkItemId(idStr);
|
|
1817
2570
|
validateOrgProjectPair(options);
|
|
2571
|
+
const imageOptions = resolveImageDownloadOptionsOrExit(options);
|
|
1818
2572
|
let context;
|
|
1819
2573
|
try {
|
|
1820
2574
|
context = resolveContext(options);
|
|
1821
|
-
const credential = await
|
|
1822
|
-
const value = await getWorkItemFieldValue(context, id, credential
|
|
2575
|
+
const credential = await requireAuthCredential(context.org);
|
|
2576
|
+
const value = await getWorkItemFieldValue(context, id, credential, field);
|
|
1823
2577
|
if (value === null) {
|
|
1824
2578
|
process.stdout.write("\n");
|
|
1825
2579
|
} else {
|
|
1826
2580
|
process.stdout.write(toMarkdown(value) + "\n");
|
|
1827
2581
|
}
|
|
2582
|
+
if (imageOptions.enabled) {
|
|
2583
|
+
await runImageDownload(
|
|
2584
|
+
[{ content: value ?? "", field }],
|
|
2585
|
+
{ workItemId: id, options: imageOptions },
|
|
2586
|
+
credential
|
|
2587
|
+
);
|
|
2588
|
+
}
|
|
1828
2589
|
} catch (err) {
|
|
1829
2590
|
handleCommandError(err, id, context, "read");
|
|
1830
2591
|
}
|
|
@@ -1834,7 +2595,7 @@ function createGetMdFieldCommand() {
|
|
|
1834
2595
|
}
|
|
1835
2596
|
|
|
1836
2597
|
// src/commands/set-md-field.ts
|
|
1837
|
-
import { existsSync as
|
|
2598
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
1838
2599
|
import { Command as Command9 } from "commander";
|
|
1839
2600
|
function fail(message) {
|
|
1840
2601
|
process.stderr.write(`Error: ${message}
|
|
@@ -1854,7 +2615,7 @@ function resolveContent(inlineContent, options) {
|
|
|
1854
2615
|
return null;
|
|
1855
2616
|
}
|
|
1856
2617
|
function readFileContent(filePath) {
|
|
1857
|
-
if (!
|
|
2618
|
+
if (!existsSync3(filePath)) {
|
|
1858
2619
|
fail(`File not found: ${filePath}`);
|
|
1859
2620
|
}
|
|
1860
2621
|
try {
|
|
@@ -1906,12 +2667,12 @@ function createSetMdFieldCommand() {
|
|
|
1906
2667
|
let context;
|
|
1907
2668
|
try {
|
|
1908
2669
|
context = resolveContext(options);
|
|
1909
|
-
const credential = await
|
|
2670
|
+
const credential = await requireAuthCredential(context.org);
|
|
1910
2671
|
const operations = [
|
|
1911
2672
|
{ op: "add", path: `/fields/${field}`, value: content },
|
|
1912
2673
|
{ op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
|
|
1913
2674
|
];
|
|
1914
|
-
const result = await updateWorkItem(context, id, credential
|
|
2675
|
+
const result = await updateWorkItem(context, id, credential, field, operations);
|
|
1915
2676
|
formatOutput(result, options, field);
|
|
1916
2677
|
} catch (err) {
|
|
1917
2678
|
handleCommandError(err, id, context, "write");
|
|
@@ -1922,7 +2683,7 @@ function createSetMdFieldCommand() {
|
|
|
1922
2683
|
}
|
|
1923
2684
|
|
|
1924
2685
|
// src/commands/upsert.ts
|
|
1925
|
-
import { existsSync as
|
|
2686
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
|
|
1926
2687
|
import { Command as Command10 } from "commander";
|
|
1927
2688
|
|
|
1928
2689
|
// src/services/task-document.ts
|
|
@@ -2086,7 +2847,7 @@ function loadSourceContent(options) {
|
|
|
2086
2847
|
return { content: options.content };
|
|
2087
2848
|
}
|
|
2088
2849
|
const filePath = options.file;
|
|
2089
|
-
if (!
|
|
2850
|
+
if (!existsSync4(filePath)) {
|
|
2090
2851
|
fail2(`File not found: ${filePath}`);
|
|
2091
2852
|
}
|
|
2092
2853
|
try {
|
|
@@ -2222,15 +2983,15 @@ function createUpsertCommand() {
|
|
|
2222
2983
|
ensureTitleForCreate(document.fields);
|
|
2223
2984
|
}
|
|
2224
2985
|
const operations = toPatchOperations(document.fields, action);
|
|
2225
|
-
const credential = await
|
|
2986
|
+
const credential = await requireAuthCredential(context.org);
|
|
2226
2987
|
let writeResult;
|
|
2227
2988
|
if (action === "created") {
|
|
2228
|
-
writeResult = await createWorkItem(context, createType, credential
|
|
2989
|
+
writeResult = await createWorkItem(context, createType, credential, operations);
|
|
2229
2990
|
} else {
|
|
2230
2991
|
if (id === void 0) {
|
|
2231
2992
|
fail2("Work item ID is required for updates.");
|
|
2232
2993
|
}
|
|
2233
|
-
writeResult = await applyWorkItemPatch(context, id, credential
|
|
2994
|
+
writeResult = await applyWorkItemPatch(context, id, credential, operations);
|
|
2234
2995
|
}
|
|
2235
2996
|
const result = buildUpsertResult(
|
|
2236
2997
|
action,
|
|
@@ -2288,8 +3049,8 @@ function createListFieldsCommand() {
|
|
|
2288
3049
|
let context;
|
|
2289
3050
|
try {
|
|
2290
3051
|
context = resolveContext(options);
|
|
2291
|
-
const credential = await
|
|
2292
|
-
const fields = await getWorkItemFields(context, id, credential
|
|
3052
|
+
const credential = await requireAuthCredential(context.org);
|
|
3053
|
+
const fields = await getWorkItemFields(context, id, credential);
|
|
2293
3054
|
if (options.json) {
|
|
2294
3055
|
process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
|
|
2295
3056
|
} else {
|
|
@@ -2331,6 +3092,31 @@ function buildPullRequestStatusesUrl(context, repo, prId) {
|
|
|
2331
3092
|
url.searchParams.set("api-version", "7.1");
|
|
2332
3093
|
return url;
|
|
2333
3094
|
}
|
|
3095
|
+
function buildProjectUrl(context) {
|
|
3096
|
+
const url = new URL(
|
|
3097
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/projects/${encodeURIComponent(context.project)}`
|
|
3098
|
+
);
|
|
3099
|
+
url.searchParams.set("api-version", "7.1");
|
|
3100
|
+
return url;
|
|
3101
|
+
}
|
|
3102
|
+
function buildPolicyEvaluationsUrl(context, projectId, prId) {
|
|
3103
|
+
const url = new URL(
|
|
3104
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/policy/evaluations`
|
|
3105
|
+
);
|
|
3106
|
+
url.searchParams.set("api-version", "7.1");
|
|
3107
|
+
url.searchParams.set("artifactId", `vstfs:///CodeReview/CodeReviewId/${projectId}/${prId}`);
|
|
3108
|
+
return url;
|
|
3109
|
+
}
|
|
3110
|
+
function buildPullRequestBuildsUrl(context, prId) {
|
|
3111
|
+
const url = new URL(
|
|
3112
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/build/builds`
|
|
3113
|
+
);
|
|
3114
|
+
url.searchParams.set("branchName", `refs/pull/${prId}/merge`);
|
|
3115
|
+
url.searchParams.set("queryOrder", "queueTimeDescending");
|
|
3116
|
+
url.searchParams.set("$top", "50");
|
|
3117
|
+
url.searchParams.set("api-version", "7.1");
|
|
3118
|
+
return url;
|
|
3119
|
+
}
|
|
2334
3120
|
function mapPullRequest(repo, pullRequest) {
|
|
2335
3121
|
return {
|
|
2336
3122
|
id: pullRequest.pullRequestId,
|
|
@@ -2343,9 +3129,9 @@ function mapPullRequest(repo, pullRequest) {
|
|
|
2343
3129
|
url: pullRequest._links?.web?.href ?? null
|
|
2344
3130
|
};
|
|
2345
3131
|
}
|
|
2346
|
-
function mapPullRequestCheckName(
|
|
2347
|
-
const genre =
|
|
2348
|
-
const name =
|
|
3132
|
+
function mapPullRequestCheckName(status2) {
|
|
3133
|
+
const genre = status2.context?.genre?.trim();
|
|
3134
|
+
const name = status2.context?.name?.trim();
|
|
2349
3135
|
if (genre && name) {
|
|
2350
3136
|
return `${genre}/${name}`;
|
|
2351
3137
|
}
|
|
@@ -2355,21 +3141,80 @@ function mapPullRequestCheckName(status) {
|
|
|
2355
3141
|
if (genre) {
|
|
2356
3142
|
return genre;
|
|
2357
3143
|
}
|
|
2358
|
-
return `Status #${
|
|
3144
|
+
return `Status #${status2.id}`;
|
|
3145
|
+
}
|
|
3146
|
+
function mapPullRequestCheck(status2) {
|
|
3147
|
+
if (status2.state === "notApplicable" || status2.state === "notSet") {
|
|
3148
|
+
return null;
|
|
3149
|
+
}
|
|
3150
|
+
return {
|
|
3151
|
+
id: status2.id,
|
|
3152
|
+
state: status2.state,
|
|
3153
|
+
name: mapPullRequestCheckName(status2),
|
|
3154
|
+
description: status2.description ?? null,
|
|
3155
|
+
targetUrl: status2.targetUrl ?? null,
|
|
3156
|
+
createdBy: status2.createdBy?.displayName ?? null,
|
|
3157
|
+
createdAt: status2.creationDate ?? null,
|
|
3158
|
+
updatedAt: status2.updatedDate ?? null,
|
|
3159
|
+
source: "status"
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3162
|
+
function mapPolicyEvaluationState(status2) {
|
|
3163
|
+
switch (status2) {
|
|
3164
|
+
case "approved":
|
|
3165
|
+
return "succeeded";
|
|
3166
|
+
case "rejected":
|
|
3167
|
+
return "failed";
|
|
3168
|
+
case "running":
|
|
3169
|
+
case "queued":
|
|
3170
|
+
return "pending";
|
|
3171
|
+
case "notApplicable":
|
|
3172
|
+
case "notSet":
|
|
3173
|
+
case void 0:
|
|
3174
|
+
return null;
|
|
3175
|
+
default:
|
|
3176
|
+
return status2;
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
function mapBuildToCheckState(build) {
|
|
3180
|
+
if (build.status !== "completed") {
|
|
3181
|
+
return "pending";
|
|
3182
|
+
}
|
|
3183
|
+
switch (build.result) {
|
|
3184
|
+
case "succeeded":
|
|
3185
|
+
case "partiallySucceeded":
|
|
3186
|
+
return "succeeded";
|
|
3187
|
+
case "failed":
|
|
3188
|
+
return "failed";
|
|
3189
|
+
case "canceled":
|
|
3190
|
+
return "error";
|
|
3191
|
+
default:
|
|
3192
|
+
return "pending";
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
function mapPolicyEvaluationName(evaluation) {
|
|
3196
|
+
const display = evaluation.configuration?.settings?.displayName?.trim() || evaluation.configuration?.type?.displayName?.trim();
|
|
3197
|
+
if (display) {
|
|
3198
|
+
return display;
|
|
3199
|
+
}
|
|
3200
|
+
return `Policy ${evaluation.configuration?.id ?? evaluation.evaluationId ?? "?"}`;
|
|
2359
3201
|
}
|
|
2360
|
-
function
|
|
2361
|
-
|
|
3202
|
+
function mapPolicyEvaluationCheck(evaluation) {
|
|
3203
|
+
const state = mapPolicyEvaluationState(evaluation.status);
|
|
3204
|
+
if (state === null) {
|
|
2362
3205
|
return null;
|
|
2363
3206
|
}
|
|
2364
3207
|
return {
|
|
2365
|
-
id:
|
|
2366
|
-
state
|
|
2367
|
-
name:
|
|
2368
|
-
description:
|
|
2369
|
-
targetUrl:
|
|
2370
|
-
createdBy:
|
|
2371
|
-
createdAt:
|
|
2372
|
-
updatedAt:
|
|
3208
|
+
id: evaluation.configuration?.id ?? 0,
|
|
3209
|
+
state,
|
|
3210
|
+
name: mapPolicyEvaluationName(evaluation),
|
|
3211
|
+
description: null,
|
|
3212
|
+
targetUrl: null,
|
|
3213
|
+
createdBy: null,
|
|
3214
|
+
createdAt: null,
|
|
3215
|
+
updatedAt: null,
|
|
3216
|
+
source: "policy",
|
|
3217
|
+
isBlocking: evaluation.configuration?.isBlocking ?? null
|
|
2373
3218
|
};
|
|
2374
3219
|
}
|
|
2375
3220
|
function mapComment(comment) {
|
|
@@ -2389,24 +3234,28 @@ function mapThread(thread) {
|
|
|
2389
3234
|
if (comments.length === 0) {
|
|
2390
3235
|
return null;
|
|
2391
3236
|
}
|
|
3237
|
+
const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
|
|
2392
3238
|
return {
|
|
2393
3239
|
id: thread.id,
|
|
2394
|
-
status: thread.status,
|
|
3240
|
+
status: thread.status ?? "unknown",
|
|
2395
3241
|
threadContext: thread.threadContext?.filePath ?? null,
|
|
3242
|
+
line,
|
|
2396
3243
|
comments
|
|
2397
3244
|
};
|
|
2398
3245
|
}
|
|
2399
3246
|
function toActiveCommentThread(thread) {
|
|
3247
|
+
const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
|
|
2400
3248
|
return {
|
|
2401
3249
|
id: thread.id,
|
|
2402
|
-
status: thread.status,
|
|
3250
|
+
status: thread.status ?? "unknown",
|
|
2403
3251
|
threadContext: thread.threadContext?.filePath ?? null,
|
|
3252
|
+
line,
|
|
2404
3253
|
comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
|
|
2405
3254
|
};
|
|
2406
3255
|
}
|
|
2407
3256
|
var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
|
|
2408
|
-
function isThreadResolved(
|
|
2409
|
-
return RESOLVED_THREAD_STATUSES.has(
|
|
3257
|
+
function isThreadResolved(status2) {
|
|
3258
|
+
return RESOLVED_THREAD_STATUSES.has(status2);
|
|
2410
3259
|
}
|
|
2411
3260
|
async function readJsonResponse(response) {
|
|
2412
3261
|
if (!response.ok) {
|
|
@@ -2414,7 +3263,7 @@ async function readJsonResponse(response) {
|
|
|
2414
3263
|
}
|
|
2415
3264
|
return response.json();
|
|
2416
3265
|
}
|
|
2417
|
-
async function patchThreadStatus(context, repo,
|
|
3266
|
+
async function patchThreadStatus(context, repo, cred, prId, threadId, status2) {
|
|
2418
3267
|
const url = new URL(
|
|
2419
3268
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
|
|
2420
3269
|
);
|
|
@@ -2422,41 +3271,74 @@ async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
|
|
|
2422
3271
|
const response = await fetchWithErrors(url.toString(), {
|
|
2423
3272
|
method: "PATCH",
|
|
2424
3273
|
headers: {
|
|
2425
|
-
...authHeaders(
|
|
3274
|
+
...authHeaders(cred),
|
|
2426
3275
|
"Content-Type": "application/json"
|
|
2427
3276
|
},
|
|
2428
|
-
body: JSON.stringify({ status })
|
|
3277
|
+
body: JSON.stringify({ status: status2 })
|
|
2429
3278
|
});
|
|
2430
3279
|
const data = await readJsonResponse(response);
|
|
2431
3280
|
return toActiveCommentThread(data);
|
|
2432
3281
|
}
|
|
2433
|
-
async function getPullRequestById(context, repo,
|
|
3282
|
+
async function getPullRequestById(context, repo, cred, prId) {
|
|
2434
3283
|
const url = new URL(
|
|
2435
3284
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
|
|
2436
3285
|
);
|
|
2437
3286
|
url.searchParams.set("api-version", "7.1");
|
|
2438
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
3287
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
2439
3288
|
const data = await readJsonResponse(response);
|
|
2440
3289
|
return mapPullRequest(repo, data);
|
|
2441
3290
|
}
|
|
2442
|
-
async function listPullRequests(context, repo,
|
|
3291
|
+
async function listPullRequests(context, repo, cred, sourceBranch, opts) {
|
|
2443
3292
|
const response = await fetchWithErrors(
|
|
2444
3293
|
buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
|
|
2445
|
-
{ headers: authHeaders(
|
|
3294
|
+
{ headers: authHeaders(cred) }
|
|
2446
3295
|
);
|
|
2447
3296
|
const data = await readJsonResponse(response);
|
|
2448
3297
|
return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
|
|
2449
3298
|
}
|
|
2450
|
-
async function getPullRequestChecks(context, repo,
|
|
3299
|
+
async function getPullRequestChecks(context, repo, cred, prId) {
|
|
2451
3300
|
const response = await fetchWithErrors(
|
|
2452
3301
|
buildPullRequestStatusesUrl(context, repo, prId).toString(),
|
|
2453
|
-
{ headers: authHeaders(
|
|
3302
|
+
{ headers: authHeaders(cred) }
|
|
2454
3303
|
);
|
|
2455
3304
|
const data = await readJsonResponse(response);
|
|
2456
3305
|
return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
|
|
2457
3306
|
}
|
|
2458
|
-
async function
|
|
2459
|
-
const
|
|
3307
|
+
async function resolveProjectId(context, cred) {
|
|
3308
|
+
const response = await fetchWithErrors(buildProjectUrl(context).toString(), {
|
|
3309
|
+
headers: authHeaders(cred)
|
|
3310
|
+
});
|
|
3311
|
+
const data = await readJsonResponse(response);
|
|
3312
|
+
return data.id;
|
|
3313
|
+
}
|
|
3314
|
+
async function getPullRequestPolicyEvaluations(context, cred, projectId, prId) {
|
|
3315
|
+
const response = await fetchWithErrors(
|
|
3316
|
+
buildPolicyEvaluationsUrl(context, projectId, prId).toString(),
|
|
3317
|
+
{ headers: authHeaders(cred) }
|
|
3318
|
+
);
|
|
3319
|
+
const data = await readJsonResponse(response);
|
|
3320
|
+
return data.value.map(mapPolicyEvaluationCheck).filter((check) => check !== null);
|
|
3321
|
+
}
|
|
3322
|
+
async function getPullRequestBuilds(context, cred, prId) {
|
|
3323
|
+
const response = await fetchWithErrors(buildPullRequestBuildsUrl(context, prId).toString(), {
|
|
3324
|
+
headers: authHeaders(cred)
|
|
3325
|
+
});
|
|
3326
|
+
const data = await readJsonResponse(response);
|
|
3327
|
+
return data.value.map((build) => ({
|
|
3328
|
+
id: build.id,
|
|
3329
|
+
state: mapBuildToCheckState(build),
|
|
3330
|
+
name: build.definition?.name ?? `Build #${build.id}`,
|
|
3331
|
+
description: null,
|
|
3332
|
+
targetUrl: build._links?.web?.href ?? null,
|
|
3333
|
+
createdBy: null,
|
|
3334
|
+
createdAt: build.queueTime ?? null,
|
|
3335
|
+
updatedAt: build.finishTime ?? null,
|
|
3336
|
+
source: "build",
|
|
3337
|
+
isBlocking: null
|
|
3338
|
+
}));
|
|
3339
|
+
}
|
|
3340
|
+
async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
|
|
3341
|
+
const existing = await listPullRequests(context, repo, cred, sourceBranch, {
|
|
2460
3342
|
status: "active",
|
|
2461
3343
|
targetBranch: "develop"
|
|
2462
3344
|
});
|
|
@@ -2484,7 +3366,7 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
|
|
|
2484
3366
|
const response = await fetchWithErrors(url.toString(), {
|
|
2485
3367
|
method: "POST",
|
|
2486
3368
|
headers: {
|
|
2487
|
-
...authHeaders(
|
|
3369
|
+
...authHeaders(cred),
|
|
2488
3370
|
"Content-Type": "application/json"
|
|
2489
3371
|
},
|
|
2490
3372
|
body: JSON.stringify(payload)
|
|
@@ -2497,15 +3379,39 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
|
|
|
2497
3379
|
pullRequest: mapPullRequest(repo, data)
|
|
2498
3380
|
};
|
|
2499
3381
|
}
|
|
2500
|
-
async function getPullRequestThreads(context, repo,
|
|
3382
|
+
async function getPullRequestThreads(context, repo, cred, prId) {
|
|
2501
3383
|
const url = new URL(
|
|
2502
3384
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
|
|
2503
3385
|
);
|
|
2504
3386
|
url.searchParams.set("api-version", "7.1");
|
|
2505
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
3387
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
2506
3388
|
const data = await readJsonResponse(response);
|
|
2507
3389
|
return data.value.map(mapThread).filter((thread) => thread !== null);
|
|
2508
3390
|
}
|
|
3391
|
+
function buildThreadCommentUrl(context, repo, prId, threadId) {
|
|
3392
|
+
const url = new URL(
|
|
3393
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}/comments`
|
|
3394
|
+
);
|
|
3395
|
+
url.searchParams.set("api-version", "7.1");
|
|
3396
|
+
return url;
|
|
3397
|
+
}
|
|
3398
|
+
async function postThreadComment(context, repo, cred, prId, threadId, content) {
|
|
3399
|
+
const response = await fetchWithErrors(buildThreadCommentUrl(context, repo, prId, threadId).toString(), {
|
|
3400
|
+
method: "POST",
|
|
3401
|
+
headers: {
|
|
3402
|
+
...authHeaders(cred),
|
|
3403
|
+
"Content-Type": "application/json"
|
|
3404
|
+
},
|
|
3405
|
+
body: JSON.stringify({ content, parentCommentId: 0, commentType: 1 })
|
|
3406
|
+
});
|
|
3407
|
+
const data = await readJsonResponse(response);
|
|
3408
|
+
return {
|
|
3409
|
+
id: data.id,
|
|
3410
|
+
author: data.author?.displayName ?? null,
|
|
3411
|
+
content: data.content ?? content,
|
|
3412
|
+
publishedAt: data.publishedDate ?? null
|
|
3413
|
+
};
|
|
3414
|
+
}
|
|
2509
3415
|
|
|
2510
3416
|
// src/commands/pr.ts
|
|
2511
3417
|
function parsePositivePrNumber(raw) {
|
|
@@ -2515,6 +3421,22 @@ function parsePositivePrNumber(raw) {
|
|
|
2515
3421
|
const n = Number.parseInt(raw, 10);
|
|
2516
3422
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
2517
3423
|
}
|
|
3424
|
+
var PR_NUMBER_HELP = "target the pull request with this numeric id, instead of the current branch's PR. When omitted, the CLI auto-detects the pull request whose source branch equals refs/heads/<current branch> in the Azure DevOps repository identified by the origin remote; if zero or more than one open PR matches, the command fails with a message naming the searched branch.";
|
|
3425
|
+
function configureUnwrappedHelp(command) {
|
|
3426
|
+
return command.configureHelp({ helpWidth: 1e3 });
|
|
3427
|
+
}
|
|
3428
|
+
function autoDetectZeroMatch(branch) {
|
|
3429
|
+
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.`;
|
|
3430
|
+
}
|
|
3431
|
+
function autoDetectMultiMatch(branch, ids) {
|
|
3432
|
+
const idList = ids.map((id) => `#${id}`).join(", ");
|
|
3433
|
+
return `Multiple open pull requests match branch ${branch}: ${idList}. Re-run with --pr-number to choose.`;
|
|
3434
|
+
}
|
|
3435
|
+
function writeContractError(line) {
|
|
3436
|
+
process.stderr.write(`${line}
|
|
3437
|
+
`);
|
|
3438
|
+
process.exitCode = 1;
|
|
3439
|
+
}
|
|
2518
3440
|
function formatBranchName(refName) {
|
|
2519
3441
|
return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
|
|
2520
3442
|
}
|
|
@@ -2548,34 +3470,101 @@ function handlePrCommandError(err, context, mode = "read") {
|
|
|
2548
3470
|
}
|
|
2549
3471
|
writeError(error.message);
|
|
2550
3472
|
}
|
|
2551
|
-
function formatPullRequestChecks(checks) {
|
|
3473
|
+
function formatPullRequestChecks(checks, checksError) {
|
|
3474
|
+
if (checksError) {
|
|
3475
|
+
return [`Checks: unable to retrieve (${checksError})`];
|
|
3476
|
+
}
|
|
2552
3477
|
if (checks.length === 0) {
|
|
2553
3478
|
return ["Checks: none reported by Azure DevOps"];
|
|
2554
3479
|
}
|
|
2555
3480
|
const lines = ["Checks:"];
|
|
2556
3481
|
for (const check of checks) {
|
|
2557
|
-
|
|
3482
|
+
const optionalTag = check.isBlocking === false ? " [optional]" : "";
|
|
3483
|
+
lines.push(`- [${check.state}] ${check.name}${optionalTag}`);
|
|
2558
3484
|
if ((check.state === "failed" || check.state === "error") && check.description) {
|
|
2559
3485
|
lines.push(` Detail: ${check.description}`);
|
|
2560
3486
|
}
|
|
2561
3487
|
}
|
|
2562
3488
|
return lines;
|
|
2563
3489
|
}
|
|
3490
|
+
function countCodeComments(threads) {
|
|
3491
|
+
let open = 0;
|
|
3492
|
+
let closed = 0;
|
|
3493
|
+
for (const thread of threads) {
|
|
3494
|
+
if (thread.threadContext === null) {
|
|
3495
|
+
continue;
|
|
3496
|
+
}
|
|
3497
|
+
if (isThreadResolved(thread.status)) {
|
|
3498
|
+
closed += 1;
|
|
3499
|
+
} else {
|
|
3500
|
+
open += 1;
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
return { open, closed };
|
|
3504
|
+
}
|
|
3505
|
+
function formatCodeCommentCounts(counts) {
|
|
3506
|
+
return `Code comments: ${counts.open} open, ${counts.closed} closed`;
|
|
3507
|
+
}
|
|
2564
3508
|
function formatPullRequestBlock(pullRequest) {
|
|
2565
3509
|
return [
|
|
2566
3510
|
`#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
|
|
2567
3511
|
`${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
|
|
2568
3512
|
pullRequest.url ?? "\u2014",
|
|
2569
|
-
...formatPullRequestChecks(pullRequest.checks)
|
|
3513
|
+
...formatPullRequestChecks(pullRequest.checks, pullRequest.checksError),
|
|
3514
|
+
formatCodeCommentCounts(pullRequest.codeCommentCounts)
|
|
2570
3515
|
].join("\n");
|
|
2571
3516
|
}
|
|
2572
|
-
function
|
|
2573
|
-
|
|
3517
|
+
async function buildPullRequestStatusEntry(context, repo, cred, pullRequest, projectId) {
|
|
3518
|
+
let statusChecks = [];
|
|
3519
|
+
let statusOk = true;
|
|
3520
|
+
try {
|
|
3521
|
+
statusChecks = await getPullRequestChecks(context, repo, cred, pullRequest.id);
|
|
3522
|
+
} catch {
|
|
3523
|
+
statusOk = false;
|
|
3524
|
+
}
|
|
3525
|
+
let policyChecks = [];
|
|
3526
|
+
let policyOk = true;
|
|
3527
|
+
if (projectId === null) {
|
|
3528
|
+
policyOk = false;
|
|
3529
|
+
} else {
|
|
3530
|
+
try {
|
|
3531
|
+
policyChecks = await getPullRequestPolicyEvaluations(context, cred, projectId, pullRequest.id);
|
|
3532
|
+
} catch {
|
|
3533
|
+
policyOk = false;
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
let buildChecks = [];
|
|
3537
|
+
let buildsOk = true;
|
|
3538
|
+
try {
|
|
3539
|
+
buildChecks = await getPullRequestBuilds(context, cred, pullRequest.id);
|
|
3540
|
+
} catch {
|
|
3541
|
+
buildsOk = false;
|
|
3542
|
+
}
|
|
3543
|
+
let codeCommentCounts;
|
|
3544
|
+
try {
|
|
3545
|
+
const threads = await getPullRequestThreads(context, repo, cred, pullRequest.id);
|
|
3546
|
+
codeCommentCounts = countCodeComments(threads);
|
|
3547
|
+
} catch {
|
|
3548
|
+
codeCommentCounts = { open: 0, closed: 0 };
|
|
3549
|
+
}
|
|
3550
|
+
const checks = [...statusChecks, ...policyChecks, ...buildChecks];
|
|
3551
|
+
const checksError = checks.length === 0 && (!statusOk || !policyOk || !buildsOk) ? "Azure DevOps request failed" : null;
|
|
3552
|
+
return {
|
|
3553
|
+
...pullRequest,
|
|
3554
|
+
checks,
|
|
3555
|
+
codeCommentCounts,
|
|
3556
|
+
checksError
|
|
3557
|
+
};
|
|
3558
|
+
}
|
|
3559
|
+
function threadStatusLabel(status2) {
|
|
3560
|
+
return isThreadResolved(status2) ? "resolved" : status2;
|
|
2574
3561
|
}
|
|
2575
3562
|
function formatThreads(prId, title, threads) {
|
|
2576
3563
|
const lines = [`Comment threads for pull request #${prId}: ${title}`];
|
|
2577
3564
|
for (const thread of threads) {
|
|
2578
|
-
|
|
3565
|
+
const lineSuffix = thread.line === null ? "" : `:${thread.line}`;
|
|
3566
|
+
const location = thread.threadContext ? `${thread.threadContext}${lineSuffix}` : "(general)";
|
|
3567
|
+
lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${location}`);
|
|
2579
3568
|
for (const comment of thread.comments) {
|
|
2580
3569
|
lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
|
|
2581
3570
|
}
|
|
@@ -2587,12 +3576,12 @@ async function resolvePrCommandContext(options, resolveOpts = {}) {
|
|
|
2587
3576
|
const context = resolveContext(options);
|
|
2588
3577
|
const repo = detectRepoName();
|
|
2589
3578
|
const branch = requireBranch ? getCurrentBranch() : null;
|
|
2590
|
-
const credential = await
|
|
3579
|
+
const credential = await requireAuthCredential(context.org);
|
|
2591
3580
|
return {
|
|
2592
3581
|
context,
|
|
2593
3582
|
repo,
|
|
2594
3583
|
branch,
|
|
2595
|
-
pat: credential
|
|
3584
|
+
pat: credential
|
|
2596
3585
|
};
|
|
2597
3586
|
}
|
|
2598
3587
|
function createPrStatusCommand() {
|
|
@@ -2605,11 +3594,16 @@ function createPrStatusCommand() {
|
|
|
2605
3594
|
context = resolved.context;
|
|
2606
3595
|
const branch = resolved.branch;
|
|
2607
3596
|
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
|
|
3597
|
+
let projectId = null;
|
|
3598
|
+
try {
|
|
3599
|
+
projectId = await resolveProjectId(resolved.context, resolved.pat);
|
|
3600
|
+
} catch {
|
|
3601
|
+
projectId = null;
|
|
3602
|
+
}
|
|
2608
3603
|
const pullRequestsWithChecks = await Promise.all(
|
|
2609
|
-
pullRequests.map(
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
}))
|
|
3604
|
+
pullRequests.map(
|
|
3605
|
+
async (pullRequest) => buildPullRequestStatusEntry(resolved.context, resolved.repo, resolved.pat, pullRequest, projectId)
|
|
3606
|
+
)
|
|
2613
3607
|
);
|
|
2614
3608
|
const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
|
|
2615
3609
|
if (options.json) {
|
|
@@ -2690,7 +3684,7 @@ ${result.pullRequest.url ?? "\u2014"}
|
|
|
2690
3684
|
}
|
|
2691
3685
|
function createPrCommentsCommand() {
|
|
2692
3686
|
const command = new Command12("comments");
|
|
2693
|
-
command.description("List pull request comment threads for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "
|
|
3687
|
+
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) => {
|
|
2694
3688
|
validateOrgProjectPair(options);
|
|
2695
3689
|
let context;
|
|
2696
3690
|
let explicitPrId = null;
|
|
@@ -2722,19 +3716,22 @@ function createPrCommentsCommand() {
|
|
|
2722
3716
|
status: "active"
|
|
2723
3717
|
});
|
|
2724
3718
|
if (pullRequests.length === 0) {
|
|
2725
|
-
|
|
3719
|
+
writeContractError(autoDetectZeroMatch(resolved.branch));
|
|
2726
3720
|
return;
|
|
2727
3721
|
}
|
|
2728
3722
|
if (pullRequests.length > 1) {
|
|
2729
|
-
|
|
2730
|
-
writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
|
|
3723
|
+
writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
|
|
2731
3724
|
return;
|
|
2732
3725
|
}
|
|
2733
3726
|
pullRequest = pullRequests[0];
|
|
2734
3727
|
branchLabel = resolved.branch;
|
|
2735
3728
|
}
|
|
3729
|
+
const hideResolved = options.hideResolved === true || options.excludeResolved === true;
|
|
3730
|
+
const codeRelatedOnly = options.codeRelatedOnly === true;
|
|
2736
3731
|
const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
|
|
2737
|
-
const threads =
|
|
3732
|
+
const threads = allThreads.filter(
|
|
3733
|
+
(thread) => (!hideResolved || !isThreadResolved(thread.status)) && (!codeRelatedOnly || thread.threadContext !== null)
|
|
3734
|
+
);
|
|
2738
3735
|
const result = { branch: branchLabel, pullRequest, threads };
|
|
2739
3736
|
if (options.json) {
|
|
2740
3737
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
@@ -2742,9 +3739,18 @@ function createPrCommentsCommand() {
|
|
|
2742
3739
|
return;
|
|
2743
3740
|
}
|
|
2744
3741
|
if (threads.length === 0) {
|
|
2745
|
-
if (
|
|
2746
|
-
|
|
2747
|
-
|
|
3742
|
+
if (allThreads.length > 0 && (hideResolved || codeRelatedOnly)) {
|
|
3743
|
+
const filters = [];
|
|
3744
|
+
if (codeRelatedOnly) {
|
|
3745
|
+
filters.push("code-related");
|
|
3746
|
+
}
|
|
3747
|
+
if (hideResolved) {
|
|
3748
|
+
filters.push("unresolved");
|
|
3749
|
+
}
|
|
3750
|
+
process.stdout.write(
|
|
3751
|
+
`Pull request #${pullRequest.id} has no ${filters.join(" ")} comment threads (filtered from ${allThreads.length} thread${allThreads.length === 1 ? "" : "s"}).
|
|
3752
|
+
`
|
|
3753
|
+
);
|
|
2748
3754
|
} else {
|
|
2749
3755
|
process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
|
|
2750
3756
|
`);
|
|
@@ -2757,6 +3763,7 @@ function createPrCommentsCommand() {
|
|
|
2757
3763
|
handlePrCommandError(err, context, "read");
|
|
2758
3764
|
}
|
|
2759
3765
|
});
|
|
3766
|
+
command.addCommand(createPrCommentsReplyCommand());
|
|
2760
3767
|
return command;
|
|
2761
3768
|
}
|
|
2762
3769
|
async function resolveThreadTarget(threadIdRaw, options) {
|
|
@@ -2791,12 +3798,11 @@ async function resolveThreadTarget(threadIdRaw, options) {
|
|
|
2791
3798
|
status: "active"
|
|
2792
3799
|
});
|
|
2793
3800
|
if (pullRequests.length === 0) {
|
|
2794
|
-
|
|
3801
|
+
writeContractError(autoDetectZeroMatch(resolved.branch));
|
|
2795
3802
|
return null;
|
|
2796
3803
|
}
|
|
2797
3804
|
if (pullRequests.length > 1) {
|
|
2798
|
-
|
|
2799
|
-
writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
|
|
3805
|
+
writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
|
|
2800
3806
|
return null;
|
|
2801
3807
|
}
|
|
2802
3808
|
pullRequest = pullRequests[0];
|
|
@@ -2864,18 +3870,76 @@ async function runThreadStateChange(threadIdRaw, options, direction) {
|
|
|
2864
3870
|
}
|
|
2865
3871
|
function createPrCommentResolveCommand() {
|
|
2866
3872
|
const command = new Command12("comment-resolve");
|
|
2867
|
-
command.description("Mark a pull request comment thread as resolved").argument("<threadId>", "numeric id of the thread to resolve").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>",
|
|
3873
|
+
configureUnwrappedHelp(command).description("Mark a pull request comment thread as resolved").argument("<threadId>", "numeric id of the thread to resolve").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--json", "output JSON").action(async (threadIdRaw, options) => {
|
|
2868
3874
|
await runThreadStateChange(threadIdRaw, options, "resolve");
|
|
2869
3875
|
});
|
|
2870
3876
|
return command;
|
|
2871
3877
|
}
|
|
2872
3878
|
function createPrCommentReopenCommand() {
|
|
2873
3879
|
const command = new Command12("comment-reopen");
|
|
2874
|
-
command.description("Reopen (set to active) a previously resolved pull request comment thread").argument("<threadId>", "numeric id of the thread to reopen").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>",
|
|
3880
|
+
configureUnwrappedHelp(command).description("Reopen (set to active) a previously resolved pull request comment thread").argument("<threadId>", "numeric id of the thread to reopen").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--json", "output JSON").action(async (threadIdRaw, options) => {
|
|
2875
3881
|
await runThreadStateChange(threadIdRaw, options, "reopen");
|
|
2876
3882
|
});
|
|
2877
3883
|
return command;
|
|
2878
3884
|
}
|
|
3885
|
+
async function runCommentReply(threadIdRaw, text, options) {
|
|
3886
|
+
let context;
|
|
3887
|
+
try {
|
|
3888
|
+
const trimmedText = text.trim();
|
|
3889
|
+
if (!trimmedText) {
|
|
3890
|
+
writeError("Reply text must not be empty.");
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3893
|
+
const target = await resolveThreadTarget(threadIdRaw, options);
|
|
3894
|
+
if (target === null) {
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
context = target.context;
|
|
3898
|
+
const threads = await getPullRequestThreads(target.context, target.repo, target.pat, target.pullRequest.id);
|
|
3899
|
+
const thread = threads.find((t) => t.id === target.threadId);
|
|
3900
|
+
if (!thread) {
|
|
3901
|
+
writeError(`Thread #${target.threadId} not found on pull request #${target.pullRequest.id}.`);
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
const posted = await postThreadComment(
|
|
3905
|
+
target.context,
|
|
3906
|
+
target.repo,
|
|
3907
|
+
target.pat,
|
|
3908
|
+
target.pullRequest.id,
|
|
3909
|
+
target.threadId,
|
|
3910
|
+
trimmedText
|
|
3911
|
+
);
|
|
3912
|
+
const result = {
|
|
3913
|
+
pullRequestId: target.pullRequest.id,
|
|
3914
|
+
threadId: target.threadId,
|
|
3915
|
+
commentId: posted.id,
|
|
3916
|
+
content: posted.content
|
|
3917
|
+
};
|
|
3918
|
+
if (options.json) {
|
|
3919
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3920
|
+
`);
|
|
3921
|
+
return;
|
|
3922
|
+
}
|
|
3923
|
+
process.stdout.write(`Reply posted to thread #${target.threadId} on pull request #${target.pullRequest.id}.
|
|
3924
|
+
`);
|
|
3925
|
+
} catch (err) {
|
|
3926
|
+
handlePrCommandError(err, context, "write");
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
function createPrCommentsReplyCommand() {
|
|
3930
|
+
const command = new Command12("reply");
|
|
3931
|
+
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) => {
|
|
3932
|
+
await runCommentReply(threadIdRaw, text, options);
|
|
3933
|
+
});
|
|
3934
|
+
return command;
|
|
3935
|
+
}
|
|
3936
|
+
function createPrCommentReplyCommand() {
|
|
3937
|
+
const command = new Command12("comment-reply");
|
|
3938
|
+
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) => {
|
|
3939
|
+
await runCommentReply(threadIdRaw, text, options);
|
|
3940
|
+
});
|
|
3941
|
+
return command;
|
|
3942
|
+
}
|
|
2879
3943
|
function createPrCommand() {
|
|
2880
3944
|
const command = new Command12("pr");
|
|
2881
3945
|
command.description("Manage Azure DevOps pull requests");
|
|
@@ -2884,71 +3948,984 @@ function createPrCommand() {
|
|
|
2884
3948
|
command.addCommand(createPrCommentsCommand());
|
|
2885
3949
|
command.addCommand(createPrCommentResolveCommand());
|
|
2886
3950
|
command.addCommand(createPrCommentReopenCommand());
|
|
3951
|
+
command.addCommand(createPrCommentReplyCommand());
|
|
2887
3952
|
return command;
|
|
2888
3953
|
}
|
|
2889
3954
|
|
|
2890
|
-
// src/commands/
|
|
3955
|
+
// src/commands/pipeline.ts
|
|
2891
3956
|
import { Command as Command13 } from "commander";
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
3957
|
+
|
|
3958
|
+
// src/services/pipeline-client.ts
|
|
3959
|
+
var API_VERSION = "7.1";
|
|
3960
|
+
function orgProjectBase(context) {
|
|
3961
|
+
return `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}`;
|
|
2896
3962
|
}
|
|
2897
|
-
function
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
return `Comment #${comment.id} by ${author} at ${timestamp}`;
|
|
3963
|
+
function withApiVersion(url) {
|
|
3964
|
+
url.searchParams.set("api-version", API_VERSION);
|
|
3965
|
+
return url;
|
|
2901
3966
|
}
|
|
2902
|
-
function
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
|
|
2906
|
-
lines.push("", formatCommentHeader(comment), text);
|
|
3967
|
+
async function readJsonResponse2(response) {
|
|
3968
|
+
if (!response.ok) {
|
|
3969
|
+
throw new Error(`HTTP_${response.status}`);
|
|
2907
3970
|
}
|
|
2908
|
-
return
|
|
3971
|
+
return await response.json();
|
|
2909
3972
|
}
|
|
2910
|
-
function
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
3973
|
+
function mapPipeline(pipeline) {
|
|
3974
|
+
return {
|
|
3975
|
+
id: pipeline.id,
|
|
3976
|
+
name: pipeline.name,
|
|
3977
|
+
folder: pipeline.folder?.trim() ? pipeline.folder : null
|
|
3978
|
+
};
|
|
3979
|
+
}
|
|
3980
|
+
function mapRunState(state) {
|
|
3981
|
+
if (state === "inProgress" || state === "completed") {
|
|
3982
|
+
return state;
|
|
3983
|
+
}
|
|
3984
|
+
return "unknown";
|
|
3985
|
+
}
|
|
3986
|
+
function mapRunResult(result) {
|
|
3987
|
+
if (result === "succeeded" || result === "failed" || result === "canceled") {
|
|
3988
|
+
return result;
|
|
3989
|
+
}
|
|
3990
|
+
if (result === "partiallySucceeded") {
|
|
3991
|
+
return "failed";
|
|
3992
|
+
}
|
|
3993
|
+
return null;
|
|
3994
|
+
}
|
|
3995
|
+
function mapBuildState(status2) {
|
|
3996
|
+
if (status2 === "completed") return "completed";
|
|
3997
|
+
if (status2 === void 0 || status2 === "none") return "unknown";
|
|
3998
|
+
return "inProgress";
|
|
3999
|
+
}
|
|
4000
|
+
function mapBuildSummary(build) {
|
|
4001
|
+
return {
|
|
4002
|
+
id: build.id,
|
|
4003
|
+
name: build.buildNumber ?? null,
|
|
4004
|
+
state: mapBuildState(build.status),
|
|
4005
|
+
result: mapRunResult(build.result),
|
|
4006
|
+
createdDate: build.queueTime ?? build.startTime ?? null,
|
|
4007
|
+
finishedDate: build.finishTime ?? null,
|
|
4008
|
+
sourceBranch: build.sourceBranch ?? null,
|
|
4009
|
+
sourceCommit: build.sourceVersion ?? null
|
|
4010
|
+
};
|
|
4011
|
+
}
|
|
4012
|
+
function normalizeRef(branch) {
|
|
4013
|
+
return branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
|
|
4014
|
+
}
|
|
4015
|
+
async function getPipelineDefinitions(context, cred) {
|
|
4016
|
+
const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/pipelines`));
|
|
4017
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
4018
|
+
const data = await readJsonResponse2(response);
|
|
4019
|
+
return data.value.map(mapPipeline);
|
|
4020
|
+
}
|
|
4021
|
+
var COMMIT_LOOKBACK = 200;
|
|
4022
|
+
async function getPipelineRuns(context, cred, query) {
|
|
4023
|
+
const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds`));
|
|
4024
|
+
if (query.definitionId !== void 0) {
|
|
4025
|
+
url.searchParams.set("definitions", String(query.definitionId));
|
|
4026
|
+
}
|
|
4027
|
+
if (query.prNumber !== void 0) {
|
|
4028
|
+
url.searchParams.set("branchName", `refs/pull/${query.prNumber}/merge`);
|
|
4029
|
+
} else if (query.branch) {
|
|
4030
|
+
url.searchParams.set("branchName", normalizeRef(query.branch));
|
|
4031
|
+
}
|
|
4032
|
+
url.searchParams.set("queryOrder", "queueTimeDescending");
|
|
4033
|
+
url.searchParams.set("$top", String(query.commit ? COMMIT_LOOKBACK : query.top));
|
|
4034
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
4035
|
+
const data = await readJsonResponse2(response);
|
|
4036
|
+
let runs = data.value.map(mapBuildSummary);
|
|
4037
|
+
if (query.commit) {
|
|
4038
|
+
const needle = query.commit.toLowerCase();
|
|
4039
|
+
runs = runs.filter((run) => run.sourceCommit?.toLowerCase().startsWith(needle));
|
|
4040
|
+
}
|
|
4041
|
+
return runs.slice(0, query.top);
|
|
4042
|
+
}
|
|
4043
|
+
async function runPipeline(context, cred, pipelineId, opts) {
|
|
4044
|
+
const url = withApiVersion(
|
|
4045
|
+
new URL(`${orgProjectBase(context)}/_apis/pipelines/${pipelineId}/runs`)
|
|
4046
|
+
);
|
|
4047
|
+
const body = {};
|
|
4048
|
+
if (opts.branch) {
|
|
4049
|
+
const refName = opts.branch.startsWith("refs/") ? opts.branch : `refs/heads/${opts.branch}`;
|
|
4050
|
+
body.resources = { repositories: { self: { refName } } };
|
|
4051
|
+
}
|
|
4052
|
+
if (opts.parameters && Object.keys(opts.parameters).length > 0) {
|
|
4053
|
+
body.templateParameters = opts.parameters;
|
|
4054
|
+
}
|
|
4055
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
4056
|
+
method: "POST",
|
|
4057
|
+
headers: { ...authHeaders(cred), "Content-Type": "application/json" },
|
|
4058
|
+
body: JSON.stringify(body)
|
|
4059
|
+
});
|
|
4060
|
+
const data = await readJsonResponse2(response);
|
|
4061
|
+
return {
|
|
4062
|
+
id: data.id,
|
|
4063
|
+
state: mapRunState(data.state),
|
|
4064
|
+
webUrl: data._links?.web?.href ?? null
|
|
4065
|
+
};
|
|
4066
|
+
}
|
|
4067
|
+
function buildUrl(context, buildId) {
|
|
4068
|
+
return withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}`));
|
|
4069
|
+
}
|
|
4070
|
+
async function getBuild(context, cred, buildId) {
|
|
4071
|
+
const response = await fetchWithErrors(buildUrl(context, buildId).toString(), {
|
|
4072
|
+
headers: authHeaders(cred)
|
|
4073
|
+
});
|
|
4074
|
+
return readJsonResponse2(response);
|
|
4075
|
+
}
|
|
4076
|
+
async function getBuildStatus(context, cred, buildId) {
|
|
4077
|
+
const build = await getBuild(context, cred, buildId);
|
|
4078
|
+
return { state: mapBuildState(build.status), result: mapRunResult(build.result) };
|
|
4079
|
+
}
|
|
4080
|
+
async function getBuildTimeline(context, cred, buildId) {
|
|
4081
|
+
const url = withApiVersion(
|
|
4082
|
+
new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/timeline`)
|
|
4083
|
+
);
|
|
4084
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
4085
|
+
const data = await readJsonResponse2(response);
|
|
4086
|
+
const records = data.records ?? [];
|
|
4087
|
+
const errors = [];
|
|
4088
|
+
const stages = [];
|
|
4089
|
+
const jobs = [];
|
|
4090
|
+
const logSteps = /* @__PURE__ */ new Map();
|
|
4091
|
+
for (const record of records) {
|
|
4092
|
+
for (const issue of record.issues ?? []) {
|
|
4093
|
+
if (issue.type === "error" && issue.message) {
|
|
4094
|
+
errors.push({ message: issue.message, source: record.name ?? null });
|
|
2929
4095
|
}
|
|
2930
|
-
process.stdout.write(`${formatComments(result, options.markdown === true)}
|
|
2931
|
-
`);
|
|
2932
|
-
} catch (err) {
|
|
2933
|
-
handleCommandError(err, id, context, "read");
|
|
2934
4096
|
}
|
|
4097
|
+
if (record.name && record.log?.id !== void 0) {
|
|
4098
|
+
logSteps.set(record.log.id, record.name);
|
|
4099
|
+
}
|
|
4100
|
+
if (!record.name) continue;
|
|
4101
|
+
const status2 = {
|
|
4102
|
+
name: record.name,
|
|
4103
|
+
state: record.state ?? "unknown",
|
|
4104
|
+
result: record.result ?? null
|
|
4105
|
+
};
|
|
4106
|
+
if (record.type === "Stage") {
|
|
4107
|
+
stages.push(status2);
|
|
4108
|
+
} else if (record.type === "Job") {
|
|
4109
|
+
jobs.push({ startTime: record.startTime, status: status2 });
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
jobs.sort((a, b) => {
|
|
4113
|
+
if (a.startTime === b.startTime) return 0;
|
|
4114
|
+
if (a.startTime === void 0) return 1;
|
|
4115
|
+
if (b.startTime === void 0) return -1;
|
|
4116
|
+
return a.startTime < b.startTime ? -1 : 1;
|
|
2935
4117
|
});
|
|
2936
|
-
return
|
|
4118
|
+
return { errors, stages, jobs: jobs.map((j) => j.status), logSteps };
|
|
4119
|
+
}
|
|
4120
|
+
async function listTestRuns(context, cred, buildId) {
|
|
4121
|
+
const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/test/runs`));
|
|
4122
|
+
url.searchParams.set("buildUri", `vstfs:///Build/Build/${buildId}`);
|
|
4123
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
4124
|
+
const data = await readJsonResponse2(response);
|
|
4125
|
+
return data.value;
|
|
4126
|
+
}
|
|
4127
|
+
async function getTestSummary(context, cred, buildId) {
|
|
4128
|
+
const testRuns = await listTestRuns(context, cred, buildId);
|
|
4129
|
+
let total = 0;
|
|
4130
|
+
let failed = 0;
|
|
4131
|
+
for (const run of testRuns) {
|
|
4132
|
+
const runTotal = run.totalTests ?? 0;
|
|
4133
|
+
total += runTotal;
|
|
4134
|
+
const passedOrSkipped = (run.passedTests ?? 0) + (run.notApplicableTests ?? 0) + (run.incompleteTests ?? 0);
|
|
4135
|
+
failed += Math.max(0, runTotal - passedOrSkipped);
|
|
4136
|
+
}
|
|
4137
|
+
if (total === 0) {
|
|
4138
|
+
return { present: false, total: 0, failed: 0, failedTests: [] };
|
|
4139
|
+
}
|
|
4140
|
+
return { present: true, total, failed, failedTests: [] };
|
|
4141
|
+
}
|
|
4142
|
+
var MAX_FAILED_TESTS = 50;
|
|
4143
|
+
async function getFailedTests(context, cred, buildId) {
|
|
4144
|
+
const testRuns = await listTestRuns(context, cred, buildId);
|
|
4145
|
+
const failed = [];
|
|
4146
|
+
for (const testRun of testRuns) {
|
|
4147
|
+
if (failed.length >= MAX_FAILED_TESTS) break;
|
|
4148
|
+
const resultsUrl = withApiVersion(
|
|
4149
|
+
new URL(`${orgProjectBase(context)}/_apis/test/runs/${testRun.id}/results`)
|
|
4150
|
+
);
|
|
4151
|
+
resultsUrl.searchParams.set("outcomes", "Failed");
|
|
4152
|
+
resultsUrl.searchParams.set("$top", String(MAX_FAILED_TESTS - failed.length));
|
|
4153
|
+
const resultsResponse = await fetchWithErrors(resultsUrl.toString(), {
|
|
4154
|
+
headers: authHeaders(cred)
|
|
4155
|
+
});
|
|
4156
|
+
const resultsData = await readJsonResponse2(resultsResponse);
|
|
4157
|
+
for (const result of resultsData.value) {
|
|
4158
|
+
failed.push({
|
|
4159
|
+
name: result.testCaseTitle ?? result.automatedTestName ?? "(unnamed test)",
|
|
4160
|
+
errorMessage: result.errorMessage ?? null
|
|
4161
|
+
});
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
return failed;
|
|
4165
|
+
}
|
|
4166
|
+
function secondsBetween(start, finish) {
|
|
4167
|
+
if (!start || !finish) return null;
|
|
4168
|
+
const ms = Date.parse(finish) - Date.parse(start);
|
|
4169
|
+
return Number.isFinite(ms) ? Math.round(ms / 1e3) : null;
|
|
4170
|
+
}
|
|
4171
|
+
async function getRunDetail(context, cred, buildId) {
|
|
4172
|
+
const build = await getBuild(context, cred, buildId);
|
|
4173
|
+
let errors = [];
|
|
4174
|
+
let stages = [];
|
|
4175
|
+
let jobs = [];
|
|
4176
|
+
let errorsAvailable = true;
|
|
4177
|
+
try {
|
|
4178
|
+
const timeline = await getBuildTimeline(context, cred, buildId);
|
|
4179
|
+
errors = timeline.errors;
|
|
4180
|
+
stages = timeline.stages;
|
|
4181
|
+
jobs = timeline.jobs;
|
|
4182
|
+
} catch {
|
|
4183
|
+
errorsAvailable = false;
|
|
4184
|
+
}
|
|
4185
|
+
let tests = { present: false, total: 0, failed: 0, failedTests: [] };
|
|
4186
|
+
let testsAvailable = true;
|
|
4187
|
+
try {
|
|
4188
|
+
tests = await getTestSummary(context, cred, buildId);
|
|
4189
|
+
if (tests.failed > 0) {
|
|
4190
|
+
try {
|
|
4191
|
+
tests = { ...tests, failedTests: await getFailedTests(context, cred, buildId) };
|
|
4192
|
+
} catch {
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
} catch {
|
|
4196
|
+
testsAvailable = false;
|
|
4197
|
+
}
|
|
4198
|
+
return {
|
|
4199
|
+
id: build.id,
|
|
4200
|
+
name: build.buildNumber ?? null,
|
|
4201
|
+
state: mapBuildState(build.status),
|
|
4202
|
+
result: mapRunResult(build.result),
|
|
4203
|
+
// createdDate is the queue time, matching the run-list mapping.
|
|
4204
|
+
createdDate: build.queueTime ?? build.startTime ?? null,
|
|
4205
|
+
startedDate: build.startTime ?? null,
|
|
4206
|
+
finishedDate: build.finishTime ?? null,
|
|
4207
|
+
durationSeconds: secondsBetween(build.startTime, build.finishTime),
|
|
4208
|
+
reason: build.reason ?? null,
|
|
4209
|
+
requestedFor: build.requestedFor?.displayName ?? null,
|
|
4210
|
+
sourceBranch: build.sourceBranch ?? null,
|
|
4211
|
+
sourceCommit: build.sourceVersion ?? null,
|
|
4212
|
+
webUrl: build._links?.web?.href ?? null,
|
|
4213
|
+
errors,
|
|
4214
|
+
errorsAvailable,
|
|
4215
|
+
stages,
|
|
4216
|
+
jobs,
|
|
4217
|
+
tests,
|
|
4218
|
+
testsAvailable
|
|
4219
|
+
};
|
|
4220
|
+
}
|
|
4221
|
+
async function getRunLogs(context, cred, buildId) {
|
|
4222
|
+
const url = withApiVersion(
|
|
4223
|
+
new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs`)
|
|
4224
|
+
);
|
|
4225
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
4226
|
+
const data = await readJsonResponse2(response);
|
|
4227
|
+
let logSteps = /* @__PURE__ */ new Map();
|
|
4228
|
+
try {
|
|
4229
|
+
logSteps = (await getBuildTimeline(context, cred, buildId)).logSteps;
|
|
4230
|
+
} catch {
|
|
4231
|
+
}
|
|
4232
|
+
return data.value.map((log) => ({
|
|
4233
|
+
id: log.id,
|
|
4234
|
+
createdOn: log.createdOn ?? null,
|
|
4235
|
+
lineCount: log.lineCount ?? null,
|
|
4236
|
+
step: logSteps.get(log.id) ?? null
|
|
4237
|
+
}));
|
|
4238
|
+
}
|
|
4239
|
+
async function getRunLog(context, cred, buildId, logId) {
|
|
4240
|
+
const url = withApiVersion(
|
|
4241
|
+
new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs/${logId}`)
|
|
4242
|
+
);
|
|
4243
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
4244
|
+
if (!response.ok) {
|
|
4245
|
+
throw new Error(`HTTP_${response.status}`);
|
|
4246
|
+
}
|
|
4247
|
+
return response.text();
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
// src/commands/pipeline.ts
|
|
4251
|
+
var EXIT_FAILED = 1;
|
|
4252
|
+
var EXIT_CANCELED = 2;
|
|
4253
|
+
var EXIT_TIMEOUT = 124;
|
|
4254
|
+
function writeError2(message) {
|
|
4255
|
+
process.stderr.write(`Error: ${message}
|
|
4256
|
+
`);
|
|
4257
|
+
process.exitCode = 1;
|
|
4258
|
+
}
|
|
4259
|
+
function handlePipelineError(err, context) {
|
|
4260
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
4261
|
+
if (error.message === "AUTH_FAILED") {
|
|
4262
|
+
writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
|
|
4263
|
+
return;
|
|
4264
|
+
}
|
|
4265
|
+
if (error.message === "PERMISSION_DENIED") {
|
|
4266
|
+
writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
|
|
4267
|
+
return;
|
|
4268
|
+
}
|
|
4269
|
+
if (error.message === "NETWORK_ERROR") {
|
|
4270
|
+
writeError2("Could not connect to Azure DevOps. Check your network connection.");
|
|
4271
|
+
return;
|
|
4272
|
+
}
|
|
4273
|
+
if (error.message.startsWith("NOT_FOUND")) {
|
|
4274
|
+
writeError2(`Resource not found in ${context?.org}/${context?.project}.`);
|
|
4275
|
+
return;
|
|
4276
|
+
}
|
|
4277
|
+
if (error.message.startsWith("HTTP_")) {
|
|
4278
|
+
writeError2(`Azure DevOps request failed with ${error.message}.`);
|
|
4279
|
+
return;
|
|
4280
|
+
}
|
|
4281
|
+
writeError2(error.message);
|
|
4282
|
+
}
|
|
4283
|
+
function parsePositiveId(raw) {
|
|
4284
|
+
if (!/^\d+$/.test(raw)) return null;
|
|
4285
|
+
const n = Number.parseInt(raw, 10);
|
|
4286
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
4287
|
+
}
|
|
4288
|
+
function parseOptionalCount(value, flag) {
|
|
4289
|
+
if (value === void 0) return void 0;
|
|
4290
|
+
const parsed = parsePositiveId(value);
|
|
4291
|
+
if (parsed === null) {
|
|
4292
|
+
writeError2(`Invalid ${flag} "${value}"; expected a positive integer.`);
|
|
4293
|
+
return null;
|
|
4294
|
+
}
|
|
4295
|
+
return parsed;
|
|
4296
|
+
}
|
|
4297
|
+
function formatBranchName2(refName) {
|
|
4298
|
+
if (!refName) return "\u2014";
|
|
4299
|
+
return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
|
|
4300
|
+
}
|
|
4301
|
+
async function resolvePipelineContext(options) {
|
|
4302
|
+
const context = resolveContext(options);
|
|
4303
|
+
const cred = await requireAuthCredential(context.org);
|
|
4304
|
+
return { context, cred };
|
|
4305
|
+
}
|
|
4306
|
+
function sleep(ms) {
|
|
4307
|
+
return new Promise((resolve2) => {
|
|
4308
|
+
setTimeout(resolve2, ms);
|
|
4309
|
+
});
|
|
4310
|
+
}
|
|
4311
|
+
function formatTable(rows, rightAlign = /* @__PURE__ */ new Set()) {
|
|
4312
|
+
const widths = [];
|
|
4313
|
+
for (const row of rows) {
|
|
4314
|
+
row.forEach((cell, i) => {
|
|
4315
|
+
widths[i] = Math.max(widths[i] ?? 0, cell.length);
|
|
4316
|
+
});
|
|
4317
|
+
}
|
|
4318
|
+
return rows.map(
|
|
4319
|
+
(row) => row.map((cell, i) => rightAlign.has(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i])).join(" ").trimEnd()
|
|
4320
|
+
).join("\n");
|
|
4321
|
+
}
|
|
4322
|
+
function createPipelineListCommand() {
|
|
4323
|
+
const command = new Command13("list");
|
|
4324
|
+
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) => {
|
|
4325
|
+
validateOrgProjectPair(options);
|
|
4326
|
+
let context;
|
|
4327
|
+
try {
|
|
4328
|
+
const resolved = await resolvePipelineContext(options);
|
|
4329
|
+
context = resolved.context;
|
|
4330
|
+
let definitions = await getPipelineDefinitions(resolved.context, resolved.cred);
|
|
4331
|
+
if (options.filter) {
|
|
4332
|
+
const needle = options.filter.toLowerCase();
|
|
4333
|
+
definitions = definitions.filter((d) => d.name.toLowerCase().includes(needle));
|
|
4334
|
+
}
|
|
4335
|
+
if (options.json) {
|
|
4336
|
+
process.stdout.write(`${JSON.stringify(definitions, null, 2)}
|
|
4337
|
+
`);
|
|
4338
|
+
return;
|
|
4339
|
+
}
|
|
4340
|
+
if (definitions.length === 0) {
|
|
4341
|
+
process.stdout.write("No pipelines found.\n");
|
|
4342
|
+
return;
|
|
4343
|
+
}
|
|
4344
|
+
const hasFolder = definitions.some((d) => d.folder);
|
|
4345
|
+
const rows = definitions.map(
|
|
4346
|
+
(d) => hasFolder ? [String(d.id), d.name, d.folder ?? ""] : [String(d.id), d.name]
|
|
4347
|
+
);
|
|
4348
|
+
process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
|
|
4349
|
+
`);
|
|
4350
|
+
} catch (err) {
|
|
4351
|
+
handlePipelineError(err, context);
|
|
4352
|
+
}
|
|
4353
|
+
});
|
|
4354
|
+
return command;
|
|
4355
|
+
}
|
|
4356
|
+
function runRow(run) {
|
|
4357
|
+
const status2 = run.result ? `${run.state}/${run.result}` : run.state;
|
|
4358
|
+
return [
|
|
4359
|
+
String(run.id),
|
|
4360
|
+
`[${status2}]`,
|
|
4361
|
+
run.createdDate ?? "\u2014",
|
|
4362
|
+
formatBranchName2(run.sourceBranch),
|
|
4363
|
+
run.sourceCommit ? run.sourceCommit.slice(0, 8) : "\u2014"
|
|
4364
|
+
];
|
|
4365
|
+
}
|
|
4366
|
+
var COMMIT_SHA_PATTERN = /^[0-9a-f]{6,40}$/i;
|
|
4367
|
+
function parseGetRunsInputs(defIdRaw, options) {
|
|
4368
|
+
let defId;
|
|
4369
|
+
if (defIdRaw !== void 0) {
|
|
4370
|
+
const parsed = parsePositiveId(defIdRaw);
|
|
4371
|
+
if (parsed === null) {
|
|
4372
|
+
writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
|
|
4373
|
+
return null;
|
|
4374
|
+
}
|
|
4375
|
+
defId = parsed;
|
|
4376
|
+
} else if (options.commit === void 0 && options.pr === void 0) {
|
|
4377
|
+
writeError2("Definition id is required unless --commit or --pr is given.");
|
|
4378
|
+
return null;
|
|
4379
|
+
}
|
|
4380
|
+
const limit = parseOptionalCount(options.limit, "--limit");
|
|
4381
|
+
if (limit === null) return null;
|
|
4382
|
+
const prNumber = parseOptionalCount(options.pr, "--pr");
|
|
4383
|
+
if (prNumber === null) return null;
|
|
4384
|
+
if (options.commit !== void 0 && !COMMIT_SHA_PATTERN.test(options.commit)) {
|
|
4385
|
+
writeError2(`Invalid --commit "${options.commit}"; expected 6-40 hex characters.`);
|
|
4386
|
+
return null;
|
|
4387
|
+
}
|
|
4388
|
+
if (options.branch !== void 0 && prNumber !== void 0) {
|
|
4389
|
+
writeError2("Use either --branch or --pr, not both.");
|
|
4390
|
+
return null;
|
|
4391
|
+
}
|
|
4392
|
+
return { defId, limit: limit ?? 10, prNumber };
|
|
4393
|
+
}
|
|
4394
|
+
function createPipelineGetRunsCommand() {
|
|
4395
|
+
const command = new Command13("get-runs");
|
|
4396
|
+
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) => {
|
|
4397
|
+
validateOrgProjectPair(options);
|
|
4398
|
+
const inputs = parseGetRunsInputs(defIdRaw, options);
|
|
4399
|
+
if (inputs === null) {
|
|
4400
|
+
return;
|
|
4401
|
+
}
|
|
4402
|
+
let context;
|
|
4403
|
+
try {
|
|
4404
|
+
const resolved = await resolvePipelineContext(options);
|
|
4405
|
+
context = resolved.context;
|
|
4406
|
+
const runs = await getPipelineRuns(resolved.context, resolved.cred, {
|
|
4407
|
+
definitionId: inputs.defId,
|
|
4408
|
+
branch: options.branch,
|
|
4409
|
+
prNumber: inputs.prNumber,
|
|
4410
|
+
commit: options.commit,
|
|
4411
|
+
top: inputs.limit
|
|
4412
|
+
});
|
|
4413
|
+
if (options.json) {
|
|
4414
|
+
process.stdout.write(`${JSON.stringify(runs, null, 2)}
|
|
4415
|
+
`);
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
if (runs.length === 0) {
|
|
4419
|
+
process.stdout.write(
|
|
4420
|
+
inputs.defId === void 0 ? "No runs found matching the filters.\n" : `No runs found for pipeline ${inputs.defId}.
|
|
4421
|
+
`
|
|
4422
|
+
);
|
|
4423
|
+
return;
|
|
4424
|
+
}
|
|
4425
|
+
process.stdout.write(`${formatTable(runs.map(runRow), /* @__PURE__ */ new Set([0]))}
|
|
4426
|
+
`);
|
|
4427
|
+
} catch (err) {
|
|
4428
|
+
handlePipelineError(err, context);
|
|
4429
|
+
}
|
|
4430
|
+
});
|
|
4431
|
+
return command;
|
|
4432
|
+
}
|
|
4433
|
+
function applyWaitExitCode(result) {
|
|
4434
|
+
if (result.timedOut) {
|
|
4435
|
+
process.exitCode = EXIT_TIMEOUT;
|
|
4436
|
+
return;
|
|
4437
|
+
}
|
|
4438
|
+
switch (result.result) {
|
|
4439
|
+
case "succeeded":
|
|
4440
|
+
return;
|
|
4441
|
+
case "canceled":
|
|
4442
|
+
process.exitCode = EXIT_CANCELED;
|
|
4443
|
+
return;
|
|
4444
|
+
case "failed":
|
|
4445
|
+
default:
|
|
4446
|
+
process.exitCode = EXIT_FAILED;
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
function createPipelineWaitCommand() {
|
|
4450
|
+
const command = new Command13("wait");
|
|
4451
|
+
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) => {
|
|
4452
|
+
validateOrgProjectPair(options);
|
|
4453
|
+
const runId = parsePositiveId(runIdRaw);
|
|
4454
|
+
if (runId === null) {
|
|
4455
|
+
writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
|
|
4456
|
+
return;
|
|
4457
|
+
}
|
|
4458
|
+
const timeoutSec = options.timeout === void 0 ? 1800 : Number(options.timeout);
|
|
4459
|
+
const pollSec = options.pollInterval === void 0 ? 5 : Number(options.pollInterval);
|
|
4460
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
|
|
4461
|
+
writeError2(`Invalid --timeout "${options.timeout}"; expected a non-negative number.`);
|
|
4462
|
+
return;
|
|
4463
|
+
}
|
|
4464
|
+
if (!Number.isFinite(pollSec) || pollSec <= 0) {
|
|
4465
|
+
writeError2(`Invalid --poll-interval "${options.pollInterval}"; expected a positive number.`);
|
|
4466
|
+
return;
|
|
4467
|
+
}
|
|
4468
|
+
let context;
|
|
4469
|
+
try {
|
|
4470
|
+
const resolved = await resolvePipelineContext(options);
|
|
4471
|
+
context = resolved.context;
|
|
4472
|
+
const deadline = Date.now() + timeoutSec * 1e3;
|
|
4473
|
+
let waitResult = null;
|
|
4474
|
+
for (; ; ) {
|
|
4475
|
+
const status2 = await getBuildStatus(resolved.context, resolved.cred, runId);
|
|
4476
|
+
if (status2.state === "completed") {
|
|
4477
|
+
waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: false };
|
|
4478
|
+
break;
|
|
4479
|
+
}
|
|
4480
|
+
if (Date.now() >= deadline) {
|
|
4481
|
+
waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: true };
|
|
4482
|
+
break;
|
|
4483
|
+
}
|
|
4484
|
+
await sleep(pollSec * 1e3);
|
|
4485
|
+
}
|
|
4486
|
+
applyWaitExitCode(waitResult);
|
|
4487
|
+
if (options.json) {
|
|
4488
|
+
process.stdout.write(`${JSON.stringify(waitResult, null, 2)}
|
|
4489
|
+
`);
|
|
4490
|
+
return;
|
|
4491
|
+
}
|
|
4492
|
+
if (waitResult.timedOut) {
|
|
4493
|
+
process.stdout.write(`Run ${runId} did not finish within ${timeoutSec}s (still ${waitResult.state}).
|
|
4494
|
+
`);
|
|
4495
|
+
} else {
|
|
4496
|
+
process.stdout.write(`Run ${runId} finished: ${waitResult.result ?? waitResult.state}.
|
|
4497
|
+
`);
|
|
4498
|
+
}
|
|
4499
|
+
} catch (err) {
|
|
4500
|
+
handlePipelineError(err, context);
|
|
4501
|
+
}
|
|
4502
|
+
});
|
|
4503
|
+
return command;
|
|
4504
|
+
}
|
|
4505
|
+
function timelineRows(items, available) {
|
|
4506
|
+
if (!available) {
|
|
4507
|
+
return [" unavailable"];
|
|
4508
|
+
}
|
|
4509
|
+
if (items.length === 0) {
|
|
4510
|
+
return [" (none)"];
|
|
4511
|
+
}
|
|
4512
|
+
return items.map((item) => ` - ${item.name} [${item.result ?? item.state}]`);
|
|
4513
|
+
}
|
|
4514
|
+
function formatDuration(totalSeconds) {
|
|
4515
|
+
const h = Math.floor(totalSeconds / 3600);
|
|
4516
|
+
const m = Math.floor(totalSeconds % 3600 / 60);
|
|
4517
|
+
const s = totalSeconds % 60;
|
|
4518
|
+
if (h > 0) return `${h}h${m}m${s}s`;
|
|
4519
|
+
if (m > 0) return `${m}m${s}s`;
|
|
4520
|
+
return `${s}s`;
|
|
4521
|
+
}
|
|
4522
|
+
function errorRows(detail) {
|
|
4523
|
+
if (!detail.errorsAvailable) {
|
|
4524
|
+
return [" unavailable"];
|
|
4525
|
+
}
|
|
4526
|
+
if (detail.errors.length === 0) {
|
|
4527
|
+
return [" (none)"];
|
|
4528
|
+
}
|
|
4529
|
+
return detail.errors.map((error) => {
|
|
4530
|
+
const source = error.source ? `[${error.source}] ` : "";
|
|
4531
|
+
return ` - ${source}${error.message}`;
|
|
4532
|
+
});
|
|
4533
|
+
}
|
|
4534
|
+
function failedTestRow(test) {
|
|
4535
|
+
if (!test.errorMessage) {
|
|
4536
|
+
return ` - ${test.name}`;
|
|
4537
|
+
}
|
|
4538
|
+
const firstLine = test.errorMessage.split("\n", 1)[0].trim();
|
|
4539
|
+
return ` - ${test.name}: ${firstLine}`;
|
|
4540
|
+
}
|
|
4541
|
+
function testRows(detail) {
|
|
4542
|
+
if (!detail.testsAvailable) {
|
|
4543
|
+
return [" unavailable"];
|
|
4544
|
+
}
|
|
4545
|
+
if (detail.tests.present) {
|
|
4546
|
+
return [
|
|
4547
|
+
` ${detail.tests.failed} failing of ${detail.tests.total}`,
|
|
4548
|
+
...detail.tests.failedTests.map(failedTestRow)
|
|
4549
|
+
];
|
|
4550
|
+
}
|
|
4551
|
+
return [" no tests present"];
|
|
4552
|
+
}
|
|
4553
|
+
function formatRunDetail(detail) {
|
|
4554
|
+
const status2 = detail.result ? `${detail.state}/${detail.result}` : detail.state;
|
|
4555
|
+
const name = detail.name ? ` ${detail.name}` : "";
|
|
4556
|
+
const duration = detail.durationSeconds == null ? "\u2014" : formatDuration(detail.durationSeconds);
|
|
4557
|
+
return [
|
|
4558
|
+
`Run #${detail.id} [${status2}]${name}`,
|
|
4559
|
+
`Queued: ${detail.createdDate ?? "\u2014"} Started: ${detail.startedDate ?? "\u2014"} Finished: ${detail.finishedDate ?? "\u2014"}`,
|
|
4560
|
+
`Duration: ${duration} Reason: ${detail.reason ?? "\u2014"} Requested for: ${detail.requestedFor ?? "\u2014"}`,
|
|
4561
|
+
`Branch: ${formatBranchName2(detail.sourceBranch)} Commit: ${detail.sourceCommit ?? "unavailable"}`,
|
|
4562
|
+
...detail.webUrl ? [`Link: ${detail.webUrl}`] : [],
|
|
4563
|
+
"",
|
|
4564
|
+
"Stages:",
|
|
4565
|
+
...timelineRows(detail.stages, detail.errorsAvailable),
|
|
4566
|
+
"",
|
|
4567
|
+
"Jobs:",
|
|
4568
|
+
...timelineRows(detail.jobs, detail.errorsAvailable),
|
|
4569
|
+
"",
|
|
4570
|
+
"Errors:",
|
|
4571
|
+
...errorRows(detail),
|
|
4572
|
+
"",
|
|
4573
|
+
"Tests:",
|
|
4574
|
+
...testRows(detail)
|
|
4575
|
+
].join("\n");
|
|
4576
|
+
}
|
|
4577
|
+
function createPipelineGetRunDetailCommand() {
|
|
4578
|
+
const command = new Command13("get-run-detail");
|
|
4579
|
+
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) => {
|
|
4580
|
+
validateOrgProjectPair(options);
|
|
4581
|
+
const runId = parsePositiveId(runIdRaw);
|
|
4582
|
+
if (runId === null) {
|
|
4583
|
+
writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4586
|
+
let context;
|
|
4587
|
+
try {
|
|
4588
|
+
const resolved = await resolvePipelineContext(options);
|
|
4589
|
+
context = resolved.context;
|
|
4590
|
+
const detail = await getRunDetail(resolved.context, resolved.cred, runId);
|
|
4591
|
+
if (options.json) {
|
|
4592
|
+
process.stdout.write(`${JSON.stringify(detail, null, 2)}
|
|
4593
|
+
`);
|
|
4594
|
+
return;
|
|
4595
|
+
}
|
|
4596
|
+
process.stdout.write(`${formatRunDetail(detail)}
|
|
4597
|
+
`);
|
|
4598
|
+
} catch (err) {
|
|
4599
|
+
handlePipelineError(err, context);
|
|
4600
|
+
}
|
|
4601
|
+
});
|
|
4602
|
+
return command;
|
|
4603
|
+
}
|
|
4604
|
+
function grepWithContext(lines, grep, context) {
|
|
4605
|
+
const include = /* @__PURE__ */ new Set();
|
|
4606
|
+
lines.forEach((line, i) => {
|
|
4607
|
+
if (grep.test(line)) {
|
|
4608
|
+
for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) {
|
|
4609
|
+
include.add(j);
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
});
|
|
4613
|
+
const selected = [];
|
|
4614
|
+
let prev = -1;
|
|
4615
|
+
for (const i of [...include].sort((a, b) => a - b)) {
|
|
4616
|
+
if (selected.length > 0 && i > prev + 1) {
|
|
4617
|
+
selected.push("--");
|
|
4618
|
+
}
|
|
4619
|
+
selected.push(lines[i]);
|
|
4620
|
+
prev = i;
|
|
4621
|
+
}
|
|
4622
|
+
return selected;
|
|
4623
|
+
}
|
|
4624
|
+
function filterLogLines(content, grep, tail, context) {
|
|
4625
|
+
let lines = content.split("\n");
|
|
4626
|
+
if (lines.at(-1) === "") {
|
|
4627
|
+
lines.pop();
|
|
4628
|
+
}
|
|
4629
|
+
if (grep) {
|
|
4630
|
+
lines = context > 0 ? grepWithContext(lines, grep, context) : lines.filter((line) => grep.test(line));
|
|
4631
|
+
}
|
|
4632
|
+
if (tail !== void 0 && lines.length > tail) {
|
|
4633
|
+
lines = lines.slice(-tail);
|
|
4634
|
+
}
|
|
4635
|
+
return lines;
|
|
4636
|
+
}
|
|
4637
|
+
function parseLogFilters(options) {
|
|
4638
|
+
if (options.logId !== void 0 && options.step !== void 0) {
|
|
4639
|
+
writeError2("Use either --log-id or --step, not both.");
|
|
4640
|
+
return null;
|
|
4641
|
+
}
|
|
4642
|
+
const selectsSingleLog = options.logId !== void 0 || options.step !== void 0;
|
|
4643
|
+
const slices = options.tail !== void 0 || options.grep !== void 0 || options.context !== void 0;
|
|
4644
|
+
if (slices && !selectsSingleLog) {
|
|
4645
|
+
writeError2("--tail, --grep, and --context require --log-id or --step.");
|
|
4646
|
+
return null;
|
|
4647
|
+
}
|
|
4648
|
+
if (options.context !== void 0 && options.grep === void 0) {
|
|
4649
|
+
writeError2("--context requires --grep.");
|
|
4650
|
+
return null;
|
|
4651
|
+
}
|
|
4652
|
+
const tail = parseOptionalCount(options.tail, "--tail");
|
|
4653
|
+
if (tail === null) return null;
|
|
4654
|
+
const contextLines = parseOptionalCount(options.context, "--context");
|
|
4655
|
+
if (contextLines === null) return null;
|
|
4656
|
+
let grep;
|
|
4657
|
+
if (options.grep !== void 0) {
|
|
4658
|
+
try {
|
|
4659
|
+
grep = new RegExp(options.grep);
|
|
4660
|
+
} catch {
|
|
4661
|
+
writeError2(`Invalid --grep "${options.grep}"; expected a valid regular expression.`);
|
|
4662
|
+
return null;
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
return { tail, contextLines: contextLines ?? 0, grep };
|
|
4666
|
+
}
|
|
4667
|
+
function chooseStepLog(logs, step, runId) {
|
|
4668
|
+
const needle = step.toLowerCase();
|
|
4669
|
+
const matches = logs.filter((l) => l.step?.toLowerCase().includes(needle));
|
|
4670
|
+
const exact = matches.filter((l) => l.step?.toLowerCase() === needle);
|
|
4671
|
+
const chosen = exact.length === 1 ? exact : matches;
|
|
4672
|
+
if (chosen.length === 0) {
|
|
4673
|
+
writeError2(`No log matches step "${step}" in run ${runId}.`);
|
|
4674
|
+
return null;
|
|
4675
|
+
}
|
|
4676
|
+
if (chosen.length > 1) {
|
|
4677
|
+
const candidates = chosen.map((l) => `${l.id} (${l.step})`).join(", ");
|
|
4678
|
+
writeError2(`Step "${step}" matches multiple logs: ${candidates}. Be more specific or use --log-id.`);
|
|
4679
|
+
return null;
|
|
4680
|
+
}
|
|
4681
|
+
return chosen[0].id;
|
|
4682
|
+
}
|
|
4683
|
+
async function resolveRequestedLogId(resolved, runId, options) {
|
|
4684
|
+
if (options.logId !== void 0) {
|
|
4685
|
+
const parsed = parsePositiveId(options.logId);
|
|
4686
|
+
if (parsed === null) {
|
|
4687
|
+
writeError2(`Invalid --log-id "${options.logId}"; expected a positive integer.`);
|
|
4688
|
+
return null;
|
|
4689
|
+
}
|
|
4690
|
+
return parsed;
|
|
4691
|
+
}
|
|
4692
|
+
if (options.step === void 0) {
|
|
4693
|
+
return void 0;
|
|
4694
|
+
}
|
|
4695
|
+
const allLogs = await getRunLogs(resolved.context, resolved.cred, runId);
|
|
4696
|
+
return chooseStepLog(allLogs, options.step, runId);
|
|
4697
|
+
}
|
|
4698
|
+
function printSingleLog(content, filters) {
|
|
4699
|
+
if (filters.grep !== void 0 || filters.tail !== void 0) {
|
|
4700
|
+
const lines = filterLogLines(content, filters.grep, filters.tail, filters.contextLines);
|
|
4701
|
+
if (lines.length > 0) {
|
|
4702
|
+
process.stdout.write(`${lines.join("\n")}
|
|
4703
|
+
`);
|
|
4704
|
+
}
|
|
4705
|
+
return;
|
|
4706
|
+
}
|
|
4707
|
+
process.stdout.write(content.endsWith("\n") ? content : `${content}
|
|
4708
|
+
`);
|
|
4709
|
+
}
|
|
4710
|
+
function createPipelineLogsCommand() {
|
|
4711
|
+
const command = new Command13("logs");
|
|
4712
|
+
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) => {
|
|
4713
|
+
validateOrgProjectPair(options);
|
|
4714
|
+
const runId = parsePositiveId(runIdRaw);
|
|
4715
|
+
if (runId === null) {
|
|
4716
|
+
writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
|
|
4717
|
+
return;
|
|
4718
|
+
}
|
|
4719
|
+
const filters = parseLogFilters(options);
|
|
4720
|
+
if (filters === null) {
|
|
4721
|
+
return;
|
|
4722
|
+
}
|
|
4723
|
+
let context;
|
|
4724
|
+
try {
|
|
4725
|
+
const resolved = await resolvePipelineContext(options);
|
|
4726
|
+
context = resolved.context;
|
|
4727
|
+
const logId = await resolveRequestedLogId(resolved, runId, options);
|
|
4728
|
+
if (logId === null) {
|
|
4729
|
+
return;
|
|
4730
|
+
}
|
|
4731
|
+
if (logId !== void 0) {
|
|
4732
|
+
const content = await getRunLog(resolved.context, resolved.cred, runId, logId);
|
|
4733
|
+
printSingleLog(content, filters);
|
|
4734
|
+
return;
|
|
4735
|
+
}
|
|
4736
|
+
const logs = await getRunLogs(resolved.context, resolved.cred, runId);
|
|
4737
|
+
if (options.json) {
|
|
4738
|
+
process.stdout.write(`${JSON.stringify(logs, null, 2)}
|
|
4739
|
+
`);
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
if (logs.length === 0) {
|
|
4743
|
+
process.stdout.write(`No logs found for run ${runId}.
|
|
4744
|
+
`);
|
|
4745
|
+
return;
|
|
4746
|
+
}
|
|
4747
|
+
const rows = logs.map((l) => [
|
|
4748
|
+
String(l.id),
|
|
4749
|
+
l.createdOn ?? "\u2014",
|
|
4750
|
+
l.lineCount == null ? "" : `${l.lineCount} lines`,
|
|
4751
|
+
l.step ?? ""
|
|
4752
|
+
]);
|
|
4753
|
+
process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
|
|
4754
|
+
`);
|
|
4755
|
+
} catch (err) {
|
|
4756
|
+
handlePipelineError(err, context);
|
|
4757
|
+
}
|
|
4758
|
+
});
|
|
4759
|
+
return command;
|
|
4760
|
+
}
|
|
4761
|
+
function createPipelineTestsCommand() {
|
|
4762
|
+
const command = new Command13("tests");
|
|
4763
|
+
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) => {
|
|
4764
|
+
validateOrgProjectPair(options);
|
|
4765
|
+
const runId = parsePositiveId(runIdRaw);
|
|
4766
|
+
if (runId === null) {
|
|
4767
|
+
writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
|
|
4768
|
+
return;
|
|
4769
|
+
}
|
|
4770
|
+
let context;
|
|
4771
|
+
try {
|
|
4772
|
+
const resolved = await resolvePipelineContext(options);
|
|
4773
|
+
context = resolved.context;
|
|
4774
|
+
const summary = await getTestSummary(resolved.context, resolved.cred, runId);
|
|
4775
|
+
const failedTests = summary.failed > 0 ? await getFailedTests(resolved.context, resolved.cred, runId) : [];
|
|
4776
|
+
if (options.json) {
|
|
4777
|
+
process.stdout.write(`${JSON.stringify({ ...summary, failedTests }, null, 2)}
|
|
4778
|
+
`);
|
|
4779
|
+
return;
|
|
4780
|
+
}
|
|
4781
|
+
if (!summary.present) {
|
|
4782
|
+
process.stdout.write(`No test results published for run ${runId}.
|
|
4783
|
+
`);
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4786
|
+
if (!options.failed) {
|
|
4787
|
+
process.stdout.write(`Run #${runId}: ${summary.failed} failing of ${summary.total} tests
|
|
4788
|
+
`);
|
|
4789
|
+
}
|
|
4790
|
+
if (failedTests.length > 0) {
|
|
4791
|
+
process.stdout.write(`${failedTests.map(failedTestRow).join("\n")}
|
|
4792
|
+
`);
|
|
4793
|
+
} else if (options.failed) {
|
|
4794
|
+
process.stdout.write("No failing tests.\n");
|
|
4795
|
+
}
|
|
4796
|
+
} catch (err) {
|
|
4797
|
+
handlePipelineError(err, context);
|
|
4798
|
+
}
|
|
4799
|
+
});
|
|
4800
|
+
return command;
|
|
4801
|
+
}
|
|
4802
|
+
function parseParameters(values) {
|
|
4803
|
+
const result = {};
|
|
4804
|
+
for (const entry of values ?? []) {
|
|
4805
|
+
const eq = entry.indexOf("=");
|
|
4806
|
+
if (eq <= 0) {
|
|
4807
|
+
return null;
|
|
4808
|
+
}
|
|
4809
|
+
const key = entry.slice(0, eq);
|
|
4810
|
+
const value = entry.slice(eq + 1);
|
|
4811
|
+
result[key] = value;
|
|
4812
|
+
}
|
|
4813
|
+
return result;
|
|
4814
|
+
}
|
|
4815
|
+
function collectParameter(value, previous) {
|
|
4816
|
+
return previous.concat([value]);
|
|
4817
|
+
}
|
|
4818
|
+
function createPipelineStartCommand() {
|
|
4819
|
+
const command = new Command13("start");
|
|
4820
|
+
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) => {
|
|
4821
|
+
validateOrgProjectPair(options);
|
|
4822
|
+
const defId = parsePositiveId(defIdRaw);
|
|
4823
|
+
if (defId === null) {
|
|
4824
|
+
writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
|
|
4825
|
+
return;
|
|
4826
|
+
}
|
|
4827
|
+
const parameters = parseParameters(options.parameter);
|
|
4828
|
+
if (parameters === null) {
|
|
4829
|
+
writeError2("Invalid --parameter; expected key=value.");
|
|
4830
|
+
return;
|
|
4831
|
+
}
|
|
4832
|
+
let context;
|
|
4833
|
+
try {
|
|
4834
|
+
const resolved = await resolvePipelineContext(options);
|
|
4835
|
+
context = resolved.context;
|
|
4836
|
+
const result = await runPipeline(resolved.context, resolved.cred, defId, {
|
|
4837
|
+
branch: options.branch,
|
|
4838
|
+
parameters
|
|
4839
|
+
});
|
|
4840
|
+
if (options.json) {
|
|
4841
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
4842
|
+
`);
|
|
4843
|
+
return;
|
|
4844
|
+
}
|
|
4845
|
+
process.stdout.write(`Queued run #${result.id} [${result.state}]
|
|
4846
|
+
${result.webUrl ?? "\u2014"}
|
|
4847
|
+
`);
|
|
4848
|
+
} catch (err) {
|
|
4849
|
+
handlePipelineError(err, context);
|
|
4850
|
+
}
|
|
4851
|
+
});
|
|
4852
|
+
return command;
|
|
4853
|
+
}
|
|
4854
|
+
function createPipelineCommand() {
|
|
4855
|
+
const command = new Command13("pipeline");
|
|
4856
|
+
command.description("Manage Azure DevOps pipelines");
|
|
4857
|
+
command.addCommand(createPipelineListCommand());
|
|
4858
|
+
command.addCommand(createPipelineGetRunsCommand());
|
|
4859
|
+
command.addCommand(createPipelineWaitCommand());
|
|
4860
|
+
command.addCommand(createPipelineGetRunDetailCommand());
|
|
4861
|
+
command.addCommand(createPipelineLogsCommand());
|
|
4862
|
+
command.addCommand(createPipelineTestsCommand());
|
|
4863
|
+
command.addCommand(createPipelineStartCommand());
|
|
4864
|
+
return command;
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
// src/commands/comments.ts
|
|
4868
|
+
import { Command as Command14 } from "commander";
|
|
4869
|
+
function writeError3(message) {
|
|
4870
|
+
process.stderr.write(`Error: ${message}
|
|
4871
|
+
`);
|
|
4872
|
+
process.exit(1);
|
|
4873
|
+
}
|
|
4874
|
+
function formatCommentHeader(comment) {
|
|
4875
|
+
const author = comment.author ?? "Unknown";
|
|
4876
|
+
const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
|
|
4877
|
+
return `Comment #${comment.id} by ${author} at ${timestamp}`;
|
|
4878
|
+
}
|
|
4879
|
+
function formatComments(result, convertMarkdown) {
|
|
4880
|
+
const lines = [`Comments for work item #${result.workItemId}`];
|
|
4881
|
+
for (const comment of result.comments) {
|
|
4882
|
+
const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
|
|
4883
|
+
lines.push("", formatCommentHeader(comment), text);
|
|
4884
|
+
}
|
|
4885
|
+
return lines.join("\n");
|
|
4886
|
+
}
|
|
4887
|
+
function createCommentsListCommand() {
|
|
4888
|
+
const command = new Command14("list");
|
|
4889
|
+
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) => {
|
|
4890
|
+
validateOrgProjectPair(options);
|
|
4891
|
+
const id = parseWorkItemId(idStr);
|
|
4892
|
+
let context;
|
|
4893
|
+
try {
|
|
4894
|
+
context = resolveContext(options);
|
|
4895
|
+
const credential = await requireAuthCredential(context.org);
|
|
4896
|
+
const result = await listWorkItemComments(context, id, credential);
|
|
4897
|
+
if (options.json) {
|
|
4898
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
4899
|
+
`);
|
|
4900
|
+
return;
|
|
4901
|
+
}
|
|
4902
|
+
if (result.comments.length === 0) {
|
|
4903
|
+
process.stdout.write(`Work item #${id} has no comments.
|
|
4904
|
+
`);
|
|
4905
|
+
return;
|
|
4906
|
+
}
|
|
4907
|
+
process.stdout.write(`${formatComments(result, options.markdown === true)}
|
|
4908
|
+
`);
|
|
4909
|
+
} catch (err) {
|
|
4910
|
+
handleCommandError(err, id, context, "read");
|
|
4911
|
+
}
|
|
4912
|
+
});
|
|
4913
|
+
return command;
|
|
2937
4914
|
}
|
|
2938
4915
|
function createCommentsAddCommand() {
|
|
2939
|
-
const command = new
|
|
4916
|
+
const command = new Command14("add");
|
|
2940
4917
|
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) => {
|
|
2941
4918
|
validateOrgProjectPair(options);
|
|
2942
4919
|
const id = parseWorkItemId(idStr);
|
|
2943
4920
|
if (text.trim() === "") {
|
|
2944
|
-
|
|
4921
|
+
writeError3("Comment text must be a non-empty string.");
|
|
2945
4922
|
}
|
|
2946
4923
|
let context;
|
|
2947
4924
|
try {
|
|
2948
4925
|
context = resolveContext(options);
|
|
2949
|
-
const credential = await
|
|
4926
|
+
const credential = await requireAuthCredential(context.org);
|
|
2950
4927
|
const format = options.markdown === true ? "markdown" : "html";
|
|
2951
|
-
const result = await addWorkItemComment(context, id, credential
|
|
4928
|
+
const result = await addWorkItemComment(context, id, credential, text, format);
|
|
2952
4929
|
if (options.json) {
|
|
2953
4930
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2954
4931
|
`);
|
|
@@ -2963,7 +4940,7 @@ function createCommentsAddCommand() {
|
|
|
2963
4940
|
return command;
|
|
2964
4941
|
}
|
|
2965
4942
|
function createCommentsCommand() {
|
|
2966
|
-
const command = new
|
|
4943
|
+
const command = new Command14("comments");
|
|
2967
4944
|
command.description("Manage Azure DevOps work item comments");
|
|
2968
4945
|
command.addCommand(createCommentsListCommand());
|
|
2969
4946
|
command.addCommand(createCommentsAddCommand());
|
|
@@ -2971,12 +4948,12 @@ function createCommentsCommand() {
|
|
|
2971
4948
|
}
|
|
2972
4949
|
|
|
2973
4950
|
// src/commands/download-attachment.ts
|
|
2974
|
-
import { Command as
|
|
2975
|
-
import { writeFile } from "fs/promises";
|
|
2976
|
-
import { existsSync as
|
|
2977
|
-
import { join as
|
|
4951
|
+
import { Command as Command15 } from "commander";
|
|
4952
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
4953
|
+
import { existsSync as existsSync5 } from "fs";
|
|
4954
|
+
import { join as join3 } from "path";
|
|
2978
4955
|
function createDownloadAttachmentCommand() {
|
|
2979
|
-
const command = new
|
|
4956
|
+
const command = new Command15("download-attachment");
|
|
2980
4957
|
command.description("Download an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to download").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--output <dir>", "target directory for the downloaded file").action(
|
|
2981
4958
|
async (idStr, filename, options) => {
|
|
2982
4959
|
const id = parseWorkItemId(idStr);
|
|
@@ -2984,14 +4961,14 @@ function createDownloadAttachmentCommand() {
|
|
|
2984
4961
|
let context;
|
|
2985
4962
|
try {
|
|
2986
4963
|
context = resolveContext(options);
|
|
2987
|
-
const credential = await
|
|
4964
|
+
const credential = await requireAuthCredential(context.org);
|
|
2988
4965
|
const outputDir = options.output ?? ".";
|
|
2989
|
-
if (!
|
|
4966
|
+
if (!existsSync5(outputDir)) {
|
|
2990
4967
|
process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
|
|
2991
4968
|
`);
|
|
2992
4969
|
process.exit(1);
|
|
2993
4970
|
}
|
|
2994
|
-
const workItem = await getWorkItem(context, id, credential
|
|
4971
|
+
const workItem = await getWorkItem(context, id, credential);
|
|
2995
4972
|
const attachment = workItem.attachments?.find(
|
|
2996
4973
|
(a) => a.name === filename
|
|
2997
4974
|
);
|
|
@@ -3002,9 +4979,9 @@ function createDownloadAttachmentCommand() {
|
|
|
3002
4979
|
);
|
|
3003
4980
|
process.exit(1);
|
|
3004
4981
|
}
|
|
3005
|
-
const data = await downloadAttachment(attachment.url, credential
|
|
3006
|
-
const outputPath =
|
|
3007
|
-
await
|
|
4982
|
+
const data = await downloadAttachment(attachment.url, credential);
|
|
4983
|
+
const outputPath = join3(outputDir, filename);
|
|
4984
|
+
await writeFile2(outputPath, Buffer.from(data));
|
|
3008
4985
|
process.stdout.write(
|
|
3009
4986
|
`Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
|
|
3010
4987
|
`
|
|
@@ -3017,9 +4994,416 @@ function createDownloadAttachmentCommand() {
|
|
|
3017
4994
|
return command;
|
|
3018
4995
|
}
|
|
3019
4996
|
|
|
4997
|
+
// src/commands/relations.ts
|
|
4998
|
+
import { Command as Command16 } from "commander";
|
|
4999
|
+
|
|
5000
|
+
// src/services/relations-client.ts
|
|
5001
|
+
var API_VERSION2 = "7.1";
|
|
5002
|
+
function buildRelationTypesUrl(context) {
|
|
5003
|
+
const url = new URL(
|
|
5004
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workitemrelationtypes`
|
|
5005
|
+
);
|
|
5006
|
+
url.searchParams.set("api-version", API_VERSION2);
|
|
5007
|
+
return url;
|
|
5008
|
+
}
|
|
5009
|
+
function buildWorkItemUrl2(context, id, expand) {
|
|
5010
|
+
const url = new URL(
|
|
5011
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
5012
|
+
);
|
|
5013
|
+
url.searchParams.set("api-version", API_VERSION2);
|
|
5014
|
+
if (expand) url.searchParams.set("$expand", expand);
|
|
5015
|
+
return url;
|
|
5016
|
+
}
|
|
5017
|
+
function buildBatchWorkItemsUrl(context, ids) {
|
|
5018
|
+
const url = new URL(
|
|
5019
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems`
|
|
5020
|
+
);
|
|
5021
|
+
url.searchParams.set("ids", ids.join(","));
|
|
5022
|
+
url.searchParams.set("fields", "System.Id,System.Title");
|
|
5023
|
+
url.searchParams.set("api-version", API_VERSION2);
|
|
5024
|
+
return url;
|
|
5025
|
+
}
|
|
5026
|
+
function mapRelationType(raw) {
|
|
5027
|
+
return {
|
|
5028
|
+
referenceName: raw.referenceName,
|
|
5029
|
+
name: raw.name,
|
|
5030
|
+
usage: raw.attributes?.usage ?? "unknown",
|
|
5031
|
+
enabled: raw.attributes?.enabled !== false,
|
|
5032
|
+
directional: raw.attributes?.directional ?? null
|
|
5033
|
+
};
|
|
5034
|
+
}
|
|
5035
|
+
async function readJsonResponse3(response) {
|
|
5036
|
+
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
|
5037
|
+
return await response.json();
|
|
5038
|
+
}
|
|
5039
|
+
function parseTargetId(url) {
|
|
5040
|
+
const match = /\/workItems\/(\d+)$/i.exec(url);
|
|
5041
|
+
return match ? Number(match[1]) : null;
|
|
5042
|
+
}
|
|
5043
|
+
async function getWorkItemRelationTypes(context, cred) {
|
|
5044
|
+
const url = buildRelationTypesUrl(context);
|
|
5045
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
5046
|
+
headers: authHeaders(cred)
|
|
5047
|
+
});
|
|
5048
|
+
const body = await readJsonResponse3(response);
|
|
5049
|
+
return (body.value ?? []).map(mapRelationType).filter((t) => t.usage === "workItemLink" && t.enabled);
|
|
5050
|
+
}
|
|
5051
|
+
async function resolveRelationType(context, cred, alias) {
|
|
5052
|
+
const types = await getWorkItemRelationTypes(context, cred);
|
|
5053
|
+
const lower = alias.toLowerCase();
|
|
5054
|
+
const match = types.find((t) => t.name.toLowerCase() === lower);
|
|
5055
|
+
if (!match) throw new Error(`UNKNOWN_RELATION_TYPE:${alias}`);
|
|
5056
|
+
return match;
|
|
5057
|
+
}
|
|
5058
|
+
async function getWorkItemWithRelations(context, cred, id) {
|
|
5059
|
+
const url = buildWorkItemUrl2(context, id, "relations");
|
|
5060
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
5061
|
+
headers: authHeaders(cred)
|
|
5062
|
+
});
|
|
5063
|
+
return readJsonResponse3(response);
|
|
5064
|
+
}
|
|
5065
|
+
async function addWorkItemRelation(context, cred, type, id1, id2) {
|
|
5066
|
+
if (id1 === id2) throw new Error("SELF_RELATION");
|
|
5067
|
+
const relType = await resolveRelationType(context, cred, type);
|
|
5068
|
+
const workItem = await getWorkItemWithRelations(context, cred, id1);
|
|
5069
|
+
const targetUrl = `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workItems/${id2}`;
|
|
5070
|
+
const existing = (workItem.relations ?? []).find(
|
|
5071
|
+
(r) => r.rel === relType.referenceName && r.url.toLowerCase() === targetUrl.toLowerCase()
|
|
5072
|
+
);
|
|
5073
|
+
if (existing) {
|
|
5074
|
+
return { status: "already_exists", type: relType.name, referenceName: relType.referenceName, id1, id2 };
|
|
5075
|
+
}
|
|
5076
|
+
const patchUrl = buildWorkItemUrl2(context, id1);
|
|
5077
|
+
const response = await fetchWithErrors(patchUrl.toString(), {
|
|
5078
|
+
method: "PATCH",
|
|
5079
|
+
headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
|
|
5080
|
+
body: JSON.stringify([
|
|
5081
|
+
{ op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
|
|
5082
|
+
])
|
|
5083
|
+
});
|
|
5084
|
+
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
|
5085
|
+
return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
|
|
5086
|
+
}
|
|
5087
|
+
async function removeWorkItemRelation(context, cred, type, id1, id2) {
|
|
5088
|
+
if (id1 === id2) throw new Error("SELF_RELATION");
|
|
5089
|
+
const relType = await resolveRelationType(context, cred, type);
|
|
5090
|
+
const workItem = await getWorkItemWithRelations(context, cred, id1);
|
|
5091
|
+
const relations = workItem.relations ?? [];
|
|
5092
|
+
const index = relations.findIndex(
|
|
5093
|
+
(r) => r.rel === relType.referenceName && parseTargetId(r.url) === id2
|
|
5094
|
+
);
|
|
5095
|
+
if (index === -1) {
|
|
5096
|
+
return { status: "not_found", type: relType.name, referenceName: relType.referenceName, id1, id2 };
|
|
5097
|
+
}
|
|
5098
|
+
const patchUrl = buildWorkItemUrl2(context, id1);
|
|
5099
|
+
const response = await fetchWithErrors(patchUrl.toString(), {
|
|
5100
|
+
method: "PATCH",
|
|
5101
|
+
headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
|
|
5102
|
+
body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
|
|
5103
|
+
});
|
|
5104
|
+
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
|
5105
|
+
return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
|
|
5106
|
+
}
|
|
5107
|
+
async function listWorkItemRelations(context, cred, id) {
|
|
5108
|
+
const workItem = await getWorkItemWithRelations(context, cred, id);
|
|
5109
|
+
const allRelations = workItem.relations ?? [];
|
|
5110
|
+
const wiRelations = allRelations.filter(
|
|
5111
|
+
(r) => !r.rel.startsWith("AttachedFile") && !r.rel.startsWith("Hyperlink") && !r.rel.startsWith("ArtifactLink")
|
|
5112
|
+
);
|
|
5113
|
+
if (wiRelations.length === 0) {
|
|
5114
|
+
return { workItemId: id, relations: [] };
|
|
5115
|
+
}
|
|
5116
|
+
const types = await getWorkItemRelationTypes(context, cred);
|
|
5117
|
+
const typeNameMap = new Map(types.map((t) => [t.referenceName, t.name]));
|
|
5118
|
+
const targetIds = wiRelations.map((r) => parseTargetId(r.url)).filter((n) => n !== null);
|
|
5119
|
+
const titleMap = /* @__PURE__ */ new Map();
|
|
5120
|
+
if (targetIds.length > 0) {
|
|
5121
|
+
try {
|
|
5122
|
+
const batchUrl = buildBatchWorkItemsUrl(context, targetIds);
|
|
5123
|
+
const batchResponse = await fetchWithErrors(batchUrl.toString(), {
|
|
5124
|
+
headers: authHeaders(cred)
|
|
5125
|
+
});
|
|
5126
|
+
const batchBody = await readJsonResponse3(batchResponse);
|
|
5127
|
+
for (const item of batchBody.value ?? []) {
|
|
5128
|
+
titleMap.set(item.id, item.fields["System.Title"] ?? "");
|
|
5129
|
+
}
|
|
5130
|
+
} catch {
|
|
5131
|
+
}
|
|
5132
|
+
}
|
|
5133
|
+
const relations = wiRelations.map((r) => {
|
|
5134
|
+
const targetId = parseTargetId(r.url);
|
|
5135
|
+
return {
|
|
5136
|
+
rel: r.rel,
|
|
5137
|
+
relName: typeNameMap.get(r.rel) ?? r.rel,
|
|
5138
|
+
targetId: targetId ?? 0,
|
|
5139
|
+
targetTitle: targetId !== null ? titleMap.get(targetId) ?? null : null,
|
|
5140
|
+
targetUrl: r.url,
|
|
5141
|
+
comment: r.attributes?.comment ?? null
|
|
5142
|
+
};
|
|
5143
|
+
});
|
|
5144
|
+
return { workItemId: id, relations };
|
|
5145
|
+
}
|
|
5146
|
+
|
|
5147
|
+
// src/commands/relations.ts
|
|
5148
|
+
function addCommonOptions(cmd) {
|
|
5149
|
+
return cmd.option("--json", "Output as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
|
|
5150
|
+
}
|
|
5151
|
+
async function resolveCredentials(opts) {
|
|
5152
|
+
const context = resolveContext(opts);
|
|
5153
|
+
const cred = await requireAuthCredential(context.org);
|
|
5154
|
+
return { context, cred };
|
|
5155
|
+
}
|
|
5156
|
+
function formatRelationTypes(types) {
|
|
5157
|
+
if (types.length === 0) return "No work item relation types found.";
|
|
5158
|
+
const nameWidth = Math.max(...types.map((t) => t.name.length), 4);
|
|
5159
|
+
const lines = ["Available work item relation types:", ""];
|
|
5160
|
+
for (const t of types) {
|
|
5161
|
+
lines.push(`${t.name.padEnd(nameWidth + 2)}${t.referenceName}`);
|
|
5162
|
+
}
|
|
5163
|
+
return lines.join("\n");
|
|
5164
|
+
}
|
|
5165
|
+
function formatRelationsList(workItemId, relations) {
|
|
5166
|
+
if (relations.length === 0) return `Work item #${workItemId} has no relations.`;
|
|
5167
|
+
const typeWidth = Math.max(...relations.map((r) => r.relName.length), 4) + 2;
|
|
5168
|
+
const idWidth = Math.max(...relations.map((r) => String(r.targetId).length), 4) + 1;
|
|
5169
|
+
const lines = [`Relations for work item #${workItemId}:`, ""];
|
|
5170
|
+
for (const r of relations) {
|
|
5171
|
+
const typeLabel = `[${r.relName}]`.padEnd(typeWidth);
|
|
5172
|
+
const idLabel = `#${r.targetId}`.padEnd(idWidth);
|
|
5173
|
+
lines.push(`${typeLabel} ${idLabel} ${r.targetTitle ?? "(unknown)"}`);
|
|
5174
|
+
}
|
|
5175
|
+
return lines.join("\n");
|
|
5176
|
+
}
|
|
5177
|
+
function handleRelationError(err, id1) {
|
|
5178
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5179
|
+
if (msg === "SELF_RELATION") {
|
|
5180
|
+
process.stderr.write(`Error: cannot relate a work item to itself (#${id1}).
|
|
5181
|
+
`);
|
|
5182
|
+
} else if (msg.startsWith("UNKNOWN_RELATION_TYPE:")) {
|
|
5183
|
+
const name = msg.replace("UNKNOWN_RELATION_TYPE:", "");
|
|
5184
|
+
process.stderr.write(
|
|
5185
|
+
`Error: unknown relation type "${name}". Run 'azdo relations types' to see valid names.
|
|
5186
|
+
`
|
|
5187
|
+
);
|
|
5188
|
+
} else if (msg.startsWith("NOT_FOUND")) {
|
|
5189
|
+
const target = id1 !== void 0 ? id1 : "unknown";
|
|
5190
|
+
process.stderr.write(`Error: work item #${target} not found.
|
|
5191
|
+
`);
|
|
5192
|
+
} else if (msg === "AUTH_FAILED") {
|
|
5193
|
+
process.stderr.write(
|
|
5194
|
+
`Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
|
|
5195
|
+
`
|
|
5196
|
+
);
|
|
5197
|
+
} else {
|
|
5198
|
+
process.stderr.write(`Error: ${msg}
|
|
5199
|
+
`);
|
|
5200
|
+
}
|
|
5201
|
+
process.exit(1);
|
|
5202
|
+
}
|
|
5203
|
+
function parsePositiveInt(value, label) {
|
|
5204
|
+
const n = Number(value);
|
|
5205
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
5206
|
+
process.stderr.write(`Error: ${label} must be a positive integer.
|
|
5207
|
+
`);
|
|
5208
|
+
process.exit(1);
|
|
5209
|
+
}
|
|
5210
|
+
return n;
|
|
5211
|
+
}
|
|
5212
|
+
function createRelationsCommand() {
|
|
5213
|
+
const relations = new Command16("relations").description("Manage work item relations");
|
|
5214
|
+
addCommonOptions(
|
|
5215
|
+
relations.command("types").description("List all available work item relation types")
|
|
5216
|
+
).action(async (opts) => {
|
|
5217
|
+
try {
|
|
5218
|
+
const { context, cred } = await resolveCredentials(opts);
|
|
5219
|
+
const types = await getWorkItemRelationTypes(context, cred);
|
|
5220
|
+
if (opts.json) {
|
|
5221
|
+
process.stdout.write(JSON.stringify(types, null, 2) + "\n");
|
|
5222
|
+
} else {
|
|
5223
|
+
process.stdout.write(formatRelationTypes(types) + "\n");
|
|
5224
|
+
}
|
|
5225
|
+
} catch (err) {
|
|
5226
|
+
handleRelationError(err);
|
|
5227
|
+
}
|
|
5228
|
+
});
|
|
5229
|
+
addCommonOptions(
|
|
5230
|
+
relations.command("add <type> <id1> <id2>").description("Add a directed relation from work item <id1> to <id2>")
|
|
5231
|
+
).action(async (type, id1Str, id2Str, opts) => {
|
|
5232
|
+
const id1 = parsePositiveInt(id1Str, "id1");
|
|
5233
|
+
const id2 = parsePositiveInt(id2Str, "id2");
|
|
5234
|
+
try {
|
|
5235
|
+
const { context, cred } = await resolveCredentials(opts);
|
|
5236
|
+
const result = await addWorkItemRelation(context, cred, type, id1, id2);
|
|
5237
|
+
if (opts.json) {
|
|
5238
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
5239
|
+
} else if (result.status === "already_exists") {
|
|
5240
|
+
process.stdout.write(`Relation already exists: #${id1} --[${result.type}]--> #${id2}
|
|
5241
|
+
`);
|
|
5242
|
+
} else {
|
|
5243
|
+
process.stdout.write(`Added relation: #${id1} --[${result.type}]--> #${id2}
|
|
5244
|
+
`);
|
|
5245
|
+
}
|
|
5246
|
+
} catch (err) {
|
|
5247
|
+
handleRelationError(err, id1);
|
|
5248
|
+
}
|
|
5249
|
+
});
|
|
5250
|
+
addCommonOptions(
|
|
5251
|
+
relations.command("remove <type> <id1> <id2>").description("Remove a directed relation of <type> from work item <id1> to <id2>")
|
|
5252
|
+
).action(async (type, id1Str, id2Str, opts) => {
|
|
5253
|
+
const id1 = parsePositiveInt(id1Str, "id1");
|
|
5254
|
+
const id2 = parsePositiveInt(id2Str, "id2");
|
|
5255
|
+
try {
|
|
5256
|
+
const { context, cred } = await resolveCredentials(opts);
|
|
5257
|
+
const result = await removeWorkItemRelation(context, cred, type, id1, id2);
|
|
5258
|
+
if (opts.json) {
|
|
5259
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
5260
|
+
} else if (result.status === "not_found") {
|
|
5261
|
+
process.stdout.write(`No relation of type '${result.type}' found between #${id1} and #${id2}
|
|
5262
|
+
`);
|
|
5263
|
+
} else {
|
|
5264
|
+
process.stdout.write(`Removed relation: #${id1} --[${result.type}]--> #${id2}
|
|
5265
|
+
`);
|
|
5266
|
+
}
|
|
5267
|
+
} catch (err) {
|
|
5268
|
+
handleRelationError(err, id1);
|
|
5269
|
+
}
|
|
5270
|
+
});
|
|
5271
|
+
addCommonOptions(
|
|
5272
|
+
relations.command("list <id>").description("List all work item link relations on a work item")
|
|
5273
|
+
).action(async (idStr, opts) => {
|
|
5274
|
+
const id = parsePositiveInt(idStr, "id");
|
|
5275
|
+
try {
|
|
5276
|
+
const { context, cred } = await resolveCredentials(opts);
|
|
5277
|
+
const result = await listWorkItemRelations(context, cred, id);
|
|
5278
|
+
if (opts.json) {
|
|
5279
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
5280
|
+
} else {
|
|
5281
|
+
process.stdout.write(formatRelationsList(result.workItemId, result.relations) + "\n");
|
|
5282
|
+
}
|
|
5283
|
+
} catch (err) {
|
|
5284
|
+
handleRelationError(err, id);
|
|
5285
|
+
}
|
|
5286
|
+
});
|
|
5287
|
+
return relations;
|
|
5288
|
+
}
|
|
5289
|
+
|
|
5290
|
+
// src/services/update-check.ts
|
|
5291
|
+
import fs2 from "fs";
|
|
5292
|
+
import path2 from "path";
|
|
5293
|
+
import os from "os";
|
|
5294
|
+
var THROTTLE_MS = 10 * 60 * 1e3;
|
|
5295
|
+
var FETCH_TIMEOUT_MS = 1500;
|
|
5296
|
+
var REGISTRY_URL = "https://registry.npmjs.org/azdo-cli/latest";
|
|
5297
|
+
function getCachePath() {
|
|
5298
|
+
return path2.join(os.homedir(), ".azdo", "update-check.json");
|
|
5299
|
+
}
|
|
5300
|
+
function defaultReadCache() {
|
|
5301
|
+
try {
|
|
5302
|
+
return fs2.readFileSync(getCachePath(), "utf-8");
|
|
5303
|
+
} catch {
|
|
5304
|
+
return null;
|
|
5305
|
+
}
|
|
5306
|
+
}
|
|
5307
|
+
function defaultWriteCache(data) {
|
|
5308
|
+
try {
|
|
5309
|
+
const cachePath = getCachePath();
|
|
5310
|
+
fs2.mkdirSync(path2.dirname(cachePath), { recursive: true });
|
|
5311
|
+
fs2.writeFileSync(cachePath, data);
|
|
5312
|
+
} catch {
|
|
5313
|
+
}
|
|
5314
|
+
}
|
|
5315
|
+
function parseCache(raw) {
|
|
5316
|
+
if (!raw) return null;
|
|
5317
|
+
let parsed;
|
|
5318
|
+
try {
|
|
5319
|
+
parsed = JSON.parse(raw);
|
|
5320
|
+
} catch {
|
|
5321
|
+
return null;
|
|
5322
|
+
}
|
|
5323
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
5324
|
+
const { lastCheck, latestVersion } = parsed;
|
|
5325
|
+
if (typeof lastCheck !== "number" || !Number.isFinite(lastCheck)) return null;
|
|
5326
|
+
if (typeof latestVersion !== "string" || latestVersion.length === 0) return null;
|
|
5327
|
+
return { lastCheck, latestVersion };
|
|
5328
|
+
}
|
|
5329
|
+
async function defaultFetchLatest() {
|
|
5330
|
+
const controller = new AbortController();
|
|
5331
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
5332
|
+
try {
|
|
5333
|
+
const res = await fetch(REGISTRY_URL, { signal: controller.signal });
|
|
5334
|
+
if (!res.ok) return null;
|
|
5335
|
+
const body = await res.json();
|
|
5336
|
+
if (typeof body !== "object" || body === null) return null;
|
|
5337
|
+
const v = body.version;
|
|
5338
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
5339
|
+
} catch {
|
|
5340
|
+
return null;
|
|
5341
|
+
} finally {
|
|
5342
|
+
clearTimeout(timer);
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5345
|
+
function parseVersion(v) {
|
|
5346
|
+
if (typeof v !== "string") return null;
|
|
5347
|
+
const trimmed = v.trim().replace(/^v/, "");
|
|
5348
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(trimmed);
|
|
5349
|
+
if (!match) return null;
|
|
5350
|
+
const release = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
5351
|
+
if (!release.every(Number.isFinite)) return null;
|
|
5352
|
+
return { release, prerelease: match[4] !== void 0 };
|
|
5353
|
+
}
|
|
5354
|
+
function isNewer(latest, current) {
|
|
5355
|
+
const a = parseVersion(latest);
|
|
5356
|
+
const b = parseVersion(current);
|
|
5357
|
+
if (!a || !b) return false;
|
|
5358
|
+
for (let i = 0; i < 3; i++) {
|
|
5359
|
+
if (a.release[i] > b.release[i]) return true;
|
|
5360
|
+
if (a.release[i] < b.release[i]) return false;
|
|
5361
|
+
}
|
|
5362
|
+
if (a.prerelease && !b.prerelease) return false;
|
|
5363
|
+
if (!a.prerelease && b.prerelease) return true;
|
|
5364
|
+
return false;
|
|
5365
|
+
}
|
|
5366
|
+
async function getUpdateNotice(opts) {
|
|
5367
|
+
try {
|
|
5368
|
+
const {
|
|
5369
|
+
enabled = true,
|
|
5370
|
+
now = Date.now,
|
|
5371
|
+
readCache = defaultReadCache,
|
|
5372
|
+
writeCache = defaultWriteCache,
|
|
5373
|
+
fetchLatest = defaultFetchLatest,
|
|
5374
|
+
isTTY = () => Boolean(process.stderr.isTTY),
|
|
5375
|
+
currentVersion = version
|
|
5376
|
+
} = opts ?? {};
|
|
5377
|
+
if (enabled === false) return null;
|
|
5378
|
+
if (!isTTY()) return null;
|
|
5379
|
+
const cache = parseCache(readCache());
|
|
5380
|
+
const lastCheck = cache?.lastCheck ?? 0;
|
|
5381
|
+
if (now() - lastCheck < THROTTLE_MS) return null;
|
|
5382
|
+
const latest = await fetchLatest();
|
|
5383
|
+
if (!latest) return null;
|
|
5384
|
+
writeCache(JSON.stringify({ lastCheck: now(), latestVersion: latest }));
|
|
5385
|
+
if (isNewer(latest, currentVersion)) {
|
|
5386
|
+
return `A new version of azdo-cli is available: ${currentVersion} \u2192 ${latest}. Run \`npm i -g azdo-cli\` to update.`;
|
|
5387
|
+
}
|
|
5388
|
+
return null;
|
|
5389
|
+
} catch {
|
|
5390
|
+
return null;
|
|
5391
|
+
}
|
|
5392
|
+
}
|
|
5393
|
+
|
|
3020
5394
|
// src/index.ts
|
|
3021
|
-
|
|
5395
|
+
function exitOnEpipe(err) {
|
|
5396
|
+
if (err.code === "EPIPE") {
|
|
5397
|
+
process.exit(0);
|
|
5398
|
+
}
|
|
5399
|
+
throw err;
|
|
5400
|
+
}
|
|
5401
|
+
process.stdout.on("error", exitOnEpipe);
|
|
5402
|
+
process.stderr.on("error", exitOnEpipe);
|
|
5403
|
+
var program = new Command17();
|
|
3022
5404
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
5405
|
+
program.option("--no-update-check", "Skip the check for a newer published version");
|
|
5406
|
+
program.option("--trace <filepath>", "Append redacted HTTP request/response trace to a file (owner-read-only permissions)");
|
|
3023
5407
|
program.addCommand(createGetItemCommand());
|
|
3024
5408
|
program.addCommand(createAuthCommand());
|
|
3025
5409
|
program.addCommand(createClearPatCommand());
|
|
@@ -3032,10 +5416,24 @@ program.addCommand(createSetMdFieldCommand());
|
|
|
3032
5416
|
program.addCommand(createUpsertCommand());
|
|
3033
5417
|
program.addCommand(createListFieldsCommand());
|
|
3034
5418
|
program.addCommand(createPrCommand());
|
|
5419
|
+
program.addCommand(createPipelineCommand());
|
|
3035
5420
|
program.addCommand(createCommentsCommand());
|
|
3036
5421
|
program.addCommand(createDownloadAttachmentCommand());
|
|
5422
|
+
program.addCommand(createRelationsCommand());
|
|
3037
5423
|
program.showHelpAfterError();
|
|
3038
|
-
program.
|
|
5424
|
+
program.hook("preAction", () => {
|
|
5425
|
+
const { trace } = program.opts();
|
|
5426
|
+
if (trace) {
|
|
5427
|
+
initTraceWriter(trace);
|
|
5428
|
+
}
|
|
5429
|
+
});
|
|
5430
|
+
program.hook("postAction", async () => {
|
|
5431
|
+
const notice = await getUpdateNotice({ enabled: program.opts().updateCheck });
|
|
5432
|
+
if (notice) {
|
|
5433
|
+
process.stderr.write(notice + "\n");
|
|
5434
|
+
}
|
|
5435
|
+
});
|
|
5436
|
+
await program.parseAsync();
|
|
3039
5437
|
if (process.argv.length <= 2) {
|
|
3040
5438
|
program.help();
|
|
3041
5439
|
}
|