azdo-cli 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -4
- package/dist/chunk-XVXMDWQE.js +1218 -0
- package/dist/index.js +2766 -657
- package/dist/oauth-token-refresh-7FQ4VAIS.js +15 -0
- package/package.json +2 -1
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";
|
|
@@ -26,8 +63,15 @@ var DEFAULT_FIELDS = [
|
|
|
26
63
|
"System.AreaPath",
|
|
27
64
|
"System.IterationPath"
|
|
28
65
|
];
|
|
29
|
-
function authHeaders(
|
|
30
|
-
|
|
66
|
+
function authHeaders(credentialOrPat) {
|
|
67
|
+
if (typeof credentialOrPat === "string") {
|
|
68
|
+
const token2 = Buffer.from(`:${credentialOrPat}`).toString("base64");
|
|
69
|
+
return { Authorization: `Basic ${token2}` };
|
|
70
|
+
}
|
|
71
|
+
if (credentialOrPat.kind === "oauth") {
|
|
72
|
+
return { Authorization: `Bearer ${credentialOrPat.pat}` };
|
|
73
|
+
}
|
|
74
|
+
const token = Buffer.from(`:${credentialOrPat.pat}`).toString("base64");
|
|
31
75
|
return { Authorization: `Basic ${token}` };
|
|
32
76
|
}
|
|
33
77
|
async function fetchWithErrors(url, init) {
|
|
@@ -48,6 +92,10 @@ async function fetchWithErrors(url, init) {
|
|
|
48
92
|
}
|
|
49
93
|
throw new Error(`NOT_FOUND${detail}`);
|
|
50
94
|
}
|
|
95
|
+
const contentType = response.headers?.get("content-type") ?? "";
|
|
96
|
+
if (contentType.toLowerCase().startsWith("text/html")) {
|
|
97
|
+
throw new Error("AUTH_FAILED");
|
|
98
|
+
}
|
|
51
99
|
return response;
|
|
52
100
|
}
|
|
53
101
|
async function readResponseMessage(response) {
|
|
@@ -90,9 +138,9 @@ function buildExtraFields(fields, requested) {
|
|
|
90
138
|
}
|
|
91
139
|
return Object.keys(result).length > 0 ? result : null;
|
|
92
140
|
}
|
|
93
|
-
function writeHeaders(
|
|
141
|
+
function writeHeaders(cred) {
|
|
94
142
|
return {
|
|
95
|
-
...authHeaders(
|
|
143
|
+
...authHeaders(cred),
|
|
96
144
|
"Content-Type": "application/json-patch+json"
|
|
97
145
|
};
|
|
98
146
|
}
|
|
@@ -147,13 +195,13 @@ async function readWriteResponse(response, errorCode) {
|
|
|
147
195
|
fields: data.fields
|
|
148
196
|
};
|
|
149
197
|
}
|
|
150
|
-
async function getWorkItemFields(context, id,
|
|
198
|
+
async function getWorkItemFields(context, id, cred) {
|
|
151
199
|
const url = new URL(
|
|
152
200
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
153
201
|
);
|
|
154
202
|
url.searchParams.set("api-version", "7.1");
|
|
155
203
|
url.searchParams.set("$expand", "all");
|
|
156
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
204
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
157
205
|
if (response.status === 400) {
|
|
158
206
|
const serverMessage = await readResponseMessage(response);
|
|
159
207
|
if (serverMessage) {
|
|
@@ -188,10 +236,10 @@ function buildWorkItemUrl(context, id, options = {}) {
|
|
|
188
236
|
}
|
|
189
237
|
return url;
|
|
190
238
|
}
|
|
191
|
-
async function fetchWorkItemResponse(context, id,
|
|
239
|
+
async function fetchWorkItemResponse(context, id, cred, options = {}) {
|
|
192
240
|
const response = await fetchWithErrors(
|
|
193
241
|
buildWorkItemUrl(context, id, options).toString(),
|
|
194
|
-
{ headers: authHeaders(
|
|
242
|
+
{ headers: authHeaders(cred) }
|
|
195
243
|
);
|
|
196
244
|
if (response.status === 400) {
|
|
197
245
|
const serverMessage = await readResponseMessage(response);
|
|
@@ -204,28 +252,66 @@ async function fetchWorkItemResponse(context, id, pat, options = {}) {
|
|
|
204
252
|
}
|
|
205
253
|
return await response.json();
|
|
206
254
|
}
|
|
207
|
-
async function
|
|
255
|
+
async function getOrgFieldNames(context, cred) {
|
|
256
|
+
const url = new URL(
|
|
257
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/fields`
|
|
258
|
+
);
|
|
259
|
+
url.searchParams.set("api-version", "7.1");
|
|
260
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
throw new Error(`HTTP_${response.status}`);
|
|
263
|
+
}
|
|
264
|
+
const data = await response.json();
|
|
265
|
+
return (data.value ?? []).map((f) => f.referenceName);
|
|
266
|
+
}
|
|
267
|
+
function buildCombinedDescription(fields) {
|
|
268
|
+
const parts = [];
|
|
269
|
+
if (fields["System.Description"]) {
|
|
270
|
+
parts.push({ label: "Description", value: fields["System.Description"] });
|
|
271
|
+
}
|
|
272
|
+
if (fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
|
|
273
|
+
parts.push({ label: "Acceptance Criteria", value: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
|
|
274
|
+
}
|
|
275
|
+
if (fields["Microsoft.VSTS.TCM.ReproSteps"]) {
|
|
276
|
+
parts.push({ label: "Repro Steps", value: fields["Microsoft.VSTS.TCM.ReproSteps"] });
|
|
277
|
+
}
|
|
278
|
+
if (parts.length === 0) return null;
|
|
279
|
+
if (parts.length === 1) return parts[0].value;
|
|
280
|
+
return parts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
|
|
281
|
+
}
|
|
282
|
+
async function fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields) {
|
|
283
|
+
try {
|
|
284
|
+
const data = await fetchWorkItemResponse(context, id, cred, {
|
|
285
|
+
fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
|
|
286
|
+
});
|
|
287
|
+
return { data, effectiveExtraFields: normalizedExtraFields };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
290
|
+
if (!msg.includes("TF51535")) throw err;
|
|
291
|
+
const orgFieldNames = await getOrgFieldNames(context, cred);
|
|
292
|
+
const orgFieldsLower = new Set(orgFieldNames.map((n) => n.toLowerCase()));
|
|
293
|
+
const missing = normalizedExtraFields.filter((f) => !orgFieldsLower.has(f.toLowerCase()));
|
|
294
|
+
const effectiveExtraFields = normalizedExtraFields.filter((f) => orgFieldsLower.has(f.toLowerCase()));
|
|
295
|
+
for (const f of missing) {
|
|
296
|
+
process.stderr.write(`azdo: warning: field '${f}' does not exist in organization '${context.org}' and was skipped
|
|
297
|
+
`);
|
|
298
|
+
}
|
|
299
|
+
const data = await fetchWorkItemResponse(context, id, cred, {
|
|
300
|
+
fields: normalizeFieldList([...DEFAULT_FIELDS, ...effectiveExtraFields])
|
|
301
|
+
});
|
|
302
|
+
return { data, effectiveExtraFields };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function getWorkItem(context, id, cred, extraFields) {
|
|
208
306
|
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("");
|
|
307
|
+
let effectiveExtraFields = normalizedExtraFields;
|
|
308
|
+
let data;
|
|
309
|
+
if (normalizedExtraFields.length > 0) {
|
|
310
|
+
({ data, effectiveExtraFields } = await fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields));
|
|
311
|
+
} else {
|
|
312
|
+
data = await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
|
|
228
313
|
}
|
|
314
|
+
const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
|
|
229
315
|
return {
|
|
230
316
|
id: data.id,
|
|
231
317
|
rev: data.rev,
|
|
@@ -233,21 +319,21 @@ async function getWorkItem(context, id, pat, extraFields) {
|
|
|
233
319
|
state: data.fields["System.State"],
|
|
234
320
|
type: data.fields["System.WorkItemType"],
|
|
235
321
|
assignedTo: data.fields["System.AssignedTo"]?.displayName ?? null,
|
|
236
|
-
description:
|
|
322
|
+
description: buildCombinedDescription(data.fields),
|
|
237
323
|
areaPath: data.fields["System.AreaPath"],
|
|
238
324
|
iterationPath: data.fields["System.IterationPath"],
|
|
239
325
|
url: data._links.html.href,
|
|
240
|
-
extraFields:
|
|
326
|
+
extraFields: effectiveExtraFields.length > 0 ? buildExtraFields(data.fields, effectiveExtraFields) : null,
|
|
241
327
|
attachments: extractAttachments(relationsData.relations)
|
|
242
328
|
};
|
|
243
329
|
}
|
|
244
|
-
async function getWorkItemFieldValue(context, id,
|
|
330
|
+
async function getWorkItemFieldValue(context, id, cred, fieldName) {
|
|
245
331
|
const url = new URL(
|
|
246
332
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
247
333
|
);
|
|
248
334
|
url.searchParams.set("api-version", "7.1");
|
|
249
335
|
url.searchParams.set("fields", fieldName);
|
|
250
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
336
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
251
337
|
if (response.status === 400) {
|
|
252
338
|
const serverMessage = await readResponseMessage(response);
|
|
253
339
|
if (serverMessage) {
|
|
@@ -264,13 +350,13 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
|
|
|
264
350
|
}
|
|
265
351
|
return stringifyFieldValue(value);
|
|
266
352
|
}
|
|
267
|
-
async function listWorkItemComments(context, id,
|
|
353
|
+
async function listWorkItemComments(context, id, cred) {
|
|
268
354
|
const comments = [];
|
|
269
355
|
let continuationToken = null;
|
|
270
356
|
do {
|
|
271
357
|
const response = await fetchWithErrors(
|
|
272
358
|
buildWorkItemCommentsListUrl(context, id, continuationToken ?? void 0).toString(),
|
|
273
|
-
{ headers: authHeaders(
|
|
359
|
+
{ headers: authHeaders(cred) }
|
|
274
360
|
);
|
|
275
361
|
if (!response.ok) {
|
|
276
362
|
throw new Error(`HTTP_${response.status}`);
|
|
@@ -287,13 +373,13 @@ async function listWorkItemComments(context, id, pat) {
|
|
|
287
373
|
comments
|
|
288
374
|
};
|
|
289
375
|
}
|
|
290
|
-
async function addWorkItemComment(context, id,
|
|
376
|
+
async function addWorkItemComment(context, id, cred, text, format = "html") {
|
|
291
377
|
const url = buildWorkItemCommentsUrl(context, id);
|
|
292
378
|
url.searchParams.set("format", format);
|
|
293
379
|
const response = await fetchWithErrors(url.toString(), {
|
|
294
380
|
method: "POST",
|
|
295
381
|
headers: {
|
|
296
|
-
...authHeaders(
|
|
382
|
+
...authHeaders(cred),
|
|
297
383
|
"Content-Type": "application/json"
|
|
298
384
|
},
|
|
299
385
|
body: JSON.stringify({ text })
|
|
@@ -315,8 +401,8 @@ async function addWorkItemComment(context, id, pat, text, format = "html") {
|
|
|
315
401
|
url: data.url ?? null
|
|
316
402
|
};
|
|
317
403
|
}
|
|
318
|
-
async function updateWorkItem(context, id,
|
|
319
|
-
const result = await applyWorkItemPatch(context, id,
|
|
404
|
+
async function updateWorkItem(context, id, cred, fieldName, operations) {
|
|
405
|
+
const result = await applyWorkItemPatch(context, id, cred, operations);
|
|
320
406
|
const title = result.fields["System.Title"];
|
|
321
407
|
const lastOp = operations.at(-1);
|
|
322
408
|
const fieldValue = lastOp?.value ?? null;
|
|
@@ -328,32 +414,32 @@ async function updateWorkItem(context, id, pat, fieldName, operations) {
|
|
|
328
414
|
fieldValue
|
|
329
415
|
};
|
|
330
416
|
}
|
|
331
|
-
async function createWorkItem(context, workItemType,
|
|
417
|
+
async function createWorkItem(context, workItemType, cred, operations) {
|
|
332
418
|
const url = new URL(
|
|
333
419
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
|
|
334
420
|
);
|
|
335
421
|
url.searchParams.set("api-version", "7.1");
|
|
336
422
|
const response = await fetchWithErrors(url.toString(), {
|
|
337
423
|
method: "POST",
|
|
338
|
-
headers: writeHeaders(
|
|
424
|
+
headers: writeHeaders(cred),
|
|
339
425
|
body: JSON.stringify(operations)
|
|
340
426
|
});
|
|
341
427
|
return readWriteResponse(response, "CREATE_REJECTED");
|
|
342
428
|
}
|
|
343
|
-
async function applyWorkItemPatch(context, id,
|
|
429
|
+
async function applyWorkItemPatch(context, id, cred, operations) {
|
|
344
430
|
const url = new URL(
|
|
345
431
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
346
432
|
);
|
|
347
433
|
url.searchParams.set("api-version", "7.1");
|
|
348
434
|
const response = await fetchWithErrors(url.toString(), {
|
|
349
435
|
method: "PATCH",
|
|
350
|
-
headers: writeHeaders(
|
|
436
|
+
headers: writeHeaders(cred),
|
|
351
437
|
body: JSON.stringify(operations)
|
|
352
438
|
});
|
|
353
439
|
return readWriteResponse(response, "UPDATE_REJECTED");
|
|
354
440
|
}
|
|
355
|
-
async function downloadAttachment(url,
|
|
356
|
-
const response = await fetchWithErrors(url, { headers: authHeaders(
|
|
441
|
+
async function downloadAttachment(url, cred) {
|
|
442
|
+
const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
|
|
357
443
|
if (!response.ok) {
|
|
358
444
|
throw new Error(`HTTP_${response.status}`);
|
|
359
445
|
}
|
|
@@ -365,323 +451,124 @@ import { createInterface } from "readline";
|
|
|
365
451
|
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
366
452
|
import { dirname as dirname2, join } from "path";
|
|
367
453
|
|
|
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;
|
|
454
|
+
// src/services/oauth-device-code.ts
|
|
455
|
+
var DeviceCodeFlowError = class extends Error {
|
|
456
|
+
reason;
|
|
457
|
+
constructor(reason, message, cause) {
|
|
458
|
+
super(message);
|
|
459
|
+
this.name = "DeviceCodeFlowError";
|
|
460
|
+
this.reason = reason;
|
|
378
461
|
if (cause instanceof Error) {
|
|
379
462
|
this.cause = cause;
|
|
380
463
|
}
|
|
381
464
|
}
|
|
382
465
|
};
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
466
|
+
var MIN_INTERVAL_SEC = 5;
|
|
467
|
+
async function defaultSleep(ms) {
|
|
468
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
469
|
+
}
|
|
470
|
+
async function requestDeviceCode(oauthConfig, fetchFn) {
|
|
471
|
+
const body = new URLSearchParams({
|
|
472
|
+
client_id: oauthConfig.clientId,
|
|
473
|
+
scope: buildScopeString(oauthConfig.scopes)
|
|
474
|
+
});
|
|
475
|
+
const response = await fetchFn(oauthConfig.deviceCodeEndpoint, {
|
|
476
|
+
method: "POST",
|
|
477
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
478
|
+
body: body.toString()
|
|
479
|
+
});
|
|
480
|
+
const text = await response.text();
|
|
481
|
+
let parsed;
|
|
396
482
|
try {
|
|
397
|
-
|
|
483
|
+
parsed = JSON.parse(text);
|
|
398
484
|
} catch {
|
|
485
|
+
throw new DeviceCodeFlowError("idp-error", `device-code endpoint returned non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
399
486
|
}
|
|
487
|
+
if (!response.ok) {
|
|
488
|
+
const err = parsed;
|
|
489
|
+
const code = err.error ?? "unknown";
|
|
490
|
+
const desc = err.error_description ? `: ${err.error_description}` : "";
|
|
491
|
+
throw new DeviceCodeFlowError(
|
|
492
|
+
"idp-error",
|
|
493
|
+
`device-code endpoint rejected request (${response.status}): ${code}${desc}`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
return parsed;
|
|
400
497
|
}
|
|
401
|
-
function
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
return;
|
|
498
|
+
async function classifyDeviceTokenResponse(response) {
|
|
499
|
+
if (response.ok) {
|
|
500
|
+
return { kind: "success", token: await readTokenResponse(response) };
|
|
405
501
|
}
|
|
502
|
+
const text = await response.text();
|
|
503
|
+
let parsed;
|
|
406
504
|
try {
|
|
407
|
-
|
|
505
|
+
parsed = JSON.parse(text);
|
|
408
506
|
} catch {
|
|
507
|
+
throw new DeviceCodeFlowError("idp-error", `non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
409
508
|
}
|
|
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
|
-
}
|
|
509
|
+
const errCode = parsed.error ?? "";
|
|
510
|
+
if (errCode === "authorization_pending") return { kind: "pending" };
|
|
511
|
+
if (errCode === "slow_down") return { kind: "slow_down" };
|
|
512
|
+
if (errCode === "expired_token") {
|
|
513
|
+
throw new DeviceCodeFlowError("expired_token", "device code expired before authorisation completed");
|
|
443
514
|
}
|
|
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
|
|
515
|
+
if (errCode === "access_denied") {
|
|
516
|
+
throw new DeviceCodeFlowError("access_denied", "authorisation denied by user");
|
|
479
517
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
518
|
+
const desc = parsed.error_description ? `: ${parsed.error_description}` : "";
|
|
519
|
+
throw new DeviceCodeFlowError(
|
|
520
|
+
"idp-error",
|
|
521
|
+
`IdP rejected device-token poll (${response.status}): ${errCode}${desc}`
|
|
522
|
+
);
|
|
484
523
|
}
|
|
485
|
-
function
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (
|
|
492
|
-
|
|
524
|
+
async function pollForDeviceToken(deviceCode, oauthConfig, initialIntervalSec, expiresAtMs, deps) {
|
|
525
|
+
const fetchFn = deps.fetch ?? fetch;
|
|
526
|
+
const now = deps.now ?? (() => Date.now());
|
|
527
|
+
const sleep2 = deps.sleep ?? defaultSleep;
|
|
528
|
+
let intervalSec = Math.max(MIN_INTERVAL_SEC, initialIntervalSec);
|
|
529
|
+
for (; ; ) {
|
|
530
|
+
if (now() >= expiresAtMs) {
|
|
531
|
+
throw new DeviceCodeFlowError("expired_token", "device-code flow expired before authorisation completed");
|
|
493
532
|
}
|
|
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".`);
|
|
533
|
+
await sleep2(intervalSec * 1e3);
|
|
534
|
+
const body = new URLSearchParams({
|
|
535
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
536
|
+
client_id: oauthConfig.clientId,
|
|
537
|
+
device_code: deviceCode
|
|
538
|
+
});
|
|
539
|
+
const response = await fetchFn(oauthConfig.tokenEndpoint, {
|
|
540
|
+
method: "POST",
|
|
541
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
542
|
+
body: body.toString()
|
|
543
|
+
});
|
|
544
|
+
const outcome = await classifyDeviceTokenResponse(response);
|
|
545
|
+
if (outcome.kind === "success") return outcome.token;
|
|
546
|
+
if (outcome.kind === "slow_down") {
|
|
547
|
+
intervalSec += 5;
|
|
528
548
|
}
|
|
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
549
|
}
|
|
535
|
-
saveConfig(config);
|
|
536
|
-
}
|
|
537
|
-
function unsetConfigValue(key) {
|
|
538
|
-
validateKey(key);
|
|
539
|
-
const config = loadConfig();
|
|
540
|
-
delete config[key];
|
|
541
|
-
saveConfig(config);
|
|
542
550
|
}
|
|
551
|
+
async function runDeviceCodeFlow(org, oauthConfig, deps = {}) {
|
|
552
|
+
const fetchFn = deps.fetch ?? fetch;
|
|
553
|
+
const now = deps.now ?? (() => Date.now());
|
|
554
|
+
const writePrompt = deps.writePrompt ?? ((m) => {
|
|
555
|
+
process.stderr.write(m);
|
|
556
|
+
});
|
|
557
|
+
const dc = await requestDeviceCode(oauthConfig, fetchFn);
|
|
558
|
+
const expiryMin = Math.round(dc.expires_in / 60);
|
|
559
|
+
writePrompt(
|
|
560
|
+
`
|
|
561
|
+
To authenticate, open ${dc.verification_uri} in a browser and enter the code:
|
|
543
562
|
|
|
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
|
-
}
|
|
563
|
+
${dc.user_code}
|
|
557
564
|
|
|
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'
|
|
565
|
+
Waiting for authorisation (expires in ${expiryMin} min)\u2026
|
|
566
|
+
`
|
|
589
567
|
);
|
|
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;
|
|
568
|
+
const expiresAtMs = now() + dc.expires_in * 1e3;
|
|
569
|
+
const token = await pollForDeviceToken(dc.device_code, oauthConfig, dc.interval, expiresAtMs, deps);
|
|
570
|
+
const credential = tokenResponseToCredential(org, oauthConfig, token, now());
|
|
571
|
+
return { credential, flowUsed: "device-code" };
|
|
685
572
|
}
|
|
686
573
|
|
|
687
574
|
// src/services/auth.ts
|
|
@@ -750,29 +637,36 @@ function findDotEnvPat(startDir = process.cwd()) {
|
|
|
750
637
|
}
|
|
751
638
|
return null;
|
|
752
639
|
}
|
|
753
|
-
async function
|
|
640
|
+
async function resolveAuthCredential(org) {
|
|
754
641
|
const envPat = process.env.AZDO_PAT;
|
|
755
642
|
if (envPat && envPat.length > 0) {
|
|
756
|
-
return { pat: envPat, source: "env" };
|
|
643
|
+
return { pat: envPat, source: "env", kind: "pat" };
|
|
757
644
|
}
|
|
758
|
-
const
|
|
759
|
-
if (
|
|
760
|
-
|
|
645
|
+
const stored = await getStoredCredential(org);
|
|
646
|
+
if (stored !== null) {
|
|
647
|
+
if (stored.kind === "pat") {
|
|
648
|
+
return { pat: stored.token, source: "credential-store", kind: "pat" };
|
|
649
|
+
}
|
|
650
|
+
const fresh = await refreshIfNeeded(org, stored);
|
|
651
|
+
return {
|
|
652
|
+
pat: fresh.accessToken,
|
|
653
|
+
source: "credential-store",
|
|
654
|
+
kind: "oauth",
|
|
655
|
+
accountId: fresh.accountId
|
|
656
|
+
};
|
|
761
657
|
}
|
|
762
658
|
const dotEnvPat = findDotEnvPat();
|
|
763
659
|
if (dotEnvPat !== null) {
|
|
764
|
-
return { pat: dotEnvPat, source: "env" };
|
|
660
|
+
return { pat: dotEnvPat, source: "env", kind: "pat" };
|
|
765
661
|
}
|
|
766
662
|
return null;
|
|
767
663
|
}
|
|
768
|
-
async function
|
|
769
|
-
const cred = await
|
|
664
|
+
async function requireAuthCredential(org) {
|
|
665
|
+
const cred = await resolveAuthCredential(org);
|
|
770
666
|
if (cred !== null) {
|
|
771
667
|
return cred;
|
|
772
668
|
}
|
|
773
|
-
throw new
|
|
774
|
-
`No PAT available for org "${org}". Set AZDO_PAT environment variable or run \`azdo auth --org ${org}\`.`
|
|
775
|
-
);
|
|
669
|
+
throw new CredentialMissingError(org);
|
|
776
670
|
}
|
|
777
671
|
async function validatePatAgainstAzdo(pat, org) {
|
|
778
672
|
const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?$top=1&api-version=7.1`;
|
|
@@ -791,51 +685,309 @@ async function validatePatAgainstAzdo(pat, org) {
|
|
|
791
685
|
}
|
|
792
686
|
throw new Error(`Azure DevOps returned HTTP ${response.status} while validating PAT for org "${org}".`);
|
|
793
687
|
}
|
|
688
|
+
var LOGIN_FAILURE_REASONS = /* @__PURE__ */ new Set([
|
|
689
|
+
"user-cancelled",
|
|
690
|
+
"port-conflict",
|
|
691
|
+
"state-mismatch",
|
|
692
|
+
"redirect-mismatch",
|
|
693
|
+
"idp-error",
|
|
694
|
+
"timeout",
|
|
695
|
+
"expired_token",
|
|
696
|
+
"access_denied"
|
|
697
|
+
]);
|
|
698
|
+
function extractLoginFailureReason(err) {
|
|
699
|
+
if (typeof err === "object" && err !== null && "reason" in err) {
|
|
700
|
+
const r = err.reason;
|
|
701
|
+
if (typeof r === "string" && LOGIN_FAILURE_REASONS.has(r)) {
|
|
702
|
+
return r;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return "unknown";
|
|
706
|
+
}
|
|
707
|
+
async function loginWithOAuth(org, opts = {}) {
|
|
708
|
+
const oauthConfig = resolveOAuthConfig({
|
|
709
|
+
clientIdOverride: opts.clientIdOverride,
|
|
710
|
+
tenantIdOverride: opts.tenantIdOverride,
|
|
711
|
+
scopesOverride: opts.scopesOverride
|
|
712
|
+
});
|
|
713
|
+
const isHeadlessRuntime = () => {
|
|
714
|
+
if (opts.forceHeadless) return true;
|
|
715
|
+
if (process.platform === "linux") {
|
|
716
|
+
return !process.env.DISPLAY || process.env.DISPLAY.length === 0;
|
|
717
|
+
}
|
|
718
|
+
return false;
|
|
719
|
+
};
|
|
720
|
+
const useDeviceCode = opts.flow === "device-code" || opts.flow !== "auth-code" && isHeadlessRuntime();
|
|
721
|
+
appendAuthAuditEvent({
|
|
722
|
+
event: "oauth-login-started",
|
|
723
|
+
org,
|
|
724
|
+
backend: probeBackend(),
|
|
725
|
+
flow: useDeviceCode ? "device-code" : "auth-code",
|
|
726
|
+
clientIdSource: oauthConfig.clientIdSource
|
|
727
|
+
});
|
|
728
|
+
let credential;
|
|
729
|
+
let flowUsed;
|
|
730
|
+
try {
|
|
731
|
+
if (useDeviceCode) {
|
|
732
|
+
const r = await runDeviceCodeFlow(org, oauthConfig);
|
|
733
|
+
credential = r.credential;
|
|
734
|
+
flowUsed = "device-code";
|
|
735
|
+
} else {
|
|
736
|
+
const r = await runAuthCodeFlow(org, oauthConfig);
|
|
737
|
+
credential = r.credential;
|
|
738
|
+
flowUsed = "auth-code";
|
|
739
|
+
}
|
|
740
|
+
} catch (err) {
|
|
741
|
+
appendAuthAuditEvent({
|
|
742
|
+
event: "oauth-login-failed",
|
|
743
|
+
org,
|
|
744
|
+
backend: probeBackend(),
|
|
745
|
+
flow: useDeviceCode ? "device-code" : "auth-code",
|
|
746
|
+
reason: extractLoginFailureReason(err)
|
|
747
|
+
});
|
|
748
|
+
throw err;
|
|
749
|
+
}
|
|
750
|
+
await storeOAuthCredential(org, credential);
|
|
751
|
+
appendAuthAuditEvent({
|
|
752
|
+
event: "oauth-login-success",
|
|
753
|
+
org,
|
|
754
|
+
backend: probeBackend(),
|
|
755
|
+
flow: flowUsed,
|
|
756
|
+
clientIdSource: oauthConfig.clientIdSource,
|
|
757
|
+
accountId: credential.accountId,
|
|
758
|
+
scope: credential.scope,
|
|
759
|
+
tokenLifetimeSec: credential.expiresAt - credential.issuedAt
|
|
760
|
+
});
|
|
761
|
+
return {
|
|
762
|
+
org,
|
|
763
|
+
kind: "oauth",
|
|
764
|
+
accountId: credential.accountId,
|
|
765
|
+
expiresAt: credential.expiresAt,
|
|
766
|
+
scope: credential.scope,
|
|
767
|
+
flowUsed
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
async function logout(opts = {}) {
|
|
771
|
+
if (opts.all) {
|
|
772
|
+
const orgs = await listOrgsWithStoredPat();
|
|
773
|
+
const removed = [];
|
|
774
|
+
for (const o of orgs) {
|
|
775
|
+
const cred2 = await getStoredCredential(o);
|
|
776
|
+
const ok2 = await deletePat(o);
|
|
777
|
+
if (ok2 && cred2 !== null) {
|
|
778
|
+
removed.push({ org: o, kind: cred2.kind });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return { removed };
|
|
782
|
+
}
|
|
783
|
+
if (!opts.org) {
|
|
784
|
+
throw new Error("logout requires an org or --all");
|
|
785
|
+
}
|
|
786
|
+
const cred = await getStoredCredential(opts.org);
|
|
787
|
+
const ok = await deletePat(opts.org);
|
|
788
|
+
return { removed: ok && cred !== null ? [{ org: opts.org, kind: cred.kind }] : [] };
|
|
789
|
+
}
|
|
790
|
+
async function status() {
|
|
791
|
+
const orgs = await listOrgsWithStoredPat();
|
|
792
|
+
const out = [];
|
|
793
|
+
for (const org of orgs) {
|
|
794
|
+
const cred = await getStoredCredential(org);
|
|
795
|
+
if (cred === null) continue;
|
|
796
|
+
if (cred.kind === "pat") {
|
|
797
|
+
out.push({ org, kind: "pat", backend: probeBackend() });
|
|
798
|
+
} else {
|
|
799
|
+
out.push({
|
|
800
|
+
org,
|
|
801
|
+
kind: "oauth",
|
|
802
|
+
accountId: cred.accountId,
|
|
803
|
+
expiresAt: cred.expiresAt,
|
|
804
|
+
scope: cred.scope,
|
|
805
|
+
backend: probeBackend()
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return { orgs: out };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/services/git-remote.ts
|
|
813
|
+
import { execFileSync } from "child_process";
|
|
814
|
+
import fs from "fs";
|
|
815
|
+
import path from "path";
|
|
816
|
+
|
|
817
|
+
// src/services/remote-warning.ts
|
|
818
|
+
var warned = false;
|
|
819
|
+
function buildWarning(remoteName) {
|
|
820
|
+
return `azdo: warning: ${remoteName} includes embedded credentials; consider removing them with 'git remote set-url ${remoteName} <clean-url>'
|
|
821
|
+
`;
|
|
822
|
+
}
|
|
823
|
+
function noticeCredentialBearingRemote(remoteName = "origin") {
|
|
824
|
+
if (warned) {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
warned = true;
|
|
828
|
+
try {
|
|
829
|
+
process.stderr.write(buildWarning(remoteName));
|
|
830
|
+
} catch {
|
|
831
|
+
}
|
|
832
|
+
}
|
|
794
833
|
|
|
795
834
|
// src/services/git-remote.ts
|
|
796
|
-
|
|
835
|
+
var GIT_BINARY = (() => {
|
|
836
|
+
const known = process.platform === "win32" ? [String.raw`C:\Program Files\Git\bin\git.exe`, String.raw`C:\Program Files (x86)\Git\bin\git.exe`] : ["/usr/bin/git", "/usr/local/bin/git", "/opt/homebrew/bin/git"];
|
|
837
|
+
return known.find((p) => {
|
|
838
|
+
try {
|
|
839
|
+
execFileSync(p, ["--version"], { stdio: "ignore" });
|
|
840
|
+
return true;
|
|
841
|
+
} catch {
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
}) ?? "git";
|
|
845
|
+
})();
|
|
797
846
|
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\/([^/]+)\/([^/]+)\/([^/]
|
|
847
|
+
// HTTPS (current): https://[user[:token]@]dev.azure.com/{org}/{project}/_git/{repo}[.git]
|
|
848
|
+
/^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
|
|
849
|
+
// HTTPS (legacy + DefaultCollection): https://[user[:token]@]{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}[.git]
|
|
850
|
+
/^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
|
|
851
|
+
// HTTPS (legacy): https://[user[:token]@]{org}.visualstudio.com/{project}/_git/{repo}[.git]
|
|
852
|
+
/^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
|
|
853
|
+
// SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}[.git]
|
|
854
|
+
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/,
|
|
855
|
+
// SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}[.git]
|
|
856
|
+
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/
|
|
808
857
|
];
|
|
809
|
-
|
|
858
|
+
var httpsEmbeddedSecret = /^https?:\/\/[^:@/]+:[^@/]+@/;
|
|
859
|
+
function parseSingleRemoteLine(line) {
|
|
860
|
+
const trimmed = line.trim();
|
|
861
|
+
if (!trimmed) return null;
|
|
862
|
+
const tabIdx = trimmed.indexOf(" ");
|
|
863
|
+
if (tabIdx === -1) return null;
|
|
864
|
+
const remoteName = trimmed.slice(0, tabIdx);
|
|
865
|
+
const afterTab = trimmed.slice(tabIdx + 1);
|
|
866
|
+
const urlEnd = afterTab.lastIndexOf(" (");
|
|
867
|
+
const url = urlEnd === -1 ? afterTab : afterTab.slice(0, urlEnd);
|
|
868
|
+
return { remoteName, url };
|
|
869
|
+
}
|
|
870
|
+
function matchAzdoRemote(remoteName, url) {
|
|
810
871
|
for (const pattern of patterns) {
|
|
811
872
|
const match = pattern.exec(url);
|
|
812
|
-
if (match)
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
873
|
+
if (!match) continue;
|
|
874
|
+
const project = match[2];
|
|
875
|
+
if (/^DefaultCollection$/i.test(project)) return null;
|
|
876
|
+
return { remoteName, org: match[1], project, hasEmbeddedSecret: httpsEmbeddedSecret.test(url) };
|
|
877
|
+
}
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
function parseAllAzdoRemotes(output) {
|
|
881
|
+
const seen = /* @__PURE__ */ new Set();
|
|
882
|
+
const results = [];
|
|
883
|
+
for (const line of output.split("\n")) {
|
|
884
|
+
const parsed = parseSingleRemoteLine(line);
|
|
885
|
+
if (!parsed) continue;
|
|
886
|
+
const { remoteName, url } = parsed;
|
|
887
|
+
if (seen.has(remoteName)) continue;
|
|
888
|
+
const candidate = matchAzdoRemote(remoteName, url);
|
|
889
|
+
if (candidate) {
|
|
890
|
+
seen.add(remoteName);
|
|
891
|
+
results.push(candidate);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return results;
|
|
895
|
+
}
|
|
896
|
+
function selectRemote(candidates) {
|
|
897
|
+
if (candidates.length === 0) {
|
|
898
|
+
throw new Error("No Azure DevOps remote found. Provide --org and --project explicitly.");
|
|
899
|
+
}
|
|
900
|
+
const origin = candidates.find((c) => c.remoteName === "origin");
|
|
901
|
+
if (origin) return origin;
|
|
902
|
+
if (candidates.length === 1) return candidates[0];
|
|
903
|
+
const first = candidates[0];
|
|
904
|
+
const allSame = candidates.every(
|
|
905
|
+
(c) => c.org.toLowerCase() === first.org.toLowerCase() && c.project.toLowerCase() === first.project.toLowerCase()
|
|
906
|
+
);
|
|
907
|
+
if (allSame) return first;
|
|
908
|
+
const lines = candidates.map((c) => ` ${c.remoteName.padEnd(8)} \u2192 ${c.org}/${c.project}`).join("\n");
|
|
909
|
+
throw new Error(
|
|
910
|
+
`Multiple Azure DevOps remotes found with different org/project:
|
|
911
|
+
${lines}
|
|
912
|
+
Use --org/--project (or 'git remote rename <name> origin') to disambiguate.`
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
function readGitConfigContent() {
|
|
916
|
+
const gitDirEnv = process.env.GIT_DIR;
|
|
917
|
+
if (gitDirEnv) {
|
|
918
|
+
return fs.readFileSync(path.join(gitDirEnv, "config"), "utf-8");
|
|
919
|
+
}
|
|
920
|
+
let dir = process.cwd();
|
|
921
|
+
for (; ; ) {
|
|
922
|
+
const gitPath = path.join(dir, ".git");
|
|
923
|
+
try {
|
|
924
|
+
const stat = fs.statSync(gitPath);
|
|
925
|
+
if (stat.isDirectory()) {
|
|
926
|
+
return fs.readFileSync(path.join(gitPath, "config"), "utf-8");
|
|
927
|
+
}
|
|
928
|
+
if (stat.isFile()) {
|
|
929
|
+
const ref = fs.readFileSync(gitPath, "utf-8");
|
|
930
|
+
const m = /^gitdir:[ \t]*([^\r\n]+)/m.exec(ref);
|
|
931
|
+
if (m) {
|
|
932
|
+
return fs.readFileSync(path.join(path.resolve(dir, m[1].trim()), "config"), "utf-8");
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
} catch {
|
|
936
|
+
}
|
|
937
|
+
const parent = path.dirname(dir);
|
|
938
|
+
if (parent === dir) break;
|
|
939
|
+
dir = parent;
|
|
940
|
+
}
|
|
941
|
+
throw new Error("Not in a git repository. Provide --org and --project explicitly.");
|
|
942
|
+
}
|
|
943
|
+
function gitConfigToRemoteLines(configContent) {
|
|
944
|
+
const lines = [];
|
|
945
|
+
let currentRemote = null;
|
|
946
|
+
let emittedUrl = false;
|
|
947
|
+
for (const line of configContent.split("\n")) {
|
|
948
|
+
const sectionMatch = /^\[remote\s+"([^"]+)"\]/.exec(line);
|
|
949
|
+
if (sectionMatch) {
|
|
950
|
+
currentRemote = sectionMatch[1];
|
|
951
|
+
emittedUrl = false;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (line.startsWith("[")) {
|
|
955
|
+
currentRemote = null;
|
|
956
|
+
emittedUrl = false;
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (currentRemote && !emittedUrl) {
|
|
960
|
+
const urlMatch = /^[ \t]+url[ \t]*=[ \t]*([^\r\n]+)/.exec(line);
|
|
961
|
+
if (urlMatch) {
|
|
962
|
+
lines.push(`${currentRemote} ${urlMatch[1].trim()} (fetch)`);
|
|
963
|
+
emittedUrl = true;
|
|
816
964
|
}
|
|
817
|
-
return { org: match[1], project };
|
|
818
965
|
}
|
|
819
966
|
}
|
|
820
|
-
return
|
|
967
|
+
return lines.join("\n");
|
|
821
968
|
}
|
|
822
969
|
function detectAzdoContext() {
|
|
823
|
-
let
|
|
970
|
+
let configContent;
|
|
824
971
|
try {
|
|
825
|
-
|
|
972
|
+
configContent = readGitConfigContent();
|
|
826
973
|
} catch {
|
|
827
974
|
throw new Error("Not in a git repository. Provide --org and --project explicitly.");
|
|
828
975
|
}
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
976
|
+
const remoteLines = gitConfigToRemoteLines(configContent);
|
|
977
|
+
const candidates = parseAllAzdoRemotes(remoteLines);
|
|
978
|
+
const selected = selectRemote(candidates);
|
|
979
|
+
if (selected.hasEmbeddedSecret) {
|
|
980
|
+
noticeCredentialBearingRemote(selected.remoteName);
|
|
832
981
|
}
|
|
833
|
-
return
|
|
982
|
+
return { org: selected.org, project: selected.project };
|
|
834
983
|
}
|
|
835
984
|
function parseRepoName(url) {
|
|
836
985
|
for (const pattern of patterns) {
|
|
837
986
|
const match = pattern.exec(url);
|
|
838
987
|
if (match) {
|
|
988
|
+
if (httpsEmbeddedSecret.test(url)) {
|
|
989
|
+
noticeCredentialBearingRemote();
|
|
990
|
+
}
|
|
839
991
|
return match[3];
|
|
840
992
|
}
|
|
841
993
|
}
|
|
@@ -844,7 +996,7 @@ function parseRepoName(url) {
|
|
|
844
996
|
function detectRepoName() {
|
|
845
997
|
let remoteUrl;
|
|
846
998
|
try {
|
|
847
|
-
remoteUrl =
|
|
999
|
+
remoteUrl = execFileSync(GIT_BINARY, ["remote", "get-url", "origin"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
848
1000
|
} catch {
|
|
849
1001
|
throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
|
|
850
1002
|
}
|
|
@@ -855,7 +1007,7 @@ function detectRepoName() {
|
|
|
855
1007
|
return repo;
|
|
856
1008
|
}
|
|
857
1009
|
function getCurrentBranch() {
|
|
858
|
-
const branch =
|
|
1010
|
+
const branch = execFileSync(GIT_BINARY, ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
859
1011
|
if (branch === "HEAD") {
|
|
860
1012
|
throw new Error("Not on a named branch. Check out a named branch and try again.");
|
|
861
1013
|
}
|
|
@@ -891,7 +1043,7 @@ function formatResolutionError() {
|
|
|
891
1043
|
return [
|
|
892
1044
|
"Could not resolve an Azure DevOps organization. Options (in priority order):",
|
|
893
1045
|
" 1. Pass --org <name> on the command line.",
|
|
894
|
-
" 2. Run this command from a git repo
|
|
1046
|
+
" 2. Run this command from a git repo that has an Azure DevOps remote.",
|
|
895
1047
|
" 3. Run `azdo config set org <name>` once to set a persistent default."
|
|
896
1048
|
].join("\n");
|
|
897
1049
|
}
|
|
@@ -904,9 +1056,9 @@ function resolveContext(options) {
|
|
|
904
1056
|
gitContext = detectAzdoContext();
|
|
905
1057
|
} catch {
|
|
906
1058
|
}
|
|
907
|
-
const config = loadConfig();
|
|
908
1059
|
const org = resolvedOrg?.org;
|
|
909
|
-
const
|
|
1060
|
+
const scopedCfg = resolveScopedConfig(org);
|
|
1061
|
+
const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || scopedCfg.project;
|
|
910
1062
|
if (org && project) {
|
|
911
1063
|
return { org, project };
|
|
912
1064
|
}
|
|
@@ -1058,49 +1210,216 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
|
|
|
1058
1210
|
}
|
|
1059
1211
|
}
|
|
1060
1212
|
|
|
1061
|
-
// src/
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1213
|
+
// src/services/image-download.ts
|
|
1214
|
+
import { Jimp } from "jimp";
|
|
1215
|
+
import { writeFile } from "fs/promises";
|
|
1216
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1217
|
+
import { tmpdir } from "os";
|
|
1218
|
+
import { join as join2 } from "path";
|
|
1219
|
+
var ATTACHMENT_GUID_RE = /_apis\/wit\/attachments\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/;
|
|
1220
|
+
function isAzureDevOpsAttachmentHost(hostname) {
|
|
1221
|
+
const host = hostname.toLowerCase();
|
|
1222
|
+
return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
|
|
1068
1223
|
}
|
|
1069
|
-
function
|
|
1070
|
-
|
|
1071
|
-
text = text.replaceAll(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
|
|
1072
|
-
text = text.replaceAll(/<br\s*\/?>/gi, "\n");
|
|
1073
|
-
text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
|
|
1074
|
-
text = text.replaceAll(/<li>/gi, "\n");
|
|
1075
|
-
text = removeHtmlTags(text);
|
|
1076
|
-
text = text.replaceAll("&", "&");
|
|
1077
|
-
text = text.replaceAll("<", "<");
|
|
1078
|
-
text = text.replaceAll(">", ">");
|
|
1079
|
-
text = text.replaceAll(""", '"');
|
|
1080
|
-
text = text.replaceAll("'", "'");
|
|
1081
|
-
text = text.replaceAll(" ", " ");
|
|
1082
|
-
text = text.replaceAll(/\n{3,}/g, "\n\n");
|
|
1083
|
-
return text.trim();
|
|
1224
|
+
function decodeHtmlEntities(value) {
|
|
1225
|
+
return value.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
1084
1226
|
}
|
|
1085
|
-
function
|
|
1086
|
-
|
|
1087
|
-
let
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
}
|
|
1093
|
-
if (char === ">" && insideTag) {
|
|
1094
|
-
insideTag = false;
|
|
1095
|
-
continue;
|
|
1096
|
-
}
|
|
1097
|
-
if (!insideTag) {
|
|
1098
|
-
result += char;
|
|
1099
|
-
}
|
|
1227
|
+
function parseAttachmentReference(rawUrl, sourceField) {
|
|
1228
|
+
const url = decodeHtmlEntities(rawUrl.trim());
|
|
1229
|
+
let parsed;
|
|
1230
|
+
try {
|
|
1231
|
+
parsed = new URL(url);
|
|
1232
|
+
} catch {
|
|
1233
|
+
return null;
|
|
1100
1234
|
}
|
|
1101
|
-
|
|
1235
|
+
if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
|
|
1239
|
+
if (!match) return null;
|
|
1240
|
+
const guid = match[1].toLowerCase();
|
|
1241
|
+
let suggestedExtension = ".png";
|
|
1242
|
+
const fileName = parsed.searchParams.get("fileName");
|
|
1243
|
+
if (fileName?.includes(".")) {
|
|
1244
|
+
suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
|
|
1245
|
+
}
|
|
1246
|
+
return { url, sourceField, guid, suggestedExtension };
|
|
1102
1247
|
}
|
|
1103
|
-
function
|
|
1248
|
+
function extractImageReferences(content, sourceField) {
|
|
1249
|
+
if (!content) return [];
|
|
1250
|
+
const references = [];
|
|
1251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1252
|
+
const add = (rawUrl) => {
|
|
1253
|
+
const reference = parseAttachmentReference(rawUrl, sourceField);
|
|
1254
|
+
if (reference && !seen.has(reference.guid)) {
|
|
1255
|
+
seen.add(reference.guid);
|
|
1256
|
+
references.push(reference);
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
|
|
1260
|
+
let match;
|
|
1261
|
+
while ((match = imgRegex.exec(content)) !== null) {
|
|
1262
|
+
add(match[1]);
|
|
1263
|
+
}
|
|
1264
|
+
const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
|
|
1265
|
+
while ((match = markdownRegex.exec(content)) !== null) {
|
|
1266
|
+
add(match[1]);
|
|
1267
|
+
}
|
|
1268
|
+
return references;
|
|
1269
|
+
}
|
|
1270
|
+
function addImageDownloadOptions(command) {
|
|
1271
|
+
return command.option("--download-images", "download images embedded in rich-text fields to local files").option("--resize-images <pixels>", "max image width in px; downloads and resizes embedded images to PNG (implies --download-images)").option("--images-path <dir>", "destination directory for downloaded images (default: system temp dir)");
|
|
1272
|
+
}
|
|
1273
|
+
function resolveImageDownloadOptionsOrExit(flags) {
|
|
1274
|
+
try {
|
|
1275
|
+
return resolveImageDownloadOptions(flags);
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1278
|
+
`);
|
|
1279
|
+
process.exit(1);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
function resolveImageDownloadOptions(flags) {
|
|
1283
|
+
const wantsResize = flags.resizeImages !== void 0;
|
|
1284
|
+
const enabled = Boolean(flags.downloadImages) || wantsResize;
|
|
1285
|
+
let maxWidth;
|
|
1286
|
+
if (wantsResize) {
|
|
1287
|
+
const parsed = Number(flags.resizeImages);
|
|
1288
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1289
|
+
throw new Error(
|
|
1290
|
+
`Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
maxWidth = parsed;
|
|
1294
|
+
}
|
|
1295
|
+
const outputDir = flags.imagesPath ?? tmpdir();
|
|
1296
|
+
if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
|
|
1297
|
+
throw new Error(`Images path "${outputDir}" does not exist.`);
|
|
1298
|
+
}
|
|
1299
|
+
return { enabled, maxWidth, outputDir };
|
|
1300
|
+
}
|
|
1301
|
+
function buildImageFileName(workItemId, index, reference, resizing) {
|
|
1302
|
+
const ext = resizing ? ".png" : reference.suggestedExtension;
|
|
1303
|
+
return `wi-${workItemId}-${index}${ext}`;
|
|
1304
|
+
}
|
|
1305
|
+
async function processImageBytes(bytes, maxWidth) {
|
|
1306
|
+
if (maxWidth === void 0) {
|
|
1307
|
+
return { buffer: Buffer.from(bytes), resized: false, format: "original" };
|
|
1308
|
+
}
|
|
1309
|
+
const image = await Jimp.read(Buffer.from(bytes));
|
|
1310
|
+
let resized = false;
|
|
1311
|
+
if (image.bitmap.width > maxWidth) {
|
|
1312
|
+
image.resize({ w: maxWidth });
|
|
1313
|
+
resized = true;
|
|
1314
|
+
}
|
|
1315
|
+
const buffer = await image.getBuffer("image/png");
|
|
1316
|
+
return { buffer, resized, format: "png" };
|
|
1317
|
+
}
|
|
1318
|
+
async function downloadImagesFromFields(fields, args, credential) {
|
|
1319
|
+
const { workItemId, options } = args;
|
|
1320
|
+
const resizing = options.maxWidth !== void 0;
|
|
1321
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1322
|
+
const references = [];
|
|
1323
|
+
for (const field of fields) {
|
|
1324
|
+
for (const reference of extractImageReferences(field.content, field.field)) {
|
|
1325
|
+
if (!seen.has(reference.guid)) {
|
|
1326
|
+
seen.add(reference.guid);
|
|
1327
|
+
references.push(reference);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const results = [];
|
|
1332
|
+
let index = 0;
|
|
1333
|
+
for (const reference of references) {
|
|
1334
|
+
index += 1;
|
|
1335
|
+
try {
|
|
1336
|
+
const bytes = await downloadAttachment(reference.url, credential);
|
|
1337
|
+
const processed = await processImageBytes(bytes, options.maxWidth);
|
|
1338
|
+
const fileName = buildImageFileName(workItemId, index, reference, resizing);
|
|
1339
|
+
const outputPath = join2(options.outputDir, fileName);
|
|
1340
|
+
await writeFile(outputPath, processed.buffer);
|
|
1341
|
+
results.push({
|
|
1342
|
+
reference,
|
|
1343
|
+
path: outputPath,
|
|
1344
|
+
resized: processed.resized,
|
|
1345
|
+
format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
|
|
1346
|
+
});
|
|
1347
|
+
} catch (err) {
|
|
1348
|
+
results.push({
|
|
1349
|
+
reference,
|
|
1350
|
+
resized: false,
|
|
1351
|
+
format: reference.suggestedExtension.replace(/^\./, ""),
|
|
1352
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return results;
|
|
1357
|
+
}
|
|
1358
|
+
async function runImageDownload(fields, args, credential) {
|
|
1359
|
+
const results = await downloadImagesFromFields(fields, args, credential);
|
|
1360
|
+
process.stdout.write(formatImageSummary(results) + "\n");
|
|
1361
|
+
for (const result of results) {
|
|
1362
|
+
if (result.error) {
|
|
1363
|
+
process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
|
|
1364
|
+
`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
function formatImageSummary(results) {
|
|
1369
|
+
if (results.length === 0) {
|
|
1370
|
+
return "Images: no images found in rich-text fields";
|
|
1371
|
+
}
|
|
1372
|
+
const saved = results.filter((r) => r.path);
|
|
1373
|
+
const lines = [`Images: ${saved.length} downloaded`];
|
|
1374
|
+
for (const result of saved) {
|
|
1375
|
+
lines.push(` ${result.path}`);
|
|
1376
|
+
}
|
|
1377
|
+
return lines.join("\n");
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// src/commands/get-item.ts
|
|
1381
|
+
function parseRequestedFields(raw) {
|
|
1382
|
+
if (raw === void 0) return void 0;
|
|
1383
|
+
const source = Array.isArray(raw) ? raw : [raw];
|
|
1384
|
+
const tokens = source.flatMap((entry) => entry.split(/[,\s]+/)).map((field) => field.trim()).filter((field) => field.length > 0);
|
|
1385
|
+
if (tokens.length === 0) return void 0;
|
|
1386
|
+
return Array.from(new Set(tokens));
|
|
1387
|
+
}
|
|
1388
|
+
function stripHtml(html) {
|
|
1389
|
+
let text = html;
|
|
1390
|
+
text = text.replaceAll(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
|
|
1391
|
+
text = text.replaceAll(/<br\s*\/?>/gi, "\n");
|
|
1392
|
+
text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
|
|
1393
|
+
text = text.replaceAll(/<li>/gi, "\n");
|
|
1394
|
+
text = removeHtmlTags(text);
|
|
1395
|
+
text = text.replaceAll("<", "<");
|
|
1396
|
+
text = text.replaceAll(">", ">");
|
|
1397
|
+
text = text.replaceAll(""", '"');
|
|
1398
|
+
text = text.replaceAll("'", "'");
|
|
1399
|
+
text = text.replaceAll(" ", " ");
|
|
1400
|
+
text = text.replaceAll("&", "&");
|
|
1401
|
+
text = text.replaceAll(/\n{3,}/g, "\n\n");
|
|
1402
|
+
return text.trim();
|
|
1403
|
+
}
|
|
1404
|
+
function removeHtmlTags(value) {
|
|
1405
|
+
let result = "";
|
|
1406
|
+
let insideTag = false;
|
|
1407
|
+
for (const char of value) {
|
|
1408
|
+
if (char === "<") {
|
|
1409
|
+
insideTag = true;
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
if (char === ">" && insideTag) {
|
|
1413
|
+
insideTag = false;
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
if (!insideTag) {
|
|
1417
|
+
result += char;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
return result;
|
|
1421
|
+
}
|
|
1422
|
+
function convertRichText(html, markdown) {
|
|
1104
1423
|
if (!html) return "";
|
|
1105
1424
|
return markdown ? toMarkdown(html) : stripHtml(html);
|
|
1106
1425
|
}
|
|
@@ -1181,19 +1500,32 @@ function formatWorkItem(workItem, short, markdown = false) {
|
|
|
1181
1500
|
}
|
|
1182
1501
|
function createGetItemCommand() {
|
|
1183
1502
|
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")
|
|
1503
|
+
command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown");
|
|
1504
|
+
addImageDownloadOptions(command);
|
|
1505
|
+
command.action(
|
|
1185
1506
|
async (idStr, options) => {
|
|
1186
1507
|
const id = parseWorkItemId(idStr);
|
|
1187
1508
|
validateOrgProjectPair(options);
|
|
1509
|
+
const imageOptions = resolveImageDownloadOptionsOrExit(options);
|
|
1188
1510
|
let context;
|
|
1189
1511
|
try {
|
|
1190
1512
|
context = resolveContext(options);
|
|
1191
|
-
const credential = await
|
|
1192
|
-
const
|
|
1193
|
-
const
|
|
1194
|
-
const
|
|
1513
|
+
const credential = await requireAuthCredential(context.org);
|
|
1514
|
+
const scopedCfg = resolveScopedConfig(context.org);
|
|
1515
|
+
const fieldsList = options.fields === void 0 ? parseRequestedFields(scopedCfg.fields) : parseRequestedFields(options.fields);
|
|
1516
|
+
const workItem = await getWorkItem(context, id, credential, fieldsList);
|
|
1517
|
+
const markdownEnabled = options.markdown ?? scopedCfg.markdown ?? false;
|
|
1195
1518
|
const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
|
|
1196
1519
|
process.stdout.write(output + "\n");
|
|
1520
|
+
if (imageOptions.enabled) {
|
|
1521
|
+
const fields = [{ content: workItem.description ?? "", field: "Description" }];
|
|
1522
|
+
if (workItem.extraFields) {
|
|
1523
|
+
for (const [name, value] of Object.entries(workItem.extraFields)) {
|
|
1524
|
+
fields.push({ content: value, field: name });
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
await runImageDownload(fields, { workItemId: id, options: imageOptions }, credential);
|
|
1528
|
+
}
|
|
1197
1529
|
} catch (err) {
|
|
1198
1530
|
handleCommandError(err, id, context, "read", false);
|
|
1199
1531
|
}
|
|
@@ -1229,63 +1561,6 @@ function createClearPatCommand() {
|
|
|
1229
1561
|
|
|
1230
1562
|
// src/commands/auth.ts
|
|
1231
1563
|
import { Command as Command3 } from "commander";
|
|
1232
|
-
|
|
1233
|
-
// src/services/browser-open.ts
|
|
1234
|
-
import { execFile } from "child_process";
|
|
1235
|
-
function isHeadless(platform, hasDisplay) {
|
|
1236
|
-
if (platform === "linux") {
|
|
1237
|
-
return !hasDisplay;
|
|
1238
|
-
}
|
|
1239
|
-
return false;
|
|
1240
|
-
}
|
|
1241
|
-
function commandForPlatform(platform) {
|
|
1242
|
-
switch (platform) {
|
|
1243
|
-
case "darwin":
|
|
1244
|
-
return { cmd: "open", args: (url) => [url] };
|
|
1245
|
-
case "win32":
|
|
1246
|
-
return { cmd: "cmd", args: (url) => ["/c", "start", '""', url] };
|
|
1247
|
-
case "linux":
|
|
1248
|
-
return { cmd: "xdg-open", args: (url) => [url] };
|
|
1249
|
-
default:
|
|
1250
|
-
return null;
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
async function openUrl(url, opts = {}) {
|
|
1254
|
-
const platform = opts.platform ?? process.platform;
|
|
1255
|
-
const hasDisplay = opts.hasDisplay ?? (process.env.DISPLAY !== void 0 && process.env.DISPLAY !== "");
|
|
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";
|
|
1261
|
-
}
|
|
1262
|
-
const spec = commandForPlatform(platform);
|
|
1263
|
-
if (!spec) {
|
|
1264
|
-
process.stderr.write(`Open this URL in your browser: ${url}
|
|
1265
|
-
`);
|
|
1266
|
-
return "printed";
|
|
1267
|
-
}
|
|
1268
|
-
const runner = opts.execFileFn ?? ((cmd, args, cb) => execFile(cmd, args, { timeout: 5e3 }, (err) => cb(err)));
|
|
1269
|
-
return await new Promise((resolve2) => {
|
|
1270
|
-
try {
|
|
1271
|
-
runner(spec.cmd, spec.args(url), (err) => {
|
|
1272
|
-
if (err) {
|
|
1273
|
-
process.stderr.write(`Open this URL in your browser: ${url}
|
|
1274
|
-
`);
|
|
1275
|
-
resolve2("printed");
|
|
1276
|
-
} else {
|
|
1277
|
-
resolve2("opened");
|
|
1278
|
-
}
|
|
1279
|
-
});
|
|
1280
|
-
} catch {
|
|
1281
|
-
process.stderr.write(`Open this URL in your browser: ${url}
|
|
1282
|
-
`);
|
|
1283
|
-
resolve2("printed");
|
|
1284
|
-
}
|
|
1285
|
-
});
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
// src/commands/auth.ts
|
|
1289
1564
|
async function readStdinToString() {
|
|
1290
1565
|
const chunks = [];
|
|
1291
1566
|
for await (const chunk of process.stdin) {
|
|
@@ -1293,9 +1568,9 @@ async function readStdinToString() {
|
|
|
1293
1568
|
}
|
|
1294
1569
|
return Buffer.concat(chunks).toString("utf8");
|
|
1295
1570
|
}
|
|
1296
|
-
async function
|
|
1571
|
+
async function promptYesNo(prompt) {
|
|
1297
1572
|
if (!process.stdin.isTTY) return true;
|
|
1298
|
-
process.stderr.write(
|
|
1573
|
+
process.stderr.write(prompt);
|
|
1299
1574
|
return await new Promise((resolve2) => {
|
|
1300
1575
|
process.stdin.setEncoding("utf8");
|
|
1301
1576
|
let answered = false;
|
|
@@ -1311,7 +1586,23 @@ async function confirmOverwrite(org) {
|
|
|
1311
1586
|
process.stdin.on("data", handler);
|
|
1312
1587
|
});
|
|
1313
1588
|
}
|
|
1314
|
-
async function
|
|
1589
|
+
async function confirmOverwrite(org) {
|
|
1590
|
+
return promptYesNo(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
|
|
1591
|
+
}
|
|
1592
|
+
async function confirmOverwriteCredential(org, existingKind) {
|
|
1593
|
+
const label = existingKind === "oauth" ? "OAuth credential" : "PAT";
|
|
1594
|
+
return promptYesNo(`A ${label} is already stored for org ${org}. The new login will replace it. Continue? [y/N] `);
|
|
1595
|
+
}
|
|
1596
|
+
function rejectMutuallyExclusive(opts) {
|
|
1597
|
+
if (opts.usePat && opts.deviceCode) {
|
|
1598
|
+
return "--use-pat and --device-code are mutually exclusive (PAT has no device-code flow).";
|
|
1599
|
+
}
|
|
1600
|
+
if (opts.usePat && (opts.clientId || opts.tenantId || opts.scopes)) {
|
|
1601
|
+
return "--use-pat cannot be combined with OAuth-only flags (--client-id / --tenant-id / --scopes).";
|
|
1602
|
+
}
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
async function handlePatLogin(options) {
|
|
1315
1606
|
const resolved = resolveOrg({ org: options.org });
|
|
1316
1607
|
if (!resolved) {
|
|
1317
1608
|
process.stderr.write(`${formatResolutionError()}
|
|
@@ -1377,7 +1668,145 @@ async function handleAuthRoot(options) {
|
|
|
1377
1668
|
process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
|
|
1378
1669
|
`);
|
|
1379
1670
|
}
|
|
1671
|
+
async function ensureOverwriteConfirmed(org) {
|
|
1672
|
+
try {
|
|
1673
|
+
const existing = await getStoredCredential(org);
|
|
1674
|
+
if (existing === null || !process.stdin.isTTY) return "ok";
|
|
1675
|
+
const ok = await confirmOverwriteCredential(org, existing.kind);
|
|
1676
|
+
return ok ? "ok" : "aborted";
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1679
|
+
process.stderr.write(`${err.message}
|
|
1680
|
+
`);
|
|
1681
|
+
return "unavailable";
|
|
1682
|
+
}
|
|
1683
|
+
throw err;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
function buildOAuthLoginOptions(options) {
|
|
1687
|
+
return {
|
|
1688
|
+
flow: options.deviceCode ? "device-code" : "auto",
|
|
1689
|
+
clientIdOverride: options.clientId,
|
|
1690
|
+
tenantIdOverride: options.tenantId,
|
|
1691
|
+
scopesOverride: options.scopes ? options.scopes.split(/\s+/).filter(Boolean) : void 0
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
function reportOAuthFailure(err) {
|
|
1695
|
+
const reason = typeof err === "object" && err !== null && "reason" in err ? err.reason : null;
|
|
1696
|
+
const msg = err.message;
|
|
1697
|
+
process.stderr.write(reason ? `OAuth login failed (${reason}): ${msg}
|
|
1698
|
+
` : `OAuth login failed: ${msg}
|
|
1699
|
+
`);
|
|
1700
|
+
const noDisplay = process.platform === "linux" && (!process.env.DISPLAY || process.env.DISPLAY.length === 0);
|
|
1701
|
+
if (noDisplay) {
|
|
1702
|
+
process.stderr.write("Tip: this host has no DISPLAY; pass --device-code to use the headless flow.\n");
|
|
1703
|
+
} else if (reason === "port-conflict") {
|
|
1704
|
+
process.stderr.write("Tip: another process is using the loopback callback port. Try again or pass --device-code.\n");
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
async function handleOAuthLogin(options) {
|
|
1708
|
+
const resolved = resolveOrg({ org: options.org });
|
|
1709
|
+
if (!resolved) {
|
|
1710
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1711
|
+
`);
|
|
1712
|
+
process.exitCode = 3;
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
const org = resolved.org;
|
|
1716
|
+
const confirm = await ensureOverwriteConfirmed(org);
|
|
1717
|
+
if (confirm === "aborted") {
|
|
1718
|
+
process.stderr.write("Aborted. Existing credential preserved.\n");
|
|
1719
|
+
process.exitCode = 1;
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (confirm === "unavailable") {
|
|
1723
|
+
process.exitCode = 4;
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
try {
|
|
1727
|
+
const result = await loginWithOAuth(org, buildOAuthLoginOptions(options));
|
|
1728
|
+
process.stdout.write(
|
|
1729
|
+
`Logged in to ${org} via OAuth (${result.flowUsed}). Account: ${result.accountId}; expires ${new Date(result.expiresAt * 1e3).toISOString()}.
|
|
1730
|
+
`
|
|
1731
|
+
);
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
reportOAuthFailure(err);
|
|
1734
|
+
process.exitCode = 1;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
async function handleAuthRoot(options) {
|
|
1738
|
+
const conflict = rejectMutuallyExclusive(options);
|
|
1739
|
+
if (conflict) {
|
|
1740
|
+
process.stderr.write(`${conflict}
|
|
1741
|
+
`);
|
|
1742
|
+
process.exitCode = 2;
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
await handlePatLogin(options);
|
|
1746
|
+
}
|
|
1747
|
+
async function handleLoginSubcommand(options) {
|
|
1748
|
+
const conflict = rejectMutuallyExclusive(options);
|
|
1749
|
+
if (conflict) {
|
|
1750
|
+
process.stderr.write(`${conflict}
|
|
1751
|
+
`);
|
|
1752
|
+
process.exitCode = 2;
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (options.fromStdin || options.usePat) {
|
|
1756
|
+
await handlePatLogin(options);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
await handleOAuthLogin(options);
|
|
1760
|
+
}
|
|
1761
|
+
async function handleStatusJson() {
|
|
1762
|
+
try {
|
|
1763
|
+
const report = await status();
|
|
1764
|
+
process.stdout.write(`${JSON.stringify(report)}
|
|
1765
|
+
`);
|
|
1766
|
+
} catch (err) {
|
|
1767
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1768
|
+
process.stderr.write(`${err.message}
|
|
1769
|
+
`);
|
|
1770
|
+
process.exitCode = 4;
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
throw err;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1380
1776
|
async function handleStatus(options, org) {
|
|
1777
|
+
if (options.json) {
|
|
1778
|
+
let backend2;
|
|
1779
|
+
let value2;
|
|
1780
|
+
try {
|
|
1781
|
+
backend2 = probeBackend();
|
|
1782
|
+
value2 = await getPat(org);
|
|
1783
|
+
} catch (err) {
|
|
1784
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1785
|
+
process.stderr.write(`${err.message}
|
|
1786
|
+
`);
|
|
1787
|
+
process.exitCode = 4;
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
throw err;
|
|
1791
|
+
}
|
|
1792
|
+
const storedEvents2 = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
|
|
1793
|
+
const last2 = storedEvents2.at(-1);
|
|
1794
|
+
const updatedAt2 = last2?.ts ?? null;
|
|
1795
|
+
if (!value2) {
|
|
1796
|
+
process.stdout.write(
|
|
1797
|
+
`${JSON.stringify({ org, backend: backend2, stored: false, masked: null, updated_at: updatedAt2 })}
|
|
1798
|
+
`
|
|
1799
|
+
);
|
|
1800
|
+
process.exitCode = 1;
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
const masked2 = maskedDisplay(value2);
|
|
1804
|
+
process.stdout.write(
|
|
1805
|
+
`${JSON.stringify({ org, backend: backend2, stored: true, masked: masked2, updated_at: updatedAt2 })}
|
|
1806
|
+
`
|
|
1807
|
+
);
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1381
1810
|
let backend;
|
|
1382
1811
|
let value;
|
|
1383
1812
|
try {
|
|
@@ -1393,39 +1822,25 @@ async function handleStatus(options, org) {
|
|
|
1393
1822
|
throw err;
|
|
1394
1823
|
}
|
|
1395
1824
|
const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
|
|
1396
|
-
const last = storedEvents
|
|
1825
|
+
const last = storedEvents.at(-1);
|
|
1397
1826
|
const updatedAt = last?.ts ?? null;
|
|
1398
1827
|
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}
|
|
1828
|
+
process.stdout.write(`Organization: ${org}
|
|
1406
1829
|
Backend: ${backend}
|
|
1407
1830
|
Stored: no
|
|
1408
1831
|
`);
|
|
1409
|
-
}
|
|
1410
1832
|
process.exitCode = 1;
|
|
1411
1833
|
return;
|
|
1412
1834
|
}
|
|
1413
1835
|
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}
|
|
1836
|
+
process.stdout.write(
|
|
1837
|
+
`Organization: ${org}
|
|
1422
1838
|
Backend: ${backend}
|
|
1423
1839
|
Stored: yes
|
|
1424
1840
|
Identifier: ${masked}
|
|
1425
1841
|
` + (updatedAt ? `Last updated: ${updatedAt}
|
|
1426
1842
|
` : "")
|
|
1427
|
-
|
|
1428
|
-
}
|
|
1843
|
+
);
|
|
1429
1844
|
}
|
|
1430
1845
|
async function handleLogout(options, orgFromGlobal) {
|
|
1431
1846
|
if (options.all && orgFromGlobal) {
|
|
@@ -1434,9 +1849,16 @@ async function handleLogout(options, orgFromGlobal) {
|
|
|
1434
1849
|
return;
|
|
1435
1850
|
}
|
|
1436
1851
|
if (options.all) {
|
|
1437
|
-
let orgs;
|
|
1438
1852
|
try {
|
|
1439
|
-
|
|
1853
|
+
const result = await logout({ all: true });
|
|
1854
|
+
if (result.removed.length === 0) {
|
|
1855
|
+
process.stdout.write("No stored credentials to remove.\n");
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
for (const r of result.removed) {
|
|
1859
|
+
process.stdout.write(`Removed ${r.kind} credential for org ${r.org}.
|
|
1860
|
+
`);
|
|
1861
|
+
}
|
|
1440
1862
|
} catch (err) {
|
|
1441
1863
|
if (err instanceof CredentialStoreUnavailableError) {
|
|
1442
1864
|
process.stderr.write(`${err.message}
|
|
@@ -1444,22 +1866,9 @@ async function handleLogout(options, orgFromGlobal) {
|
|
|
1444
1866
|
process.exitCode = 4;
|
|
1445
1867
|
return;
|
|
1446
1868
|
}
|
|
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}
|
|
1869
|
+
process.stderr.write(`Failed to remove credentials: ${err.message}
|
|
1460
1870
|
`);
|
|
1461
|
-
|
|
1462
|
-
}
|
|
1871
|
+
process.exitCode = 1;
|
|
1463
1872
|
}
|
|
1464
1873
|
return;
|
|
1465
1874
|
}
|
|
@@ -1473,10 +1882,10 @@ async function handleLogout(options, orgFromGlobal) {
|
|
|
1473
1882
|
try {
|
|
1474
1883
|
const removed = await deletePat(resolved.org);
|
|
1475
1884
|
if (removed) {
|
|
1476
|
-
process.stdout.write(`
|
|
1885
|
+
process.stdout.write(`Credential removed for org ${resolved.org}.
|
|
1477
1886
|
`);
|
|
1478
1887
|
} else {
|
|
1479
|
-
process.stdout.write(`No stored
|
|
1888
|
+
process.stdout.write(`No stored credential for org ${resolved.org}.
|
|
1480
1889
|
`);
|
|
1481
1890
|
}
|
|
1482
1891
|
} catch (err) {
|
|
@@ -1491,14 +1900,70 @@ async function handleLogout(options, orgFromGlobal) {
|
|
|
1491
1900
|
}
|
|
1492
1901
|
function createAuthCommand() {
|
|
1493
1902
|
const command = new Command3("auth");
|
|
1494
|
-
command.description(
|
|
1495
|
-
|
|
1496
|
-
|
|
1903
|
+
command.description(
|
|
1904
|
+
"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."
|
|
1905
|
+
);
|
|
1906
|
+
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)");
|
|
1907
|
+
command.addHelpText(
|
|
1908
|
+
"after",
|
|
1909
|
+
`
|
|
1910
|
+
Default flow (OAuth, browser-based):
|
|
1911
|
+
azdo auth login --org <name>
|
|
1912
|
+
\u2192 opens the default browser for OAuth (Microsoft Entra v2 + PKCE).
|
|
1913
|
+
|
|
1914
|
+
Headless / no-browser:
|
|
1915
|
+
azdo auth login --org <name> --device-code
|
|
1916
|
+
|
|
1917
|
+
PAT path (legacy, opt-in):
|
|
1918
|
+
azdo auth login --org <name> --use-pat
|
|
1919
|
+
azdo auth --org <name> # back-compat alias of the above
|
|
1920
|
+
|
|
1921
|
+
OAuth scope set \u2014 shipped first-party client (default install):
|
|
1922
|
+
${firstPartyShippedScopes().join("\n ")}
|
|
1923
|
+
(uses ${AZDO_RESOURCE_ID}/.default \u2014 per-scope consent is unavailable
|
|
1924
|
+
against a client we do not own; .default grants the VS client's
|
|
1925
|
+
pre-authorized AzDO permissions in one step.)
|
|
1926
|
+
|
|
1927
|
+
OAuth scope set \u2014 self-registered apps (--client-id / AZDO_OAUTH_CLIENT_ID):
|
|
1928
|
+
${defaultScopes().join("\n ")}
|
|
1929
|
+
(FR-016, mirrors the PAT scope table \u2014 see docs/oauth-app-registration.md)
|
|
1930
|
+
|
|
1931
|
+
For self-registered OAuth apps (locked-down tenants), see docs/oauth-app-registration.md
|
|
1932
|
+
\u2014 that same guide is the maintainer reference for the project's shared client id.
|
|
1933
|
+
|
|
1934
|
+
Note: stored credentials may coexist as 'pat' or 'oauth' across orgs (FR-007).
|
|
1935
|
+
Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
|
|
1936
|
+
\`azdo auth login\` is the spec-canonical name and defaults to OAuth.`
|
|
1937
|
+
).action(async (options) => {
|
|
1497
1938
|
await handleAuthRoot(options);
|
|
1498
1939
|
});
|
|
1499
|
-
const
|
|
1940
|
+
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)");
|
|
1941
|
+
loginCmd.action(async () => {
|
|
1942
|
+
const merged = loginCmd.optsWithGlobals();
|
|
1943
|
+
await handleLoginSubcommand(merged);
|
|
1944
|
+
});
|
|
1945
|
+
const statusCmd = command.command("status").description("Report stored credentials (kind, org, account/expiry, backend) \u2014 never the token").option("--json", "emit JSON", false);
|
|
1500
1946
|
statusCmd.action(async (options) => {
|
|
1501
1947
|
const globals = statusCmd.optsWithGlobals();
|
|
1948
|
+
if (!globals.org) {
|
|
1949
|
+
if (options.json) {
|
|
1950
|
+
await handleStatusJson();
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
const report = await status();
|
|
1954
|
+
if (report.orgs.length === 0) {
|
|
1955
|
+
process.stdout.write("No stored credentials.\n");
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
for (const e of report.orgs) {
|
|
1959
|
+
const expiry = e.expiresAt ? new Date(e.expiresAt * 1e3).toISOString() : "n/a";
|
|
1960
|
+
process.stdout.write(
|
|
1961
|
+
`${e.org} ${e.kind} ${e.accountId ?? ""} ${expiry}
|
|
1962
|
+
`
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1502
1967
|
const resolved = resolveOrg({ org: globals.org });
|
|
1503
1968
|
if (!resolved) {
|
|
1504
1969
|
process.stderr.write(`${formatResolutionError()}
|
|
@@ -1508,7 +1973,11 @@ function createAuthCommand() {
|
|
|
1508
1973
|
}
|
|
1509
1974
|
await handleStatus(options, resolved.org);
|
|
1510
1975
|
});
|
|
1511
|
-
|
|
1976
|
+
statusCmd.addHelpText(
|
|
1977
|
+
"after",
|
|
1978
|
+
"\nStored credentials may be of kind `pat` or `oauth` and may coexist across orgs (FR-007).\n"
|
|
1979
|
+
);
|
|
1980
|
+
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
1981
|
logoutCmd.action(async (options) => {
|
|
1513
1982
|
const globals = logoutCmd.optsWithGlobals();
|
|
1514
1983
|
await handleLogout(options, globals.org);
|
|
@@ -1525,18 +1994,47 @@ function formatConfigValue(value, unsetFallback = "") {
|
|
|
1525
1994
|
}
|
|
1526
1995
|
return Array.isArray(value) ? value.join(",") : value;
|
|
1527
1996
|
}
|
|
1528
|
-
function
|
|
1997
|
+
function buildConfigListEntries(cfg) {
|
|
1998
|
+
const entries = SETTINGS.map((s) => ({
|
|
1999
|
+
scope: "default",
|
|
2000
|
+
key: s.key,
|
|
2001
|
+
value: cfg[s.key]
|
|
2002
|
+
}));
|
|
2003
|
+
for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
|
|
2004
|
+
for (const [k, v] of Object.entries(scope)) {
|
|
2005
|
+
entries.push({ scope: orgName, key: k, value: v });
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
return entries;
|
|
2009
|
+
}
|
|
2010
|
+
function writeConfigList(cfg) {
|
|
1529
2011
|
const keyWidth = 10;
|
|
1530
2012
|
const valueWidth = 30;
|
|
2013
|
+
const scopeWidth = 12;
|
|
2014
|
+
process.stdout.write(
|
|
2015
|
+
`${"scope".padEnd(scopeWidth)}${"key".padEnd(keyWidth)}${"value".padEnd(valueWidth)}description
|
|
2016
|
+
`
|
|
2017
|
+
);
|
|
1531
2018
|
for (const setting of SETTINGS) {
|
|
1532
2019
|
const raw = cfg[setting.key];
|
|
1533
2020
|
const value = formatConfigValue(raw, "(not set)");
|
|
1534
2021
|
const marker = raw === void 0 && setting.required ? " *" : "";
|
|
1535
2022
|
process.stdout.write(
|
|
1536
|
-
`${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
2023
|
+
`${"default".padEnd(scopeWidth)}${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
1537
2024
|
`
|
|
1538
2025
|
);
|
|
1539
2026
|
}
|
|
2027
|
+
for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
|
|
2028
|
+
const orgScope = scope;
|
|
2029
|
+
const scopedSettings = Object.entries(orgScope);
|
|
2030
|
+
for (const [k, v] of scopedSettings) {
|
|
2031
|
+
const value = formatConfigValue(v, "(not set)");
|
|
2032
|
+
process.stdout.write(
|
|
2033
|
+
`${orgName.padEnd(scopeWidth)}${k.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}
|
|
2034
|
+
`
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
1540
2038
|
const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
|
|
1541
2039
|
if (hasUnset) {
|
|
1542
2040
|
process.stdout.write(
|
|
@@ -1580,17 +2078,22 @@ function createConfigCommand() {
|
|
|
1580
2078
|
const config = new Command4("config");
|
|
1581
2079
|
config.description("Manage CLI settings");
|
|
1582
2080
|
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) => {
|
|
2081
|
+
set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").argument("<value>", "setting value").option("--org <org>", "set value in an org-scoped configuration").option("--json", "output in JSON format").action((key, value, options) => {
|
|
1584
2082
|
try {
|
|
1585
|
-
|
|
2083
|
+
if (options.org) {
|
|
2084
|
+
setOrgScopedValue(options.org, key, value);
|
|
2085
|
+
} else {
|
|
2086
|
+
setConfigValue(key, value);
|
|
2087
|
+
}
|
|
1586
2088
|
if (options.json) {
|
|
1587
|
-
const output = { key, value };
|
|
2089
|
+
const output = { key, value, scope: options.org ?? "default" };
|
|
1588
2090
|
if (key === "fields") {
|
|
1589
2091
|
output.value = value.split(",").map((s) => s.trim());
|
|
1590
2092
|
}
|
|
1591
2093
|
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1592
2094
|
} else {
|
|
1593
|
-
|
|
2095
|
+
const scopeTag = options.org ? ` (org: ${options.org})` : "";
|
|
2096
|
+
process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
|
|
1594
2097
|
`);
|
|
1595
2098
|
}
|
|
1596
2099
|
} catch (err) {
|
|
@@ -1601,12 +2104,12 @@ function createConfigCommand() {
|
|
|
1601
2104
|
}
|
|
1602
2105
|
});
|
|
1603
2106
|
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) => {
|
|
2107
|
+
get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "read from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
|
|
1605
2108
|
try {
|
|
1606
|
-
const value = getConfigValue(key);
|
|
2109
|
+
const value = options.org ? getOrgScopedValue(options.org, key) : getConfigValue(key);
|
|
1607
2110
|
if (options.json) {
|
|
1608
2111
|
process.stdout.write(
|
|
1609
|
-
JSON.stringify({ key, value: value ?? null }) + "\n"
|
|
2112
|
+
JSON.stringify({ key, value: value ?? null, scope: options.org ?? "default" }) + "\n"
|
|
1610
2113
|
);
|
|
1611
2114
|
} else if (value === void 0) {
|
|
1612
2115
|
process.stdout.write(`Setting "${key}" is not configured.
|
|
@@ -1614,7 +2117,7 @@ function createConfigCommand() {
|
|
|
1614
2117
|
} else if (Array.isArray(value)) {
|
|
1615
2118
|
process.stdout.write(value.join(",") + "\n");
|
|
1616
2119
|
} else {
|
|
1617
|
-
process.stdout.write(value + "\n");
|
|
2120
|
+
process.stdout.write(String(value) + "\n");
|
|
1618
2121
|
}
|
|
1619
2122
|
} catch (err) {
|
|
1620
2123
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1627,19 +2130,25 @@ function createConfigCommand() {
|
|
|
1627
2130
|
list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
|
|
1628
2131
|
const cfg = loadConfig();
|
|
1629
2132
|
if (options.json) {
|
|
1630
|
-
|
|
2133
|
+
const entries = buildConfigListEntries(cfg);
|
|
2134
|
+
process.stdout.write(JSON.stringify(entries) + "\n");
|
|
1631
2135
|
return;
|
|
1632
2136
|
}
|
|
1633
2137
|
writeConfigList(cfg);
|
|
1634
2138
|
});
|
|
1635
2139
|
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) => {
|
|
2140
|
+
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "remove from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
|
|
1637
2141
|
try {
|
|
1638
|
-
|
|
2142
|
+
if (options.org) {
|
|
2143
|
+
unsetOrgScopedValue(options.org, key);
|
|
2144
|
+
} else {
|
|
2145
|
+
unsetConfigValue(key);
|
|
2146
|
+
}
|
|
1639
2147
|
if (options.json) {
|
|
1640
|
-
process.stdout.write(JSON.stringify({ key, unset: true }) + "\n");
|
|
2148
|
+
process.stdout.write(JSON.stringify({ key, unset: true, scope: options.org ?? "default" }) + "\n");
|
|
1641
2149
|
} else {
|
|
1642
|
-
|
|
2150
|
+
const scopeTag = options.org ? ` (org: ${options.org})` : "";
|
|
2151
|
+
process.stdout.write(`Unset "${key}"${scopeTag}
|
|
1643
2152
|
`);
|
|
1644
2153
|
}
|
|
1645
2154
|
} catch (err) {
|
|
@@ -1671,10 +2180,52 @@ function createConfigCommand() {
|
|
|
1671
2180
|
rl.close();
|
|
1672
2181
|
process.stderr.write("Configuration complete!\n");
|
|
1673
2182
|
});
|
|
2183
|
+
const orgCopy = new Command4("org-copy");
|
|
2184
|
+
orgCopy.description('Copy settings from one scope to another (use "default" as source to copy top-level settings)').argument("<from>", 'source scope name or "default"').argument("<to>", "destination org name").option("--force", "overwrite existing values on collision").action((from, to, options) => {
|
|
2185
|
+
try {
|
|
2186
|
+
copyOrgScope(from, to, options.force ?? false);
|
|
2187
|
+
process.stdout.write(`Copied scope "${from}" to "${to}"
|
|
2188
|
+
`);
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2191
|
+
process.stderr.write(`Error: ${message}
|
|
2192
|
+
`);
|
|
2193
|
+
process.exit(1);
|
|
2194
|
+
}
|
|
2195
|
+
});
|
|
2196
|
+
const orgMove = new Command4("org-move");
|
|
2197
|
+
orgMove.description("Move settings from one org scope to another").argument("<from>", "source org name").argument("<to>", "destination org name").option("--force", "overwrite existing values on collision").action((from, to, options) => {
|
|
2198
|
+
try {
|
|
2199
|
+
moveOrgScope(from, to, options.force ?? false);
|
|
2200
|
+
process.stdout.write(`Moved scope "${from}" to "${to}"
|
|
2201
|
+
`);
|
|
2202
|
+
} catch (err) {
|
|
2203
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2204
|
+
process.stderr.write(`Error: ${message}
|
|
2205
|
+
`);
|
|
2206
|
+
process.exit(1);
|
|
2207
|
+
}
|
|
2208
|
+
});
|
|
2209
|
+
const orgDelete = new Command4("org-delete");
|
|
2210
|
+
orgDelete.description("Delete an org-scoped configuration").argument("<name>", "org name").action((name) => {
|
|
2211
|
+
try {
|
|
2212
|
+
deleteOrgScope(name);
|
|
2213
|
+
process.stdout.write(`Deleted scope "${name}"
|
|
2214
|
+
`);
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2217
|
+
process.stderr.write(`Error: ${message}
|
|
2218
|
+
`);
|
|
2219
|
+
process.exit(1);
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
1674
2222
|
config.addCommand(set);
|
|
1675
2223
|
config.addCommand(get);
|
|
1676
2224
|
config.addCommand(list);
|
|
1677
2225
|
config.addCommand(unset);
|
|
2226
|
+
config.addCommand(orgCopy);
|
|
2227
|
+
config.addCommand(orgMove);
|
|
2228
|
+
config.addCommand(orgDelete);
|
|
1678
2229
|
config.addCommand(wizard);
|
|
1679
2230
|
return config;
|
|
1680
2231
|
}
|
|
@@ -1690,11 +2241,11 @@ function createSetStateCommand() {
|
|
|
1690
2241
|
let context;
|
|
1691
2242
|
try {
|
|
1692
2243
|
context = resolveContext(options);
|
|
1693
|
-
const credential = await
|
|
2244
|
+
const credential = await requireAuthCredential(context.org);
|
|
1694
2245
|
const operations = [
|
|
1695
2246
|
{ op: "add", path: "/fields/System.State", value: state }
|
|
1696
2247
|
];
|
|
1697
|
-
const result = await updateWorkItem(context, id, credential
|
|
2248
|
+
const result = await updateWorkItem(context, id, credential, "System.State", operations);
|
|
1698
2249
|
if (options.json) {
|
|
1699
2250
|
process.stdout.write(
|
|
1700
2251
|
JSON.stringify({
|
|
@@ -1740,12 +2291,12 @@ function createAssignCommand() {
|
|
|
1740
2291
|
let context;
|
|
1741
2292
|
try {
|
|
1742
2293
|
context = resolveContext(options);
|
|
1743
|
-
const credential = await
|
|
2294
|
+
const credential = await requireAuthCredential(context.org);
|
|
1744
2295
|
const value = options.unassign ? "" : name;
|
|
1745
2296
|
const operations = [
|
|
1746
2297
|
{ op: "add", path: "/fields/System.AssignedTo", value }
|
|
1747
2298
|
];
|
|
1748
|
-
const result = await updateWorkItem(context, id, credential
|
|
2299
|
+
const result = await updateWorkItem(context, id, credential, "System.AssignedTo", operations);
|
|
1749
2300
|
if (options.json) {
|
|
1750
2301
|
process.stdout.write(
|
|
1751
2302
|
JSON.stringify({
|
|
@@ -1780,11 +2331,11 @@ function createSetFieldCommand() {
|
|
|
1780
2331
|
let context;
|
|
1781
2332
|
try {
|
|
1782
2333
|
context = resolveContext(options);
|
|
1783
|
-
const credential = await
|
|
2334
|
+
const credential = await requireAuthCredential(context.org);
|
|
1784
2335
|
const operations = [
|
|
1785
2336
|
{ op: "add", path: `/fields/${field}`, value }
|
|
1786
2337
|
];
|
|
1787
|
-
const result = await updateWorkItem(context, id, credential
|
|
2338
|
+
const result = await updateWorkItem(context, id, credential, field, operations);
|
|
1788
2339
|
if (options.json) {
|
|
1789
2340
|
process.stdout.write(
|
|
1790
2341
|
JSON.stringify({
|
|
@@ -1811,20 +2362,30 @@ function createSetFieldCommand() {
|
|
|
1811
2362
|
import { Command as Command8 } from "commander";
|
|
1812
2363
|
function createGetMdFieldCommand() {
|
|
1813
2364
|
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")
|
|
2365
|
+
command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
|
|
2366
|
+
addImageDownloadOptions(command);
|
|
2367
|
+
command.action(
|
|
1815
2368
|
async (idStr, field, options) => {
|
|
1816
2369
|
const id = parseWorkItemId(idStr);
|
|
1817
2370
|
validateOrgProjectPair(options);
|
|
2371
|
+
const imageOptions = resolveImageDownloadOptionsOrExit(options);
|
|
1818
2372
|
let context;
|
|
1819
2373
|
try {
|
|
1820
2374
|
context = resolveContext(options);
|
|
1821
|
-
const credential = await
|
|
1822
|
-
const value = await getWorkItemFieldValue(context, id, credential
|
|
2375
|
+
const credential = await requireAuthCredential(context.org);
|
|
2376
|
+
const value = await getWorkItemFieldValue(context, id, credential, field);
|
|
1823
2377
|
if (value === null) {
|
|
1824
2378
|
process.stdout.write("\n");
|
|
1825
2379
|
} else {
|
|
1826
2380
|
process.stdout.write(toMarkdown(value) + "\n");
|
|
1827
2381
|
}
|
|
2382
|
+
if (imageOptions.enabled) {
|
|
2383
|
+
await runImageDownload(
|
|
2384
|
+
[{ content: value ?? "", field }],
|
|
2385
|
+
{ workItemId: id, options: imageOptions },
|
|
2386
|
+
credential
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
1828
2389
|
} catch (err) {
|
|
1829
2390
|
handleCommandError(err, id, context, "read");
|
|
1830
2391
|
}
|
|
@@ -1834,7 +2395,7 @@ function createGetMdFieldCommand() {
|
|
|
1834
2395
|
}
|
|
1835
2396
|
|
|
1836
2397
|
// src/commands/set-md-field.ts
|
|
1837
|
-
import { existsSync as
|
|
2398
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
1838
2399
|
import { Command as Command9 } from "commander";
|
|
1839
2400
|
function fail(message) {
|
|
1840
2401
|
process.stderr.write(`Error: ${message}
|
|
@@ -1854,7 +2415,7 @@ function resolveContent(inlineContent, options) {
|
|
|
1854
2415
|
return null;
|
|
1855
2416
|
}
|
|
1856
2417
|
function readFileContent(filePath) {
|
|
1857
|
-
if (!
|
|
2418
|
+
if (!existsSync3(filePath)) {
|
|
1858
2419
|
fail(`File not found: ${filePath}`);
|
|
1859
2420
|
}
|
|
1860
2421
|
try {
|
|
@@ -1906,12 +2467,12 @@ function createSetMdFieldCommand() {
|
|
|
1906
2467
|
let context;
|
|
1907
2468
|
try {
|
|
1908
2469
|
context = resolveContext(options);
|
|
1909
|
-
const credential = await
|
|
2470
|
+
const credential = await requireAuthCredential(context.org);
|
|
1910
2471
|
const operations = [
|
|
1911
2472
|
{ op: "add", path: `/fields/${field}`, value: content },
|
|
1912
2473
|
{ op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
|
|
1913
2474
|
];
|
|
1914
|
-
const result = await updateWorkItem(context, id, credential
|
|
2475
|
+
const result = await updateWorkItem(context, id, credential, field, operations);
|
|
1915
2476
|
formatOutput(result, options, field);
|
|
1916
2477
|
} catch (err) {
|
|
1917
2478
|
handleCommandError(err, id, context, "write");
|
|
@@ -1922,7 +2483,7 @@ function createSetMdFieldCommand() {
|
|
|
1922
2483
|
}
|
|
1923
2484
|
|
|
1924
2485
|
// src/commands/upsert.ts
|
|
1925
|
-
import { existsSync as
|
|
2486
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
|
|
1926
2487
|
import { Command as Command10 } from "commander";
|
|
1927
2488
|
|
|
1928
2489
|
// src/services/task-document.ts
|
|
@@ -2086,7 +2647,7 @@ function loadSourceContent(options) {
|
|
|
2086
2647
|
return { content: options.content };
|
|
2087
2648
|
}
|
|
2088
2649
|
const filePath = options.file;
|
|
2089
|
-
if (!
|
|
2650
|
+
if (!existsSync4(filePath)) {
|
|
2090
2651
|
fail2(`File not found: ${filePath}`);
|
|
2091
2652
|
}
|
|
2092
2653
|
try {
|
|
@@ -2222,15 +2783,15 @@ function createUpsertCommand() {
|
|
|
2222
2783
|
ensureTitleForCreate(document.fields);
|
|
2223
2784
|
}
|
|
2224
2785
|
const operations = toPatchOperations(document.fields, action);
|
|
2225
|
-
const credential = await
|
|
2786
|
+
const credential = await requireAuthCredential(context.org);
|
|
2226
2787
|
let writeResult;
|
|
2227
2788
|
if (action === "created") {
|
|
2228
|
-
writeResult = await createWorkItem(context, createType, credential
|
|
2789
|
+
writeResult = await createWorkItem(context, createType, credential, operations);
|
|
2229
2790
|
} else {
|
|
2230
2791
|
if (id === void 0) {
|
|
2231
2792
|
fail2("Work item ID is required for updates.");
|
|
2232
2793
|
}
|
|
2233
|
-
writeResult = await applyWorkItemPatch(context, id, credential
|
|
2794
|
+
writeResult = await applyWorkItemPatch(context, id, credential, operations);
|
|
2234
2795
|
}
|
|
2235
2796
|
const result = buildUpsertResult(
|
|
2236
2797
|
action,
|
|
@@ -2288,8 +2849,8 @@ function createListFieldsCommand() {
|
|
|
2288
2849
|
let context;
|
|
2289
2850
|
try {
|
|
2290
2851
|
context = resolveContext(options);
|
|
2291
|
-
const credential = await
|
|
2292
|
-
const fields = await getWorkItemFields(context, id, credential
|
|
2852
|
+
const credential = await requireAuthCredential(context.org);
|
|
2853
|
+
const fields = await getWorkItemFields(context, id, credential);
|
|
2293
2854
|
if (options.json) {
|
|
2294
2855
|
process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
|
|
2295
2856
|
} else {
|
|
@@ -2331,6 +2892,31 @@ function buildPullRequestStatusesUrl(context, repo, prId) {
|
|
|
2331
2892
|
url.searchParams.set("api-version", "7.1");
|
|
2332
2893
|
return url;
|
|
2333
2894
|
}
|
|
2895
|
+
function buildProjectUrl(context) {
|
|
2896
|
+
const url = new URL(
|
|
2897
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/projects/${encodeURIComponent(context.project)}`
|
|
2898
|
+
);
|
|
2899
|
+
url.searchParams.set("api-version", "7.1");
|
|
2900
|
+
return url;
|
|
2901
|
+
}
|
|
2902
|
+
function buildPolicyEvaluationsUrl(context, projectId, prId) {
|
|
2903
|
+
const url = new URL(
|
|
2904
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/policy/evaluations`
|
|
2905
|
+
);
|
|
2906
|
+
url.searchParams.set("api-version", "7.1");
|
|
2907
|
+
url.searchParams.set("artifactId", `vstfs:///CodeReview/CodeReviewId/${projectId}/${prId}`);
|
|
2908
|
+
return url;
|
|
2909
|
+
}
|
|
2910
|
+
function buildPullRequestBuildsUrl(context, prId) {
|
|
2911
|
+
const url = new URL(
|
|
2912
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/build/builds`
|
|
2913
|
+
);
|
|
2914
|
+
url.searchParams.set("branchName", `refs/pull/${prId}/merge`);
|
|
2915
|
+
url.searchParams.set("queryOrder", "queueTimeDescending");
|
|
2916
|
+
url.searchParams.set("$top", "50");
|
|
2917
|
+
url.searchParams.set("api-version", "7.1");
|
|
2918
|
+
return url;
|
|
2919
|
+
}
|
|
2334
2920
|
function mapPullRequest(repo, pullRequest) {
|
|
2335
2921
|
return {
|
|
2336
2922
|
id: pullRequest.pullRequestId,
|
|
@@ -2343,9 +2929,9 @@ function mapPullRequest(repo, pullRequest) {
|
|
|
2343
2929
|
url: pullRequest._links?.web?.href ?? null
|
|
2344
2930
|
};
|
|
2345
2931
|
}
|
|
2346
|
-
function mapPullRequestCheckName(
|
|
2347
|
-
const genre =
|
|
2348
|
-
const name =
|
|
2932
|
+
function mapPullRequestCheckName(status2) {
|
|
2933
|
+
const genre = status2.context?.genre?.trim();
|
|
2934
|
+
const name = status2.context?.name?.trim();
|
|
2349
2935
|
if (genre && name) {
|
|
2350
2936
|
return `${genre}/${name}`;
|
|
2351
2937
|
}
|
|
@@ -2355,21 +2941,80 @@ function mapPullRequestCheckName(status) {
|
|
|
2355
2941
|
if (genre) {
|
|
2356
2942
|
return genre;
|
|
2357
2943
|
}
|
|
2358
|
-
return `Status #${
|
|
2944
|
+
return `Status #${status2.id}`;
|
|
2945
|
+
}
|
|
2946
|
+
function mapPullRequestCheck(status2) {
|
|
2947
|
+
if (status2.state === "notApplicable" || status2.state === "notSet") {
|
|
2948
|
+
return null;
|
|
2949
|
+
}
|
|
2950
|
+
return {
|
|
2951
|
+
id: status2.id,
|
|
2952
|
+
state: status2.state,
|
|
2953
|
+
name: mapPullRequestCheckName(status2),
|
|
2954
|
+
description: status2.description ?? null,
|
|
2955
|
+
targetUrl: status2.targetUrl ?? null,
|
|
2956
|
+
createdBy: status2.createdBy?.displayName ?? null,
|
|
2957
|
+
createdAt: status2.creationDate ?? null,
|
|
2958
|
+
updatedAt: status2.updatedDate ?? null,
|
|
2959
|
+
source: "status"
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
function mapPolicyEvaluationState(status2) {
|
|
2963
|
+
switch (status2) {
|
|
2964
|
+
case "approved":
|
|
2965
|
+
return "succeeded";
|
|
2966
|
+
case "rejected":
|
|
2967
|
+
return "failed";
|
|
2968
|
+
case "running":
|
|
2969
|
+
case "queued":
|
|
2970
|
+
return "pending";
|
|
2971
|
+
case "notApplicable":
|
|
2972
|
+
case "notSet":
|
|
2973
|
+
case void 0:
|
|
2974
|
+
return null;
|
|
2975
|
+
default:
|
|
2976
|
+
return status2;
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
function mapBuildToCheckState(build) {
|
|
2980
|
+
if (build.status !== "completed") {
|
|
2981
|
+
return "pending";
|
|
2982
|
+
}
|
|
2983
|
+
switch (build.result) {
|
|
2984
|
+
case "succeeded":
|
|
2985
|
+
case "partiallySucceeded":
|
|
2986
|
+
return "succeeded";
|
|
2987
|
+
case "failed":
|
|
2988
|
+
return "failed";
|
|
2989
|
+
case "canceled":
|
|
2990
|
+
return "error";
|
|
2991
|
+
default:
|
|
2992
|
+
return "pending";
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
function mapPolicyEvaluationName(evaluation) {
|
|
2996
|
+
const display = evaluation.configuration?.settings?.displayName?.trim() || evaluation.configuration?.type?.displayName?.trim();
|
|
2997
|
+
if (display) {
|
|
2998
|
+
return display;
|
|
2999
|
+
}
|
|
3000
|
+
return `Policy ${evaluation.configuration?.id ?? evaluation.evaluationId ?? "?"}`;
|
|
2359
3001
|
}
|
|
2360
|
-
function
|
|
2361
|
-
|
|
3002
|
+
function mapPolicyEvaluationCheck(evaluation) {
|
|
3003
|
+
const state = mapPolicyEvaluationState(evaluation.status);
|
|
3004
|
+
if (state === null) {
|
|
2362
3005
|
return null;
|
|
2363
3006
|
}
|
|
2364
3007
|
return {
|
|
2365
|
-
id:
|
|
2366
|
-
state
|
|
2367
|
-
name:
|
|
2368
|
-
description:
|
|
2369
|
-
targetUrl:
|
|
2370
|
-
createdBy:
|
|
2371
|
-
createdAt:
|
|
2372
|
-
updatedAt:
|
|
3008
|
+
id: evaluation.configuration?.id ?? 0,
|
|
3009
|
+
state,
|
|
3010
|
+
name: mapPolicyEvaluationName(evaluation),
|
|
3011
|
+
description: null,
|
|
3012
|
+
targetUrl: null,
|
|
3013
|
+
createdBy: null,
|
|
3014
|
+
createdAt: null,
|
|
3015
|
+
updatedAt: null,
|
|
3016
|
+
source: "policy",
|
|
3017
|
+
isBlocking: evaluation.configuration?.isBlocking ?? null
|
|
2373
3018
|
};
|
|
2374
3019
|
}
|
|
2375
3020
|
function mapComment(comment) {
|
|
@@ -2389,24 +3034,28 @@ function mapThread(thread) {
|
|
|
2389
3034
|
if (comments.length === 0) {
|
|
2390
3035
|
return null;
|
|
2391
3036
|
}
|
|
3037
|
+
const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
|
|
2392
3038
|
return {
|
|
2393
3039
|
id: thread.id,
|
|
2394
|
-
status: thread.status,
|
|
3040
|
+
status: thread.status ?? "unknown",
|
|
2395
3041
|
threadContext: thread.threadContext?.filePath ?? null,
|
|
3042
|
+
line,
|
|
2396
3043
|
comments
|
|
2397
3044
|
};
|
|
2398
3045
|
}
|
|
2399
3046
|
function toActiveCommentThread(thread) {
|
|
3047
|
+
const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
|
|
2400
3048
|
return {
|
|
2401
3049
|
id: thread.id,
|
|
2402
|
-
status: thread.status,
|
|
3050
|
+
status: thread.status ?? "unknown",
|
|
2403
3051
|
threadContext: thread.threadContext?.filePath ?? null,
|
|
3052
|
+
line,
|
|
2404
3053
|
comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
|
|
2405
3054
|
};
|
|
2406
3055
|
}
|
|
2407
3056
|
var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
|
|
2408
|
-
function isThreadResolved(
|
|
2409
|
-
return RESOLVED_THREAD_STATUSES.has(
|
|
3057
|
+
function isThreadResolved(status2) {
|
|
3058
|
+
return RESOLVED_THREAD_STATUSES.has(status2);
|
|
2410
3059
|
}
|
|
2411
3060
|
async function readJsonResponse(response) {
|
|
2412
3061
|
if (!response.ok) {
|
|
@@ -2414,7 +3063,7 @@ async function readJsonResponse(response) {
|
|
|
2414
3063
|
}
|
|
2415
3064
|
return response.json();
|
|
2416
3065
|
}
|
|
2417
|
-
async function patchThreadStatus(context, repo,
|
|
3066
|
+
async function patchThreadStatus(context, repo, cred, prId, threadId, status2) {
|
|
2418
3067
|
const url = new URL(
|
|
2419
3068
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
|
|
2420
3069
|
);
|
|
@@ -2422,41 +3071,74 @@ async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
|
|
|
2422
3071
|
const response = await fetchWithErrors(url.toString(), {
|
|
2423
3072
|
method: "PATCH",
|
|
2424
3073
|
headers: {
|
|
2425
|
-
...authHeaders(
|
|
3074
|
+
...authHeaders(cred),
|
|
2426
3075
|
"Content-Type": "application/json"
|
|
2427
3076
|
},
|
|
2428
|
-
body: JSON.stringify({ status })
|
|
3077
|
+
body: JSON.stringify({ status: status2 })
|
|
2429
3078
|
});
|
|
2430
3079
|
const data = await readJsonResponse(response);
|
|
2431
3080
|
return toActiveCommentThread(data);
|
|
2432
3081
|
}
|
|
2433
|
-
async function getPullRequestById(context, repo,
|
|
3082
|
+
async function getPullRequestById(context, repo, cred, prId) {
|
|
2434
3083
|
const url = new URL(
|
|
2435
3084
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
|
|
2436
3085
|
);
|
|
2437
3086
|
url.searchParams.set("api-version", "7.1");
|
|
2438
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
3087
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
2439
3088
|
const data = await readJsonResponse(response);
|
|
2440
3089
|
return mapPullRequest(repo, data);
|
|
2441
3090
|
}
|
|
2442
|
-
async function listPullRequests(context, repo,
|
|
3091
|
+
async function listPullRequests(context, repo, cred, sourceBranch, opts) {
|
|
2443
3092
|
const response = await fetchWithErrors(
|
|
2444
3093
|
buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
|
|
2445
|
-
{ headers: authHeaders(
|
|
3094
|
+
{ headers: authHeaders(cred) }
|
|
2446
3095
|
);
|
|
2447
3096
|
const data = await readJsonResponse(response);
|
|
2448
3097
|
return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
|
|
2449
3098
|
}
|
|
2450
|
-
async function getPullRequestChecks(context, repo,
|
|
3099
|
+
async function getPullRequestChecks(context, repo, cred, prId) {
|
|
2451
3100
|
const response = await fetchWithErrors(
|
|
2452
3101
|
buildPullRequestStatusesUrl(context, repo, prId).toString(),
|
|
2453
|
-
{ headers: authHeaders(
|
|
3102
|
+
{ headers: authHeaders(cred) }
|
|
2454
3103
|
);
|
|
2455
3104
|
const data = await readJsonResponse(response);
|
|
2456
3105
|
return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
|
|
2457
3106
|
}
|
|
2458
|
-
async function
|
|
2459
|
-
const
|
|
3107
|
+
async function resolveProjectId(context, cred) {
|
|
3108
|
+
const response = await fetchWithErrors(buildProjectUrl(context).toString(), {
|
|
3109
|
+
headers: authHeaders(cred)
|
|
3110
|
+
});
|
|
3111
|
+
const data = await readJsonResponse(response);
|
|
3112
|
+
return data.id;
|
|
3113
|
+
}
|
|
3114
|
+
async function getPullRequestPolicyEvaluations(context, cred, projectId, prId) {
|
|
3115
|
+
const response = await fetchWithErrors(
|
|
3116
|
+
buildPolicyEvaluationsUrl(context, projectId, prId).toString(),
|
|
3117
|
+
{ headers: authHeaders(cred) }
|
|
3118
|
+
);
|
|
3119
|
+
const data = await readJsonResponse(response);
|
|
3120
|
+
return data.value.map(mapPolicyEvaluationCheck).filter((check) => check !== null);
|
|
3121
|
+
}
|
|
3122
|
+
async function getPullRequestBuilds(context, cred, prId) {
|
|
3123
|
+
const response = await fetchWithErrors(buildPullRequestBuildsUrl(context, prId).toString(), {
|
|
3124
|
+
headers: authHeaders(cred)
|
|
3125
|
+
});
|
|
3126
|
+
const data = await readJsonResponse(response);
|
|
3127
|
+
return data.value.map((build) => ({
|
|
3128
|
+
id: build.id,
|
|
3129
|
+
state: mapBuildToCheckState(build),
|
|
3130
|
+
name: build.definition?.name ?? `Build #${build.id}`,
|
|
3131
|
+
description: null,
|
|
3132
|
+
targetUrl: build._links?.web?.href ?? null,
|
|
3133
|
+
createdBy: null,
|
|
3134
|
+
createdAt: build.queueTime ?? null,
|
|
3135
|
+
updatedAt: build.finishTime ?? null,
|
|
3136
|
+
source: "build",
|
|
3137
|
+
isBlocking: null
|
|
3138
|
+
}));
|
|
3139
|
+
}
|
|
3140
|
+
async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
|
|
3141
|
+
const existing = await listPullRequests(context, repo, cred, sourceBranch, {
|
|
2460
3142
|
status: "active",
|
|
2461
3143
|
targetBranch: "develop"
|
|
2462
3144
|
});
|
|
@@ -2484,7 +3166,7 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
|
|
|
2484
3166
|
const response = await fetchWithErrors(url.toString(), {
|
|
2485
3167
|
method: "POST",
|
|
2486
3168
|
headers: {
|
|
2487
|
-
...authHeaders(
|
|
3169
|
+
...authHeaders(cred),
|
|
2488
3170
|
"Content-Type": "application/json"
|
|
2489
3171
|
},
|
|
2490
3172
|
body: JSON.stringify(payload)
|
|
@@ -2497,12 +3179,12 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
|
|
|
2497
3179
|
pullRequest: mapPullRequest(repo, data)
|
|
2498
3180
|
};
|
|
2499
3181
|
}
|
|
2500
|
-
async function getPullRequestThreads(context, repo,
|
|
3182
|
+
async function getPullRequestThreads(context, repo, cred, prId) {
|
|
2501
3183
|
const url = new URL(
|
|
2502
3184
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
|
|
2503
3185
|
);
|
|
2504
3186
|
url.searchParams.set("api-version", "7.1");
|
|
2505
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
3187
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
2506
3188
|
const data = await readJsonResponse(response);
|
|
2507
3189
|
return data.value.map(mapThread).filter((thread) => thread !== null);
|
|
2508
3190
|
}
|
|
@@ -2515,6 +3197,21 @@ function parsePositivePrNumber(raw) {
|
|
|
2515
3197
|
const n = Number.parseInt(raw, 10);
|
|
2516
3198
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
2517
3199
|
}
|
|
3200
|
+
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.";
|
|
3201
|
+
function configureUnwrappedHelp(command) {
|
|
3202
|
+
return command.configureHelp({ helpWidth: 1e3 });
|
|
3203
|
+
}
|
|
3204
|
+
function autoDetectZeroMatch(branch) {
|
|
3205
|
+
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.`;
|
|
3206
|
+
}
|
|
3207
|
+
function autoDetectMultiMatch(branch, ids) {
|
|
3208
|
+
return `Multiple open pull requests match branch ${branch}: ${ids.map((id) => `#${id}`).join(", ")}. Re-run with --pr-number to choose.`;
|
|
3209
|
+
}
|
|
3210
|
+
function writeContractError(line) {
|
|
3211
|
+
process.stderr.write(`${line}
|
|
3212
|
+
`);
|
|
3213
|
+
process.exitCode = 1;
|
|
3214
|
+
}
|
|
2518
3215
|
function formatBranchName(refName) {
|
|
2519
3216
|
return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
|
|
2520
3217
|
}
|
|
@@ -2548,34 +3245,101 @@ function handlePrCommandError(err, context, mode = "read") {
|
|
|
2548
3245
|
}
|
|
2549
3246
|
writeError(error.message);
|
|
2550
3247
|
}
|
|
2551
|
-
function formatPullRequestChecks(checks) {
|
|
3248
|
+
function formatPullRequestChecks(checks, checksError) {
|
|
3249
|
+
if (checksError) {
|
|
3250
|
+
return [`Checks: unable to retrieve (${checksError})`];
|
|
3251
|
+
}
|
|
2552
3252
|
if (checks.length === 0) {
|
|
2553
3253
|
return ["Checks: none reported by Azure DevOps"];
|
|
2554
3254
|
}
|
|
2555
3255
|
const lines = ["Checks:"];
|
|
2556
3256
|
for (const check of checks) {
|
|
2557
|
-
|
|
3257
|
+
const optionalTag = check.isBlocking === false ? " [optional]" : "";
|
|
3258
|
+
lines.push(`- [${check.state}] ${check.name}${optionalTag}`);
|
|
2558
3259
|
if ((check.state === "failed" || check.state === "error") && check.description) {
|
|
2559
3260
|
lines.push(` Detail: ${check.description}`);
|
|
2560
3261
|
}
|
|
2561
3262
|
}
|
|
2562
3263
|
return lines;
|
|
2563
3264
|
}
|
|
3265
|
+
function countCodeComments(threads) {
|
|
3266
|
+
let open = 0;
|
|
3267
|
+
let closed = 0;
|
|
3268
|
+
for (const thread of threads) {
|
|
3269
|
+
if (thread.threadContext === null) {
|
|
3270
|
+
continue;
|
|
3271
|
+
}
|
|
3272
|
+
if (isThreadResolved(thread.status)) {
|
|
3273
|
+
closed += 1;
|
|
3274
|
+
} else {
|
|
3275
|
+
open += 1;
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
return { open, closed };
|
|
3279
|
+
}
|
|
3280
|
+
function formatCodeCommentCounts(counts) {
|
|
3281
|
+
return `Code comments: ${counts.open} open, ${counts.closed} closed`;
|
|
3282
|
+
}
|
|
2564
3283
|
function formatPullRequestBlock(pullRequest) {
|
|
2565
3284
|
return [
|
|
2566
3285
|
`#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
|
|
2567
3286
|
`${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
|
|
2568
3287
|
pullRequest.url ?? "\u2014",
|
|
2569
|
-
...formatPullRequestChecks(pullRequest.checks)
|
|
3288
|
+
...formatPullRequestChecks(pullRequest.checks, pullRequest.checksError),
|
|
3289
|
+
formatCodeCommentCounts(pullRequest.codeCommentCounts)
|
|
2570
3290
|
].join("\n");
|
|
2571
3291
|
}
|
|
2572
|
-
function
|
|
2573
|
-
|
|
3292
|
+
async function buildPullRequestStatusEntry(context, repo, cred, pullRequest, projectId) {
|
|
3293
|
+
let statusChecks = [];
|
|
3294
|
+
let statusOk = true;
|
|
3295
|
+
try {
|
|
3296
|
+
statusChecks = await getPullRequestChecks(context, repo, cred, pullRequest.id);
|
|
3297
|
+
} catch {
|
|
3298
|
+
statusOk = false;
|
|
3299
|
+
}
|
|
3300
|
+
let policyChecks = [];
|
|
3301
|
+
let policyOk = true;
|
|
3302
|
+
if (projectId === null) {
|
|
3303
|
+
policyOk = false;
|
|
3304
|
+
} else {
|
|
3305
|
+
try {
|
|
3306
|
+
policyChecks = await getPullRequestPolicyEvaluations(context, cred, projectId, pullRequest.id);
|
|
3307
|
+
} catch {
|
|
3308
|
+
policyOk = false;
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
let buildChecks = [];
|
|
3312
|
+
let buildsOk = true;
|
|
3313
|
+
try {
|
|
3314
|
+
buildChecks = await getPullRequestBuilds(context, cred, pullRequest.id);
|
|
3315
|
+
} catch {
|
|
3316
|
+
buildsOk = false;
|
|
3317
|
+
}
|
|
3318
|
+
let codeCommentCounts;
|
|
3319
|
+
try {
|
|
3320
|
+
const threads = await getPullRequestThreads(context, repo, cred, pullRequest.id);
|
|
3321
|
+
codeCommentCounts = countCodeComments(threads);
|
|
3322
|
+
} catch {
|
|
3323
|
+
codeCommentCounts = { open: 0, closed: 0 };
|
|
3324
|
+
}
|
|
3325
|
+
const checks = [...statusChecks, ...policyChecks, ...buildChecks];
|
|
3326
|
+
const checksError = checks.length === 0 && (!statusOk || !policyOk || !buildsOk) ? "Azure DevOps request failed" : null;
|
|
3327
|
+
return {
|
|
3328
|
+
...pullRequest,
|
|
3329
|
+
checks,
|
|
3330
|
+
codeCommentCounts,
|
|
3331
|
+
checksError
|
|
3332
|
+
};
|
|
3333
|
+
}
|
|
3334
|
+
function threadStatusLabel(status2) {
|
|
3335
|
+
return isThreadResolved(status2) ? "resolved" : status2;
|
|
2574
3336
|
}
|
|
2575
3337
|
function formatThreads(prId, title, threads) {
|
|
2576
3338
|
const lines = [`Comment threads for pull request #${prId}: ${title}`];
|
|
2577
3339
|
for (const thread of threads) {
|
|
2578
|
-
|
|
3340
|
+
const lineSuffix = thread.line === null ? "" : `:${thread.line}`;
|
|
3341
|
+
const location = thread.threadContext ? `${thread.threadContext}${lineSuffix}` : "(general)";
|
|
3342
|
+
lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${location}`);
|
|
2579
3343
|
for (const comment of thread.comments) {
|
|
2580
3344
|
lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
|
|
2581
3345
|
}
|
|
@@ -2587,12 +3351,12 @@ async function resolvePrCommandContext(options, resolveOpts = {}) {
|
|
|
2587
3351
|
const context = resolveContext(options);
|
|
2588
3352
|
const repo = detectRepoName();
|
|
2589
3353
|
const branch = requireBranch ? getCurrentBranch() : null;
|
|
2590
|
-
const credential = await
|
|
3354
|
+
const credential = await requireAuthCredential(context.org);
|
|
2591
3355
|
return {
|
|
2592
3356
|
context,
|
|
2593
3357
|
repo,
|
|
2594
3358
|
branch,
|
|
2595
|
-
pat: credential
|
|
3359
|
+
pat: credential
|
|
2596
3360
|
};
|
|
2597
3361
|
}
|
|
2598
3362
|
function createPrStatusCommand() {
|
|
@@ -2605,11 +3369,16 @@ function createPrStatusCommand() {
|
|
|
2605
3369
|
context = resolved.context;
|
|
2606
3370
|
const branch = resolved.branch;
|
|
2607
3371
|
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
|
|
3372
|
+
let projectId = null;
|
|
3373
|
+
try {
|
|
3374
|
+
projectId = await resolveProjectId(resolved.context, resolved.pat);
|
|
3375
|
+
} catch {
|
|
3376
|
+
projectId = null;
|
|
3377
|
+
}
|
|
2608
3378
|
const pullRequestsWithChecks = await Promise.all(
|
|
2609
|
-
pullRequests.map(
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
}))
|
|
3379
|
+
pullRequests.map(
|
|
3380
|
+
async (pullRequest) => buildPullRequestStatusEntry(resolved.context, resolved.repo, resolved.pat, pullRequest, projectId)
|
|
3381
|
+
)
|
|
2613
3382
|
);
|
|
2614
3383
|
const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
|
|
2615
3384
|
if (options.json) {
|
|
@@ -2690,7 +3459,7 @@ ${result.pullRequest.url ?? "\u2014"}
|
|
|
2690
3459
|
}
|
|
2691
3460
|
function createPrCommentsCommand() {
|
|
2692
3461
|
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>", "
|
|
3462
|
+
configureUnwrappedHelp(command).description("List pull request comment threads for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--exclude-resolved", "alias of --hide-resolved: exclude resolved / won't fix / closed / by design threads").option("--code-related-only", "show only threads anchored to a file/line; omit general discussion threads").option("--json", "output JSON").action(async (options) => {
|
|
2694
3463
|
validateOrgProjectPair(options);
|
|
2695
3464
|
let context;
|
|
2696
3465
|
let explicitPrId = null;
|
|
@@ -2722,19 +3491,22 @@ function createPrCommentsCommand() {
|
|
|
2722
3491
|
status: "active"
|
|
2723
3492
|
});
|
|
2724
3493
|
if (pullRequests.length === 0) {
|
|
2725
|
-
|
|
3494
|
+
writeContractError(autoDetectZeroMatch(resolved.branch));
|
|
2726
3495
|
return;
|
|
2727
3496
|
}
|
|
2728
3497
|
if (pullRequests.length > 1) {
|
|
2729
|
-
|
|
2730
|
-
writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
|
|
3498
|
+
writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
|
|
2731
3499
|
return;
|
|
2732
3500
|
}
|
|
2733
3501
|
pullRequest = pullRequests[0];
|
|
2734
3502
|
branchLabel = resolved.branch;
|
|
2735
3503
|
}
|
|
3504
|
+
const hideResolved = options.hideResolved === true || options.excludeResolved === true;
|
|
3505
|
+
const codeRelatedOnly = options.codeRelatedOnly === true;
|
|
2736
3506
|
const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
|
|
2737
|
-
const threads =
|
|
3507
|
+
const threads = allThreads.filter(
|
|
3508
|
+
(thread) => (!hideResolved || !isThreadResolved(thread.status)) && (!codeRelatedOnly || thread.threadContext !== null)
|
|
3509
|
+
);
|
|
2738
3510
|
const result = { branch: branchLabel, pullRequest, threads };
|
|
2739
3511
|
if (options.json) {
|
|
2740
3512
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
@@ -2742,9 +3514,18 @@ function createPrCommentsCommand() {
|
|
|
2742
3514
|
return;
|
|
2743
3515
|
}
|
|
2744
3516
|
if (threads.length === 0) {
|
|
2745
|
-
if (
|
|
2746
|
-
|
|
2747
|
-
|
|
3517
|
+
if (allThreads.length > 0 && (hideResolved || codeRelatedOnly)) {
|
|
3518
|
+
const filters = [];
|
|
3519
|
+
if (codeRelatedOnly) {
|
|
3520
|
+
filters.push("code-related");
|
|
3521
|
+
}
|
|
3522
|
+
if (hideResolved) {
|
|
3523
|
+
filters.push("unresolved");
|
|
3524
|
+
}
|
|
3525
|
+
process.stdout.write(
|
|
3526
|
+
`Pull request #${pullRequest.id} has no ${filters.join(" ")} comment threads (filtered from ${allThreads.length} thread${allThreads.length === 1 ? "" : "s"}).
|
|
3527
|
+
`
|
|
3528
|
+
);
|
|
2748
3529
|
} else {
|
|
2749
3530
|
process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
|
|
2750
3531
|
`);
|
|
@@ -2791,12 +3572,11 @@ async function resolveThreadTarget(threadIdRaw, options) {
|
|
|
2791
3572
|
status: "active"
|
|
2792
3573
|
});
|
|
2793
3574
|
if (pullRequests.length === 0) {
|
|
2794
|
-
|
|
3575
|
+
writeContractError(autoDetectZeroMatch(resolved.branch));
|
|
2795
3576
|
return null;
|
|
2796
3577
|
}
|
|
2797
3578
|
if (pullRequests.length > 1) {
|
|
2798
|
-
|
|
2799
|
-
writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
|
|
3579
|
+
writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
|
|
2800
3580
|
return null;
|
|
2801
3581
|
}
|
|
2802
3582
|
pullRequest = pullRequests[0];
|
|
@@ -2864,14 +3644,14 @@ async function runThreadStateChange(threadIdRaw, options, direction) {
|
|
|
2864
3644
|
}
|
|
2865
3645
|
function createPrCommentResolveCommand() {
|
|
2866
3646
|
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>",
|
|
3647
|
+
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
3648
|
await runThreadStateChange(threadIdRaw, options, "resolve");
|
|
2869
3649
|
});
|
|
2870
3650
|
return command;
|
|
2871
3651
|
}
|
|
2872
3652
|
function createPrCommentReopenCommand() {
|
|
2873
3653
|
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>",
|
|
3654
|
+
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
3655
|
await runThreadStateChange(threadIdRaw, options, "reopen");
|
|
2876
3656
|
});
|
|
2877
3657
|
return command;
|
|
@@ -2887,38 +3667,950 @@ function createPrCommand() {
|
|
|
2887
3667
|
return command;
|
|
2888
3668
|
}
|
|
2889
3669
|
|
|
2890
|
-
// src/commands/
|
|
3670
|
+
// src/commands/pipeline.ts
|
|
2891
3671
|
import { Command as Command13 } from "commander";
|
|
3672
|
+
|
|
3673
|
+
// src/services/pipeline-client.ts
|
|
3674
|
+
var API_VERSION = "7.1";
|
|
3675
|
+
function orgProjectBase(context) {
|
|
3676
|
+
return `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}`;
|
|
3677
|
+
}
|
|
3678
|
+
function withApiVersion(url) {
|
|
3679
|
+
url.searchParams.set("api-version", API_VERSION);
|
|
3680
|
+
return url;
|
|
3681
|
+
}
|
|
3682
|
+
async function readJsonResponse2(response) {
|
|
3683
|
+
if (!response.ok) {
|
|
3684
|
+
throw new Error(`HTTP_${response.status}`);
|
|
3685
|
+
}
|
|
3686
|
+
return await response.json();
|
|
3687
|
+
}
|
|
3688
|
+
function mapPipeline(pipeline) {
|
|
3689
|
+
return {
|
|
3690
|
+
id: pipeline.id,
|
|
3691
|
+
name: pipeline.name,
|
|
3692
|
+
folder: pipeline.folder?.trim() ? pipeline.folder : null
|
|
3693
|
+
};
|
|
3694
|
+
}
|
|
3695
|
+
function mapRunState(state) {
|
|
3696
|
+
if (state === "inProgress" || state === "completed") {
|
|
3697
|
+
return state;
|
|
3698
|
+
}
|
|
3699
|
+
return "unknown";
|
|
3700
|
+
}
|
|
3701
|
+
function mapRunResult(result) {
|
|
3702
|
+
if (result === "succeeded" || result === "failed" || result === "canceled") {
|
|
3703
|
+
return result;
|
|
3704
|
+
}
|
|
3705
|
+
if (result === "partiallySucceeded") {
|
|
3706
|
+
return "failed";
|
|
3707
|
+
}
|
|
3708
|
+
return null;
|
|
3709
|
+
}
|
|
3710
|
+
function mapBuildState(status2) {
|
|
3711
|
+
if (status2 === "completed") return "completed";
|
|
3712
|
+
if (status2 === void 0 || status2 === "none") return "unknown";
|
|
3713
|
+
return "inProgress";
|
|
3714
|
+
}
|
|
3715
|
+
function mapBuildSummary(build) {
|
|
3716
|
+
return {
|
|
3717
|
+
id: build.id,
|
|
3718
|
+
name: build.buildNumber ?? null,
|
|
3719
|
+
state: mapBuildState(build.status),
|
|
3720
|
+
result: mapRunResult(build.result),
|
|
3721
|
+
createdDate: build.queueTime ?? build.startTime ?? null,
|
|
3722
|
+
finishedDate: build.finishTime ?? null,
|
|
3723
|
+
sourceBranch: build.sourceBranch ?? null,
|
|
3724
|
+
sourceCommit: build.sourceVersion ?? null
|
|
3725
|
+
};
|
|
3726
|
+
}
|
|
3727
|
+
function normalizeRef(branch) {
|
|
3728
|
+
return branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
|
|
3729
|
+
}
|
|
3730
|
+
async function getPipelineDefinitions(context, cred) {
|
|
3731
|
+
const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/pipelines`));
|
|
3732
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
3733
|
+
const data = await readJsonResponse2(response);
|
|
3734
|
+
return data.value.map(mapPipeline);
|
|
3735
|
+
}
|
|
3736
|
+
var COMMIT_LOOKBACK = 200;
|
|
3737
|
+
async function getPipelineRuns(context, cred, query) {
|
|
3738
|
+
const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds`));
|
|
3739
|
+
if (query.definitionId !== void 0) {
|
|
3740
|
+
url.searchParams.set("definitions", String(query.definitionId));
|
|
3741
|
+
}
|
|
3742
|
+
if (query.prNumber !== void 0) {
|
|
3743
|
+
url.searchParams.set("branchName", `refs/pull/${query.prNumber}/merge`);
|
|
3744
|
+
} else if (query.branch) {
|
|
3745
|
+
url.searchParams.set("branchName", normalizeRef(query.branch));
|
|
3746
|
+
}
|
|
3747
|
+
url.searchParams.set("queryOrder", "queueTimeDescending");
|
|
3748
|
+
url.searchParams.set("$top", String(query.commit ? COMMIT_LOOKBACK : query.top));
|
|
3749
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
3750
|
+
const data = await readJsonResponse2(response);
|
|
3751
|
+
let runs = data.value.map(mapBuildSummary);
|
|
3752
|
+
if (query.commit) {
|
|
3753
|
+
const needle = query.commit.toLowerCase();
|
|
3754
|
+
runs = runs.filter((run) => run.sourceCommit?.toLowerCase().startsWith(needle));
|
|
3755
|
+
}
|
|
3756
|
+
return runs.slice(0, query.top);
|
|
3757
|
+
}
|
|
3758
|
+
async function runPipeline(context, cred, pipelineId, opts) {
|
|
3759
|
+
const url = withApiVersion(
|
|
3760
|
+
new URL(`${orgProjectBase(context)}/_apis/pipelines/${pipelineId}/runs`)
|
|
3761
|
+
);
|
|
3762
|
+
const body = {};
|
|
3763
|
+
if (opts.branch) {
|
|
3764
|
+
const refName = opts.branch.startsWith("refs/") ? opts.branch : `refs/heads/${opts.branch}`;
|
|
3765
|
+
body.resources = { repositories: { self: { refName } } };
|
|
3766
|
+
}
|
|
3767
|
+
if (opts.parameters && Object.keys(opts.parameters).length > 0) {
|
|
3768
|
+
body.templateParameters = opts.parameters;
|
|
3769
|
+
}
|
|
3770
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
3771
|
+
method: "POST",
|
|
3772
|
+
headers: { ...authHeaders(cred), "Content-Type": "application/json" },
|
|
3773
|
+
body: JSON.stringify(body)
|
|
3774
|
+
});
|
|
3775
|
+
const data = await readJsonResponse2(response);
|
|
3776
|
+
return {
|
|
3777
|
+
id: data.id,
|
|
3778
|
+
state: mapRunState(data.state),
|
|
3779
|
+
webUrl: data._links?.web?.href ?? null
|
|
3780
|
+
};
|
|
3781
|
+
}
|
|
3782
|
+
function buildUrl(context, buildId) {
|
|
3783
|
+
return withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}`));
|
|
3784
|
+
}
|
|
3785
|
+
async function getBuild(context, cred, buildId) {
|
|
3786
|
+
const response = await fetchWithErrors(buildUrl(context, buildId).toString(), {
|
|
3787
|
+
headers: authHeaders(cred)
|
|
3788
|
+
});
|
|
3789
|
+
return readJsonResponse2(response);
|
|
3790
|
+
}
|
|
3791
|
+
async function getBuildStatus(context, cred, buildId) {
|
|
3792
|
+
const build = await getBuild(context, cred, buildId);
|
|
3793
|
+
return { state: mapBuildState(build.status), result: mapRunResult(build.result) };
|
|
3794
|
+
}
|
|
3795
|
+
async function getBuildTimeline(context, cred, buildId) {
|
|
3796
|
+
const url = withApiVersion(
|
|
3797
|
+
new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/timeline`)
|
|
3798
|
+
);
|
|
3799
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
3800
|
+
const data = await readJsonResponse2(response);
|
|
3801
|
+
const records = data.records ?? [];
|
|
3802
|
+
const errors = [];
|
|
3803
|
+
const stages = [];
|
|
3804
|
+
const jobs = [];
|
|
3805
|
+
const logSteps = /* @__PURE__ */ new Map();
|
|
3806
|
+
for (const record of records) {
|
|
3807
|
+
for (const issue of record.issues ?? []) {
|
|
3808
|
+
if (issue.type === "error" && issue.message) {
|
|
3809
|
+
errors.push({ message: issue.message, source: record.name ?? null });
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
if (record.name && record.log?.id !== void 0) {
|
|
3813
|
+
logSteps.set(record.log.id, record.name);
|
|
3814
|
+
}
|
|
3815
|
+
if (!record.name) continue;
|
|
3816
|
+
const status2 = {
|
|
3817
|
+
name: record.name,
|
|
3818
|
+
state: record.state ?? "unknown",
|
|
3819
|
+
result: record.result ?? null
|
|
3820
|
+
};
|
|
3821
|
+
if (record.type === "Stage") {
|
|
3822
|
+
stages.push(status2);
|
|
3823
|
+
} else if (record.type === "Job") {
|
|
3824
|
+
jobs.push({ startTime: record.startTime, status: status2 });
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
jobs.sort((a, b) => {
|
|
3828
|
+
if (a.startTime === b.startTime) return 0;
|
|
3829
|
+
if (a.startTime === void 0) return 1;
|
|
3830
|
+
if (b.startTime === void 0) return -1;
|
|
3831
|
+
return a.startTime < b.startTime ? -1 : 1;
|
|
3832
|
+
});
|
|
3833
|
+
return { errors, stages, jobs: jobs.map((j) => j.status), logSteps };
|
|
3834
|
+
}
|
|
3835
|
+
async function listTestRuns(context, cred, buildId) {
|
|
3836
|
+
const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/test/runs`));
|
|
3837
|
+
url.searchParams.set("buildUri", `vstfs:///Build/Build/${buildId}`);
|
|
3838
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
3839
|
+
const data = await readJsonResponse2(response);
|
|
3840
|
+
return data.value;
|
|
3841
|
+
}
|
|
3842
|
+
async function getTestSummary(context, cred, buildId) {
|
|
3843
|
+
const testRuns = await listTestRuns(context, cred, buildId);
|
|
3844
|
+
let total = 0;
|
|
3845
|
+
let failed = 0;
|
|
3846
|
+
for (const run of testRuns) {
|
|
3847
|
+
const runTotal = run.totalTests ?? 0;
|
|
3848
|
+
total += runTotal;
|
|
3849
|
+
const passedOrSkipped = (run.passedTests ?? 0) + (run.notApplicableTests ?? 0) + (run.incompleteTests ?? 0);
|
|
3850
|
+
failed += Math.max(0, runTotal - passedOrSkipped);
|
|
3851
|
+
}
|
|
3852
|
+
if (total === 0) {
|
|
3853
|
+
return { present: false, total: 0, failed: 0, failedTests: [] };
|
|
3854
|
+
}
|
|
3855
|
+
return { present: true, total, failed, failedTests: [] };
|
|
3856
|
+
}
|
|
3857
|
+
var MAX_FAILED_TESTS = 50;
|
|
3858
|
+
async function getFailedTests(context, cred, buildId) {
|
|
3859
|
+
const testRuns = await listTestRuns(context, cred, buildId);
|
|
3860
|
+
const failed = [];
|
|
3861
|
+
for (const testRun of testRuns) {
|
|
3862
|
+
if (failed.length >= MAX_FAILED_TESTS) break;
|
|
3863
|
+
const resultsUrl = withApiVersion(
|
|
3864
|
+
new URL(`${orgProjectBase(context)}/_apis/test/runs/${testRun.id}/results`)
|
|
3865
|
+
);
|
|
3866
|
+
resultsUrl.searchParams.set("outcomes", "Failed");
|
|
3867
|
+
resultsUrl.searchParams.set("$top", String(MAX_FAILED_TESTS - failed.length));
|
|
3868
|
+
const resultsResponse = await fetchWithErrors(resultsUrl.toString(), {
|
|
3869
|
+
headers: authHeaders(cred)
|
|
3870
|
+
});
|
|
3871
|
+
const resultsData = await readJsonResponse2(resultsResponse);
|
|
3872
|
+
for (const result of resultsData.value) {
|
|
3873
|
+
failed.push({
|
|
3874
|
+
name: result.testCaseTitle ?? result.automatedTestName ?? "(unnamed test)",
|
|
3875
|
+
errorMessage: result.errorMessage ?? null
|
|
3876
|
+
});
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
return failed;
|
|
3880
|
+
}
|
|
3881
|
+
function secondsBetween(start, finish) {
|
|
3882
|
+
if (!start || !finish) return null;
|
|
3883
|
+
const ms = Date.parse(finish) - Date.parse(start);
|
|
3884
|
+
return Number.isFinite(ms) ? Math.round(ms / 1e3) : null;
|
|
3885
|
+
}
|
|
3886
|
+
async function getRunDetail(context, cred, buildId) {
|
|
3887
|
+
const build = await getBuild(context, cred, buildId);
|
|
3888
|
+
let errors = [];
|
|
3889
|
+
let stages = [];
|
|
3890
|
+
let jobs = [];
|
|
3891
|
+
let errorsAvailable = true;
|
|
3892
|
+
try {
|
|
3893
|
+
const timeline = await getBuildTimeline(context, cred, buildId);
|
|
3894
|
+
errors = timeline.errors;
|
|
3895
|
+
stages = timeline.stages;
|
|
3896
|
+
jobs = timeline.jobs;
|
|
3897
|
+
} catch {
|
|
3898
|
+
errorsAvailable = false;
|
|
3899
|
+
}
|
|
3900
|
+
let tests = { present: false, total: 0, failed: 0, failedTests: [] };
|
|
3901
|
+
let testsAvailable = true;
|
|
3902
|
+
try {
|
|
3903
|
+
tests = await getTestSummary(context, cred, buildId);
|
|
3904
|
+
if (tests.failed > 0) {
|
|
3905
|
+
try {
|
|
3906
|
+
tests = { ...tests, failedTests: await getFailedTests(context, cred, buildId) };
|
|
3907
|
+
} catch {
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
} catch {
|
|
3911
|
+
testsAvailable = false;
|
|
3912
|
+
}
|
|
3913
|
+
return {
|
|
3914
|
+
id: build.id,
|
|
3915
|
+
name: build.buildNumber ?? null,
|
|
3916
|
+
state: mapBuildState(build.status),
|
|
3917
|
+
result: mapRunResult(build.result),
|
|
3918
|
+
// createdDate is the queue time, matching the run-list mapping.
|
|
3919
|
+
createdDate: build.queueTime ?? build.startTime ?? null,
|
|
3920
|
+
startedDate: build.startTime ?? null,
|
|
3921
|
+
finishedDate: build.finishTime ?? null,
|
|
3922
|
+
durationSeconds: secondsBetween(build.startTime, build.finishTime),
|
|
3923
|
+
reason: build.reason ?? null,
|
|
3924
|
+
requestedFor: build.requestedFor?.displayName ?? null,
|
|
3925
|
+
sourceBranch: build.sourceBranch ?? null,
|
|
3926
|
+
sourceCommit: build.sourceVersion ?? null,
|
|
3927
|
+
webUrl: build._links?.web?.href ?? null,
|
|
3928
|
+
errors,
|
|
3929
|
+
errorsAvailable,
|
|
3930
|
+
stages,
|
|
3931
|
+
jobs,
|
|
3932
|
+
tests,
|
|
3933
|
+
testsAvailable
|
|
3934
|
+
};
|
|
3935
|
+
}
|
|
3936
|
+
async function getRunLogs(context, cred, buildId) {
|
|
3937
|
+
const url = withApiVersion(
|
|
3938
|
+
new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs`)
|
|
3939
|
+
);
|
|
3940
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
3941
|
+
const data = await readJsonResponse2(response);
|
|
3942
|
+
let logSteps = /* @__PURE__ */ new Map();
|
|
3943
|
+
try {
|
|
3944
|
+
logSteps = (await getBuildTimeline(context, cred, buildId)).logSteps;
|
|
3945
|
+
} catch {
|
|
3946
|
+
}
|
|
3947
|
+
return data.value.map((log) => ({
|
|
3948
|
+
id: log.id,
|
|
3949
|
+
createdOn: log.createdOn ?? null,
|
|
3950
|
+
lineCount: log.lineCount ?? null,
|
|
3951
|
+
step: logSteps.get(log.id) ?? null
|
|
3952
|
+
}));
|
|
3953
|
+
}
|
|
3954
|
+
async function getRunLog(context, cred, buildId, logId) {
|
|
3955
|
+
const url = withApiVersion(
|
|
3956
|
+
new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs/${logId}`)
|
|
3957
|
+
);
|
|
3958
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
3959
|
+
if (!response.ok) {
|
|
3960
|
+
throw new Error(`HTTP_${response.status}`);
|
|
3961
|
+
}
|
|
3962
|
+
return response.text();
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
// src/commands/pipeline.ts
|
|
3966
|
+
var EXIT_FAILED = 1;
|
|
3967
|
+
var EXIT_CANCELED = 2;
|
|
3968
|
+
var EXIT_TIMEOUT = 124;
|
|
2892
3969
|
function writeError2(message) {
|
|
2893
3970
|
process.stderr.write(`Error: ${message}
|
|
2894
3971
|
`);
|
|
2895
|
-
process.
|
|
3972
|
+
process.exitCode = 1;
|
|
2896
3973
|
}
|
|
2897
|
-
function
|
|
2898
|
-
const
|
|
2899
|
-
|
|
2900
|
-
|
|
3974
|
+
function handlePipelineError(err, context) {
|
|
3975
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
3976
|
+
if (error.message === "AUTH_FAILED") {
|
|
3977
|
+
writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
|
|
3978
|
+
return;
|
|
3979
|
+
}
|
|
3980
|
+
if (error.message === "PERMISSION_DENIED") {
|
|
3981
|
+
writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
|
|
3982
|
+
return;
|
|
3983
|
+
}
|
|
3984
|
+
if (error.message === "NETWORK_ERROR") {
|
|
3985
|
+
writeError2("Could not connect to Azure DevOps. Check your network connection.");
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
if (error.message.startsWith("NOT_FOUND")) {
|
|
3989
|
+
writeError2(`Resource not found in ${context?.org}/${context?.project}.`);
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
if (error.message.startsWith("HTTP_")) {
|
|
3993
|
+
writeError2(`Azure DevOps request failed with ${error.message}.`);
|
|
3994
|
+
return;
|
|
3995
|
+
}
|
|
3996
|
+
writeError2(error.message);
|
|
2901
3997
|
}
|
|
2902
|
-
function
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
3998
|
+
function parsePositiveId(raw) {
|
|
3999
|
+
if (!/^\d+$/.test(raw)) return null;
|
|
4000
|
+
const n = Number.parseInt(raw, 10);
|
|
4001
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
4002
|
+
}
|
|
4003
|
+
function parseOptionalCount(value, flag) {
|
|
4004
|
+
if (value === void 0) return void 0;
|
|
4005
|
+
const parsed = parsePositiveId(value);
|
|
4006
|
+
if (parsed === null) {
|
|
4007
|
+
writeError2(`Invalid ${flag} "${value}"; expected a positive integer.`);
|
|
4008
|
+
return null;
|
|
2907
4009
|
}
|
|
2908
|
-
return
|
|
4010
|
+
return parsed;
|
|
2909
4011
|
}
|
|
2910
|
-
function
|
|
4012
|
+
function formatBranchName2(refName) {
|
|
4013
|
+
if (!refName) return "\u2014";
|
|
4014
|
+
return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
|
|
4015
|
+
}
|
|
4016
|
+
async function resolvePipelineContext(options) {
|
|
4017
|
+
const context = resolveContext(options);
|
|
4018
|
+
const cred = await requireAuthCredential(context.org);
|
|
4019
|
+
return { context, cred };
|
|
4020
|
+
}
|
|
4021
|
+
function sleep(ms) {
|
|
4022
|
+
return new Promise((resolve2) => {
|
|
4023
|
+
setTimeout(resolve2, ms);
|
|
4024
|
+
});
|
|
4025
|
+
}
|
|
4026
|
+
function formatTable(rows, rightAlign = /* @__PURE__ */ new Set()) {
|
|
4027
|
+
const widths = [];
|
|
4028
|
+
for (const row of rows) {
|
|
4029
|
+
row.forEach((cell, i) => {
|
|
4030
|
+
widths[i] = Math.max(widths[i] ?? 0, cell.length);
|
|
4031
|
+
});
|
|
4032
|
+
}
|
|
4033
|
+
return rows.map(
|
|
4034
|
+
(row) => row.map((cell, i) => rightAlign.has(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i])).join(" ").trimEnd()
|
|
4035
|
+
).join("\n");
|
|
4036
|
+
}
|
|
4037
|
+
function createPipelineListCommand() {
|
|
2911
4038
|
const command = new Command13("list");
|
|
2912
|
-
command.description("List
|
|
4039
|
+
command.description("List Azure DevOps pipeline definitions").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--filter <name>", "filter definitions by name (case-insensitive substring)").option("--json", "output JSON").action(async (options) => {
|
|
2913
4040
|
validateOrgProjectPair(options);
|
|
2914
|
-
const id = parseWorkItemId(idStr);
|
|
2915
4041
|
let context;
|
|
2916
4042
|
try {
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
4043
|
+
const resolved = await resolvePipelineContext(options);
|
|
4044
|
+
context = resolved.context;
|
|
4045
|
+
let definitions = await getPipelineDefinitions(resolved.context, resolved.cred);
|
|
4046
|
+
if (options.filter) {
|
|
4047
|
+
const needle = options.filter.toLowerCase();
|
|
4048
|
+
definitions = definitions.filter((d) => d.name.toLowerCase().includes(needle));
|
|
4049
|
+
}
|
|
2920
4050
|
if (options.json) {
|
|
2921
|
-
process.stdout.write(`${JSON.stringify(
|
|
4051
|
+
process.stdout.write(`${JSON.stringify(definitions, null, 2)}
|
|
4052
|
+
`);
|
|
4053
|
+
return;
|
|
4054
|
+
}
|
|
4055
|
+
if (definitions.length === 0) {
|
|
4056
|
+
process.stdout.write("No pipelines found.\n");
|
|
4057
|
+
return;
|
|
4058
|
+
}
|
|
4059
|
+
const hasFolder = definitions.some((d) => d.folder);
|
|
4060
|
+
const rows = definitions.map(
|
|
4061
|
+
(d) => hasFolder ? [String(d.id), d.name, d.folder ?? ""] : [String(d.id), d.name]
|
|
4062
|
+
);
|
|
4063
|
+
process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
|
|
4064
|
+
`);
|
|
4065
|
+
} catch (err) {
|
|
4066
|
+
handlePipelineError(err, context);
|
|
4067
|
+
}
|
|
4068
|
+
});
|
|
4069
|
+
return command;
|
|
4070
|
+
}
|
|
4071
|
+
function runRow(run) {
|
|
4072
|
+
const status2 = run.result ? `${run.state}/${run.result}` : run.state;
|
|
4073
|
+
return [
|
|
4074
|
+
String(run.id),
|
|
4075
|
+
`[${status2}]`,
|
|
4076
|
+
run.createdDate ?? "\u2014",
|
|
4077
|
+
formatBranchName2(run.sourceBranch),
|
|
4078
|
+
run.sourceCommit ? run.sourceCommit.slice(0, 8) : "\u2014"
|
|
4079
|
+
];
|
|
4080
|
+
}
|
|
4081
|
+
var COMMIT_SHA_PATTERN = /^[0-9a-f]{6,40}$/i;
|
|
4082
|
+
function parseGetRunsInputs(defIdRaw, options) {
|
|
4083
|
+
let defId;
|
|
4084
|
+
if (defIdRaw !== void 0) {
|
|
4085
|
+
const parsed = parsePositiveId(defIdRaw);
|
|
4086
|
+
if (parsed === null) {
|
|
4087
|
+
writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
|
|
4088
|
+
return null;
|
|
4089
|
+
}
|
|
4090
|
+
defId = parsed;
|
|
4091
|
+
} else if (options.commit === void 0 && options.pr === void 0) {
|
|
4092
|
+
writeError2("Definition id is required unless --commit or --pr is given.");
|
|
4093
|
+
return null;
|
|
4094
|
+
}
|
|
4095
|
+
const limit = parseOptionalCount(options.limit, "--limit");
|
|
4096
|
+
if (limit === null) return null;
|
|
4097
|
+
const prNumber = parseOptionalCount(options.pr, "--pr");
|
|
4098
|
+
if (prNumber === null) return null;
|
|
4099
|
+
if (options.commit !== void 0 && !COMMIT_SHA_PATTERN.test(options.commit)) {
|
|
4100
|
+
writeError2(`Invalid --commit "${options.commit}"; expected 6-40 hex characters.`);
|
|
4101
|
+
return null;
|
|
4102
|
+
}
|
|
4103
|
+
if (options.branch !== void 0 && prNumber !== void 0) {
|
|
4104
|
+
writeError2("Use either --branch or --pr, not both.");
|
|
4105
|
+
return null;
|
|
4106
|
+
}
|
|
4107
|
+
return { defId, limit: limit ?? 10, prNumber };
|
|
4108
|
+
}
|
|
4109
|
+
function createPipelineGetRunsCommand() {
|
|
4110
|
+
const command = new Command13("get-runs");
|
|
4111
|
+
command.description("List recent runs for a pipeline definition (newest first)").argument("[def_id]", "pipeline definition id (optional with --commit or --pr)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--limit <n>", "maximum number of runs to show (default 10)").option("--branch <branch>", "only show runs for this source branch").option("--commit <sha>", "only show runs that built this commit (full or abbreviated SHA)").option("--pr <number>", "only show runs for this pull request").option("--json", "output JSON").action(async (defIdRaw, options) => {
|
|
4112
|
+
validateOrgProjectPair(options);
|
|
4113
|
+
const inputs = parseGetRunsInputs(defIdRaw, options);
|
|
4114
|
+
if (inputs === null) {
|
|
4115
|
+
return;
|
|
4116
|
+
}
|
|
4117
|
+
let context;
|
|
4118
|
+
try {
|
|
4119
|
+
const resolved = await resolvePipelineContext(options);
|
|
4120
|
+
context = resolved.context;
|
|
4121
|
+
const runs = await getPipelineRuns(resolved.context, resolved.cred, {
|
|
4122
|
+
definitionId: inputs.defId,
|
|
4123
|
+
branch: options.branch,
|
|
4124
|
+
prNumber: inputs.prNumber,
|
|
4125
|
+
commit: options.commit,
|
|
4126
|
+
top: inputs.limit
|
|
4127
|
+
});
|
|
4128
|
+
if (options.json) {
|
|
4129
|
+
process.stdout.write(`${JSON.stringify(runs, null, 2)}
|
|
4130
|
+
`);
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
if (runs.length === 0) {
|
|
4134
|
+
process.stdout.write(
|
|
4135
|
+
inputs.defId === void 0 ? "No runs found matching the filters.\n" : `No runs found for pipeline ${inputs.defId}.
|
|
4136
|
+
`
|
|
4137
|
+
);
|
|
4138
|
+
return;
|
|
4139
|
+
}
|
|
4140
|
+
process.stdout.write(`${formatTable(runs.map(runRow), /* @__PURE__ */ new Set([0]))}
|
|
4141
|
+
`);
|
|
4142
|
+
} catch (err) {
|
|
4143
|
+
handlePipelineError(err, context);
|
|
4144
|
+
}
|
|
4145
|
+
});
|
|
4146
|
+
return command;
|
|
4147
|
+
}
|
|
4148
|
+
function applyWaitExitCode(result) {
|
|
4149
|
+
if (result.timedOut) {
|
|
4150
|
+
process.exitCode = EXIT_TIMEOUT;
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
switch (result.result) {
|
|
4154
|
+
case "succeeded":
|
|
4155
|
+
return;
|
|
4156
|
+
case "canceled":
|
|
4157
|
+
process.exitCode = EXIT_CANCELED;
|
|
4158
|
+
return;
|
|
4159
|
+
case "failed":
|
|
4160
|
+
default:
|
|
4161
|
+
process.exitCode = EXIT_FAILED;
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
function createPipelineWaitCommand() {
|
|
4165
|
+
const command = new Command13("wait");
|
|
4166
|
+
command.description("Wait for a pipeline run to finish; exit code reflects the result (0 success, non-zero otherwise)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--timeout <seconds>", "maximum seconds to wait (default 1800)").option("--poll-interval <seconds>", "seconds between status checks (default 5)").option("--json", "output JSON").action(async (runIdRaw, options) => {
|
|
4167
|
+
validateOrgProjectPair(options);
|
|
4168
|
+
const runId = parsePositiveId(runIdRaw);
|
|
4169
|
+
if (runId === null) {
|
|
4170
|
+
writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
|
|
4171
|
+
return;
|
|
4172
|
+
}
|
|
4173
|
+
const timeoutSec = options.timeout === void 0 ? 1800 : Number(options.timeout);
|
|
4174
|
+
const pollSec = options.pollInterval === void 0 ? 5 : Number(options.pollInterval);
|
|
4175
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
|
|
4176
|
+
writeError2(`Invalid --timeout "${options.timeout}"; expected a non-negative number.`);
|
|
4177
|
+
return;
|
|
4178
|
+
}
|
|
4179
|
+
if (!Number.isFinite(pollSec) || pollSec <= 0) {
|
|
4180
|
+
writeError2(`Invalid --poll-interval "${options.pollInterval}"; expected a positive number.`);
|
|
4181
|
+
return;
|
|
4182
|
+
}
|
|
4183
|
+
let context;
|
|
4184
|
+
try {
|
|
4185
|
+
const resolved = await resolvePipelineContext(options);
|
|
4186
|
+
context = resolved.context;
|
|
4187
|
+
const deadline = Date.now() + timeoutSec * 1e3;
|
|
4188
|
+
let waitResult = null;
|
|
4189
|
+
for (; ; ) {
|
|
4190
|
+
const status2 = await getBuildStatus(resolved.context, resolved.cred, runId);
|
|
4191
|
+
if (status2.state === "completed") {
|
|
4192
|
+
waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: false };
|
|
4193
|
+
break;
|
|
4194
|
+
}
|
|
4195
|
+
if (Date.now() >= deadline) {
|
|
4196
|
+
waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: true };
|
|
4197
|
+
break;
|
|
4198
|
+
}
|
|
4199
|
+
await sleep(pollSec * 1e3);
|
|
4200
|
+
}
|
|
4201
|
+
applyWaitExitCode(waitResult);
|
|
4202
|
+
if (options.json) {
|
|
4203
|
+
process.stdout.write(`${JSON.stringify(waitResult, null, 2)}
|
|
4204
|
+
`);
|
|
4205
|
+
return;
|
|
4206
|
+
}
|
|
4207
|
+
if (waitResult.timedOut) {
|
|
4208
|
+
process.stdout.write(`Run ${runId} did not finish within ${timeoutSec}s (still ${waitResult.state}).
|
|
4209
|
+
`);
|
|
4210
|
+
} else {
|
|
4211
|
+
process.stdout.write(`Run ${runId} finished: ${waitResult.result ?? waitResult.state}.
|
|
4212
|
+
`);
|
|
4213
|
+
}
|
|
4214
|
+
} catch (err) {
|
|
4215
|
+
handlePipelineError(err, context);
|
|
4216
|
+
}
|
|
4217
|
+
});
|
|
4218
|
+
return command;
|
|
4219
|
+
}
|
|
4220
|
+
function timelineRows(items, available) {
|
|
4221
|
+
if (!available) {
|
|
4222
|
+
return [" unavailable"];
|
|
4223
|
+
}
|
|
4224
|
+
if (items.length === 0) {
|
|
4225
|
+
return [" (none)"];
|
|
4226
|
+
}
|
|
4227
|
+
return items.map((item) => ` - ${item.name} [${item.result ?? item.state}]`);
|
|
4228
|
+
}
|
|
4229
|
+
function formatDuration(totalSeconds) {
|
|
4230
|
+
const h = Math.floor(totalSeconds / 3600);
|
|
4231
|
+
const m = Math.floor(totalSeconds % 3600 / 60);
|
|
4232
|
+
const s = totalSeconds % 60;
|
|
4233
|
+
if (h > 0) return `${h}h${m}m${s}s`;
|
|
4234
|
+
if (m > 0) return `${m}m${s}s`;
|
|
4235
|
+
return `${s}s`;
|
|
4236
|
+
}
|
|
4237
|
+
function errorRows(detail) {
|
|
4238
|
+
if (!detail.errorsAvailable) {
|
|
4239
|
+
return [" unavailable"];
|
|
4240
|
+
}
|
|
4241
|
+
if (detail.errors.length === 0) {
|
|
4242
|
+
return [" (none)"];
|
|
4243
|
+
}
|
|
4244
|
+
return detail.errors.map((error) => {
|
|
4245
|
+
const source = error.source ? `[${error.source}] ` : "";
|
|
4246
|
+
return ` - ${source}${error.message}`;
|
|
4247
|
+
});
|
|
4248
|
+
}
|
|
4249
|
+
function failedTestRow(test) {
|
|
4250
|
+
if (!test.errorMessage) {
|
|
4251
|
+
return ` - ${test.name}`;
|
|
4252
|
+
}
|
|
4253
|
+
const firstLine = test.errorMessage.split("\n", 1)[0].trim();
|
|
4254
|
+
return ` - ${test.name}: ${firstLine}`;
|
|
4255
|
+
}
|
|
4256
|
+
function testRows(detail) {
|
|
4257
|
+
if (!detail.testsAvailable) {
|
|
4258
|
+
return [" unavailable"];
|
|
4259
|
+
}
|
|
4260
|
+
if (detail.tests.present) {
|
|
4261
|
+
return [
|
|
4262
|
+
` ${detail.tests.failed} failing of ${detail.tests.total}`,
|
|
4263
|
+
...detail.tests.failedTests.map(failedTestRow)
|
|
4264
|
+
];
|
|
4265
|
+
}
|
|
4266
|
+
return [" no tests present"];
|
|
4267
|
+
}
|
|
4268
|
+
function formatRunDetail(detail) {
|
|
4269
|
+
const status2 = detail.result ? `${detail.state}/${detail.result}` : detail.state;
|
|
4270
|
+
const name = detail.name ? ` ${detail.name}` : "";
|
|
4271
|
+
const duration = detail.durationSeconds == null ? "\u2014" : formatDuration(detail.durationSeconds);
|
|
4272
|
+
return [
|
|
4273
|
+
`Run #${detail.id} [${status2}]${name}`,
|
|
4274
|
+
`Queued: ${detail.createdDate ?? "\u2014"} Started: ${detail.startedDate ?? "\u2014"} Finished: ${detail.finishedDate ?? "\u2014"}`,
|
|
4275
|
+
`Duration: ${duration} Reason: ${detail.reason ?? "\u2014"} Requested for: ${detail.requestedFor ?? "\u2014"}`,
|
|
4276
|
+
`Branch: ${formatBranchName2(detail.sourceBranch)} Commit: ${detail.sourceCommit ?? "unavailable"}`,
|
|
4277
|
+
...detail.webUrl ? [`Link: ${detail.webUrl}`] : [],
|
|
4278
|
+
"",
|
|
4279
|
+
"Stages:",
|
|
4280
|
+
...timelineRows(detail.stages, detail.errorsAvailable),
|
|
4281
|
+
"",
|
|
4282
|
+
"Jobs:",
|
|
4283
|
+
...timelineRows(detail.jobs, detail.errorsAvailable),
|
|
4284
|
+
"",
|
|
4285
|
+
"Errors:",
|
|
4286
|
+
...errorRows(detail),
|
|
4287
|
+
"",
|
|
4288
|
+
"Tests:",
|
|
4289
|
+
...testRows(detail)
|
|
4290
|
+
].join("\n");
|
|
4291
|
+
}
|
|
4292
|
+
function createPipelineGetRunDetailCommand() {
|
|
4293
|
+
const command = new Command13("get-run-detail");
|
|
4294
|
+
command.description("Show a detailed summary of a single pipeline run (errors, failing tests, stages)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (runIdRaw, options) => {
|
|
4295
|
+
validateOrgProjectPair(options);
|
|
4296
|
+
const runId = parsePositiveId(runIdRaw);
|
|
4297
|
+
if (runId === null) {
|
|
4298
|
+
writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
|
|
4299
|
+
return;
|
|
4300
|
+
}
|
|
4301
|
+
let context;
|
|
4302
|
+
try {
|
|
4303
|
+
const resolved = await resolvePipelineContext(options);
|
|
4304
|
+
context = resolved.context;
|
|
4305
|
+
const detail = await getRunDetail(resolved.context, resolved.cred, runId);
|
|
4306
|
+
if (options.json) {
|
|
4307
|
+
process.stdout.write(`${JSON.stringify(detail, null, 2)}
|
|
4308
|
+
`);
|
|
4309
|
+
return;
|
|
4310
|
+
}
|
|
4311
|
+
process.stdout.write(`${formatRunDetail(detail)}
|
|
4312
|
+
`);
|
|
4313
|
+
} catch (err) {
|
|
4314
|
+
handlePipelineError(err, context);
|
|
4315
|
+
}
|
|
4316
|
+
});
|
|
4317
|
+
return command;
|
|
4318
|
+
}
|
|
4319
|
+
function grepWithContext(lines, grep, context) {
|
|
4320
|
+
const include = /* @__PURE__ */ new Set();
|
|
4321
|
+
lines.forEach((line, i) => {
|
|
4322
|
+
if (grep.test(line)) {
|
|
4323
|
+
for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) {
|
|
4324
|
+
include.add(j);
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
});
|
|
4328
|
+
const selected = [];
|
|
4329
|
+
let prev = -1;
|
|
4330
|
+
for (const i of [...include].sort((a, b) => a - b)) {
|
|
4331
|
+
if (selected.length > 0 && i > prev + 1) {
|
|
4332
|
+
selected.push("--");
|
|
4333
|
+
}
|
|
4334
|
+
selected.push(lines[i]);
|
|
4335
|
+
prev = i;
|
|
4336
|
+
}
|
|
4337
|
+
return selected;
|
|
4338
|
+
}
|
|
4339
|
+
function filterLogLines(content, grep, tail, context) {
|
|
4340
|
+
let lines = content.split("\n");
|
|
4341
|
+
if (lines.at(-1) === "") {
|
|
4342
|
+
lines.pop();
|
|
4343
|
+
}
|
|
4344
|
+
if (grep) {
|
|
4345
|
+
lines = context > 0 ? grepWithContext(lines, grep, context) : lines.filter((line) => grep.test(line));
|
|
4346
|
+
}
|
|
4347
|
+
if (tail !== void 0 && lines.length > tail) {
|
|
4348
|
+
lines = lines.slice(-tail);
|
|
4349
|
+
}
|
|
4350
|
+
return lines;
|
|
4351
|
+
}
|
|
4352
|
+
function parseLogFilters(options) {
|
|
4353
|
+
if (options.logId !== void 0 && options.step !== void 0) {
|
|
4354
|
+
writeError2("Use either --log-id or --step, not both.");
|
|
4355
|
+
return null;
|
|
4356
|
+
}
|
|
4357
|
+
const selectsSingleLog = options.logId !== void 0 || options.step !== void 0;
|
|
4358
|
+
const slices = options.tail !== void 0 || options.grep !== void 0 || options.context !== void 0;
|
|
4359
|
+
if (slices && !selectsSingleLog) {
|
|
4360
|
+
writeError2("--tail, --grep, and --context require --log-id or --step.");
|
|
4361
|
+
return null;
|
|
4362
|
+
}
|
|
4363
|
+
if (options.context !== void 0 && options.grep === void 0) {
|
|
4364
|
+
writeError2("--context requires --grep.");
|
|
4365
|
+
return null;
|
|
4366
|
+
}
|
|
4367
|
+
const tail = parseOptionalCount(options.tail, "--tail");
|
|
4368
|
+
if (tail === null) return null;
|
|
4369
|
+
const contextLines = parseOptionalCount(options.context, "--context");
|
|
4370
|
+
if (contextLines === null) return null;
|
|
4371
|
+
let grep;
|
|
4372
|
+
if (options.grep !== void 0) {
|
|
4373
|
+
try {
|
|
4374
|
+
grep = new RegExp(options.grep);
|
|
4375
|
+
} catch {
|
|
4376
|
+
writeError2(`Invalid --grep "${options.grep}"; expected a valid regular expression.`);
|
|
4377
|
+
return null;
|
|
4378
|
+
}
|
|
4379
|
+
}
|
|
4380
|
+
return { tail, contextLines: contextLines ?? 0, grep };
|
|
4381
|
+
}
|
|
4382
|
+
function chooseStepLog(logs, step, runId) {
|
|
4383
|
+
const needle = step.toLowerCase();
|
|
4384
|
+
const matches = logs.filter((l) => l.step?.toLowerCase().includes(needle));
|
|
4385
|
+
const exact = matches.filter((l) => l.step?.toLowerCase() === needle);
|
|
4386
|
+
const chosen = exact.length === 1 ? exact : matches;
|
|
4387
|
+
if (chosen.length === 0) {
|
|
4388
|
+
writeError2(`No log matches step "${step}" in run ${runId}.`);
|
|
4389
|
+
return null;
|
|
4390
|
+
}
|
|
4391
|
+
if (chosen.length > 1) {
|
|
4392
|
+
const candidates = chosen.map((l) => `${l.id} (${l.step})`).join(", ");
|
|
4393
|
+
writeError2(`Step "${step}" matches multiple logs: ${candidates}. Be more specific or use --log-id.`);
|
|
4394
|
+
return null;
|
|
4395
|
+
}
|
|
4396
|
+
return chosen[0].id;
|
|
4397
|
+
}
|
|
4398
|
+
async function resolveRequestedLogId(resolved, runId, options) {
|
|
4399
|
+
if (options.logId !== void 0) {
|
|
4400
|
+
const parsed = parsePositiveId(options.logId);
|
|
4401
|
+
if (parsed === null) {
|
|
4402
|
+
writeError2(`Invalid --log-id "${options.logId}"; expected a positive integer.`);
|
|
4403
|
+
return null;
|
|
4404
|
+
}
|
|
4405
|
+
return parsed;
|
|
4406
|
+
}
|
|
4407
|
+
if (options.step === void 0) {
|
|
4408
|
+
return void 0;
|
|
4409
|
+
}
|
|
4410
|
+
const allLogs = await getRunLogs(resolved.context, resolved.cred, runId);
|
|
4411
|
+
return chooseStepLog(allLogs, options.step, runId);
|
|
4412
|
+
}
|
|
4413
|
+
function printSingleLog(content, filters) {
|
|
4414
|
+
if (filters.grep !== void 0 || filters.tail !== void 0) {
|
|
4415
|
+
const lines = filterLogLines(content, filters.grep, filters.tail, filters.contextLines);
|
|
4416
|
+
if (lines.length > 0) {
|
|
4417
|
+
process.stdout.write(`${lines.join("\n")}
|
|
4418
|
+
`);
|
|
4419
|
+
}
|
|
4420
|
+
return;
|
|
4421
|
+
}
|
|
4422
|
+
process.stdout.write(content.endsWith("\n") ? content : `${content}
|
|
4423
|
+
`);
|
|
4424
|
+
}
|
|
4425
|
+
function createPipelineLogsCommand() {
|
|
4426
|
+
const command = new Command13("logs");
|
|
4427
|
+
command.description("List a pipeline run's logs, or print a specific log with --log-id").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--log-id <id>", "print the content of this log id").option("--step <name>", "print the log of the step/job matching this name (case-insensitive substring)").option("--tail <n>", "with --log-id/--step, print only the last N lines").option("--grep <pattern>", "with --log-id/--step, print only lines matching this regular expression").option("--context <n>", "with --grep, also print N lines around each match (grep -C)").option("--json", "output JSON").action(async (runIdRaw, options) => {
|
|
4428
|
+
validateOrgProjectPair(options);
|
|
4429
|
+
const runId = parsePositiveId(runIdRaw);
|
|
4430
|
+
if (runId === null) {
|
|
4431
|
+
writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
|
|
4432
|
+
return;
|
|
4433
|
+
}
|
|
4434
|
+
const filters = parseLogFilters(options);
|
|
4435
|
+
if (filters === null) {
|
|
4436
|
+
return;
|
|
4437
|
+
}
|
|
4438
|
+
let context;
|
|
4439
|
+
try {
|
|
4440
|
+
const resolved = await resolvePipelineContext(options);
|
|
4441
|
+
context = resolved.context;
|
|
4442
|
+
const logId = await resolveRequestedLogId(resolved, runId, options);
|
|
4443
|
+
if (logId === null) {
|
|
4444
|
+
return;
|
|
4445
|
+
}
|
|
4446
|
+
if (logId !== void 0) {
|
|
4447
|
+
const content = await getRunLog(resolved.context, resolved.cred, runId, logId);
|
|
4448
|
+
printSingleLog(content, filters);
|
|
4449
|
+
return;
|
|
4450
|
+
}
|
|
4451
|
+
const logs = await getRunLogs(resolved.context, resolved.cred, runId);
|
|
4452
|
+
if (options.json) {
|
|
4453
|
+
process.stdout.write(`${JSON.stringify(logs, null, 2)}
|
|
4454
|
+
`);
|
|
4455
|
+
return;
|
|
4456
|
+
}
|
|
4457
|
+
if (logs.length === 0) {
|
|
4458
|
+
process.stdout.write(`No logs found for run ${runId}.
|
|
4459
|
+
`);
|
|
4460
|
+
return;
|
|
4461
|
+
}
|
|
4462
|
+
const rows = logs.map((l) => [
|
|
4463
|
+
String(l.id),
|
|
4464
|
+
l.createdOn ?? "\u2014",
|
|
4465
|
+
l.lineCount == null ? "" : `${l.lineCount} lines`,
|
|
4466
|
+
l.step ?? ""
|
|
4467
|
+
]);
|
|
4468
|
+
process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
|
|
4469
|
+
`);
|
|
4470
|
+
} catch (err) {
|
|
4471
|
+
handlePipelineError(err, context);
|
|
4472
|
+
}
|
|
4473
|
+
});
|
|
4474
|
+
return command;
|
|
4475
|
+
}
|
|
4476
|
+
function createPipelineTestsCommand() {
|
|
4477
|
+
const command = new Command13("tests");
|
|
4478
|
+
command.description("Show a run's test results: summary plus failing tests by name (no log grepping needed)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--failed", "list only the failing tests").option("--json", "output JSON").action(async (runIdRaw, options) => {
|
|
4479
|
+
validateOrgProjectPair(options);
|
|
4480
|
+
const runId = parsePositiveId(runIdRaw);
|
|
4481
|
+
if (runId === null) {
|
|
4482
|
+
writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
|
|
4483
|
+
return;
|
|
4484
|
+
}
|
|
4485
|
+
let context;
|
|
4486
|
+
try {
|
|
4487
|
+
const resolved = await resolvePipelineContext(options);
|
|
4488
|
+
context = resolved.context;
|
|
4489
|
+
const summary = await getTestSummary(resolved.context, resolved.cred, runId);
|
|
4490
|
+
const failedTests = summary.failed > 0 ? await getFailedTests(resolved.context, resolved.cred, runId) : [];
|
|
4491
|
+
if (options.json) {
|
|
4492
|
+
process.stdout.write(`${JSON.stringify({ ...summary, failedTests }, null, 2)}
|
|
4493
|
+
`);
|
|
4494
|
+
return;
|
|
4495
|
+
}
|
|
4496
|
+
if (!summary.present) {
|
|
4497
|
+
process.stdout.write(`No test results published for run ${runId}.
|
|
4498
|
+
`);
|
|
4499
|
+
return;
|
|
4500
|
+
}
|
|
4501
|
+
if (!options.failed) {
|
|
4502
|
+
process.stdout.write(`Run #${runId}: ${summary.failed} failing of ${summary.total} tests
|
|
4503
|
+
`);
|
|
4504
|
+
}
|
|
4505
|
+
if (failedTests.length > 0) {
|
|
4506
|
+
process.stdout.write(`${failedTests.map(failedTestRow).join("\n")}
|
|
4507
|
+
`);
|
|
4508
|
+
} else if (options.failed) {
|
|
4509
|
+
process.stdout.write("No failing tests.\n");
|
|
4510
|
+
}
|
|
4511
|
+
} catch (err) {
|
|
4512
|
+
handlePipelineError(err, context);
|
|
4513
|
+
}
|
|
4514
|
+
});
|
|
4515
|
+
return command;
|
|
4516
|
+
}
|
|
4517
|
+
function parseParameters(values) {
|
|
4518
|
+
const result = {};
|
|
4519
|
+
for (const entry of values ?? []) {
|
|
4520
|
+
const eq = entry.indexOf("=");
|
|
4521
|
+
if (eq <= 0) {
|
|
4522
|
+
return null;
|
|
4523
|
+
}
|
|
4524
|
+
const key = entry.slice(0, eq);
|
|
4525
|
+
const value = entry.slice(eq + 1);
|
|
4526
|
+
result[key] = value;
|
|
4527
|
+
}
|
|
4528
|
+
return result;
|
|
4529
|
+
}
|
|
4530
|
+
function collectParameter(value, previous) {
|
|
4531
|
+
return previous.concat([value]);
|
|
4532
|
+
}
|
|
4533
|
+
function createPipelineStartCommand() {
|
|
4534
|
+
const command = new Command13("start");
|
|
4535
|
+
command.description("Queue a new run of a pipeline definition").argument("<def_id>", "pipeline definition id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--branch <branch>", "branch to run against (default: pipeline default branch)").option("--parameter <key=value>", "template parameter (repeatable)", collectParameter, []).option("--json", "output JSON").action(async (defIdRaw, options) => {
|
|
4536
|
+
validateOrgProjectPair(options);
|
|
4537
|
+
const defId = parsePositiveId(defIdRaw);
|
|
4538
|
+
if (defId === null) {
|
|
4539
|
+
writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
|
|
4540
|
+
return;
|
|
4541
|
+
}
|
|
4542
|
+
const parameters = parseParameters(options.parameter);
|
|
4543
|
+
if (parameters === null) {
|
|
4544
|
+
writeError2("Invalid --parameter; expected key=value.");
|
|
4545
|
+
return;
|
|
4546
|
+
}
|
|
4547
|
+
let context;
|
|
4548
|
+
try {
|
|
4549
|
+
const resolved = await resolvePipelineContext(options);
|
|
4550
|
+
context = resolved.context;
|
|
4551
|
+
const result = await runPipeline(resolved.context, resolved.cred, defId, {
|
|
4552
|
+
branch: options.branch,
|
|
4553
|
+
parameters
|
|
4554
|
+
});
|
|
4555
|
+
if (options.json) {
|
|
4556
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
4557
|
+
`);
|
|
4558
|
+
return;
|
|
4559
|
+
}
|
|
4560
|
+
process.stdout.write(`Queued run #${result.id} [${result.state}]
|
|
4561
|
+
${result.webUrl ?? "\u2014"}
|
|
4562
|
+
`);
|
|
4563
|
+
} catch (err) {
|
|
4564
|
+
handlePipelineError(err, context);
|
|
4565
|
+
}
|
|
4566
|
+
});
|
|
4567
|
+
return command;
|
|
4568
|
+
}
|
|
4569
|
+
function createPipelineCommand() {
|
|
4570
|
+
const command = new Command13("pipeline");
|
|
4571
|
+
command.description("Manage Azure DevOps pipelines");
|
|
4572
|
+
command.addCommand(createPipelineListCommand());
|
|
4573
|
+
command.addCommand(createPipelineGetRunsCommand());
|
|
4574
|
+
command.addCommand(createPipelineWaitCommand());
|
|
4575
|
+
command.addCommand(createPipelineGetRunDetailCommand());
|
|
4576
|
+
command.addCommand(createPipelineLogsCommand());
|
|
4577
|
+
command.addCommand(createPipelineTestsCommand());
|
|
4578
|
+
command.addCommand(createPipelineStartCommand());
|
|
4579
|
+
return command;
|
|
4580
|
+
}
|
|
4581
|
+
|
|
4582
|
+
// src/commands/comments.ts
|
|
4583
|
+
import { Command as Command14 } from "commander";
|
|
4584
|
+
function writeError3(message) {
|
|
4585
|
+
process.stderr.write(`Error: ${message}
|
|
4586
|
+
`);
|
|
4587
|
+
process.exit(1);
|
|
4588
|
+
}
|
|
4589
|
+
function formatCommentHeader(comment) {
|
|
4590
|
+
const author = comment.author ?? "Unknown";
|
|
4591
|
+
const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
|
|
4592
|
+
return `Comment #${comment.id} by ${author} at ${timestamp}`;
|
|
4593
|
+
}
|
|
4594
|
+
function formatComments(result, convertMarkdown) {
|
|
4595
|
+
const lines = [`Comments for work item #${result.workItemId}`];
|
|
4596
|
+
for (const comment of result.comments) {
|
|
4597
|
+
const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
|
|
4598
|
+
lines.push("", formatCommentHeader(comment), text);
|
|
4599
|
+
}
|
|
4600
|
+
return lines.join("\n");
|
|
4601
|
+
}
|
|
4602
|
+
function createCommentsListCommand() {
|
|
4603
|
+
const command = new Command14("list");
|
|
4604
|
+
command.description("List visible comments for a work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "convert HTML comment bodies to markdown").action(async (idStr, options) => {
|
|
4605
|
+
validateOrgProjectPair(options);
|
|
4606
|
+
const id = parseWorkItemId(idStr);
|
|
4607
|
+
let context;
|
|
4608
|
+
try {
|
|
4609
|
+
context = resolveContext(options);
|
|
4610
|
+
const credential = await requireAuthCredential(context.org);
|
|
4611
|
+
const result = await listWorkItemComments(context, id, credential);
|
|
4612
|
+
if (options.json) {
|
|
4613
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2922
4614
|
`);
|
|
2923
4615
|
return;
|
|
2924
4616
|
}
|
|
@@ -2936,19 +4628,19 @@ function createCommentsListCommand() {
|
|
|
2936
4628
|
return command;
|
|
2937
4629
|
}
|
|
2938
4630
|
function createCommentsAddCommand() {
|
|
2939
|
-
const command = new
|
|
4631
|
+
const command = new Command14("add");
|
|
2940
4632
|
command.description("Add a comment to a work item").argument("<id>", "work item ID").argument("<text>", "comment text").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "post comment as markdown").action(async (idStr, text, options) => {
|
|
2941
4633
|
validateOrgProjectPair(options);
|
|
2942
4634
|
const id = parseWorkItemId(idStr);
|
|
2943
4635
|
if (text.trim() === "") {
|
|
2944
|
-
|
|
4636
|
+
writeError3("Comment text must be a non-empty string.");
|
|
2945
4637
|
}
|
|
2946
4638
|
let context;
|
|
2947
4639
|
try {
|
|
2948
4640
|
context = resolveContext(options);
|
|
2949
|
-
const credential = await
|
|
4641
|
+
const credential = await requireAuthCredential(context.org);
|
|
2950
4642
|
const format = options.markdown === true ? "markdown" : "html";
|
|
2951
|
-
const result = await addWorkItemComment(context, id, credential
|
|
4643
|
+
const result = await addWorkItemComment(context, id, credential, text, format);
|
|
2952
4644
|
if (options.json) {
|
|
2953
4645
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2954
4646
|
`);
|
|
@@ -2963,7 +4655,7 @@ function createCommentsAddCommand() {
|
|
|
2963
4655
|
return command;
|
|
2964
4656
|
}
|
|
2965
4657
|
function createCommentsCommand() {
|
|
2966
|
-
const command = new
|
|
4658
|
+
const command = new Command14("comments");
|
|
2967
4659
|
command.description("Manage Azure DevOps work item comments");
|
|
2968
4660
|
command.addCommand(createCommentsListCommand());
|
|
2969
4661
|
command.addCommand(createCommentsAddCommand());
|
|
@@ -2971,12 +4663,12 @@ function createCommentsCommand() {
|
|
|
2971
4663
|
}
|
|
2972
4664
|
|
|
2973
4665
|
// src/commands/download-attachment.ts
|
|
2974
|
-
import { Command as
|
|
2975
|
-
import { writeFile } from "fs/promises";
|
|
2976
|
-
import { existsSync as
|
|
2977
|
-
import { join as
|
|
4666
|
+
import { Command as Command15 } from "commander";
|
|
4667
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
4668
|
+
import { existsSync as existsSync5 } from "fs";
|
|
4669
|
+
import { join as join3 } from "path";
|
|
2978
4670
|
function createDownloadAttachmentCommand() {
|
|
2979
|
-
const command = new
|
|
4671
|
+
const command = new Command15("download-attachment");
|
|
2980
4672
|
command.description("Download an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to download").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--output <dir>", "target directory for the downloaded file").action(
|
|
2981
4673
|
async (idStr, filename, options) => {
|
|
2982
4674
|
const id = parseWorkItemId(idStr);
|
|
@@ -2984,14 +4676,14 @@ function createDownloadAttachmentCommand() {
|
|
|
2984
4676
|
let context;
|
|
2985
4677
|
try {
|
|
2986
4678
|
context = resolveContext(options);
|
|
2987
|
-
const credential = await
|
|
4679
|
+
const credential = await requireAuthCredential(context.org);
|
|
2988
4680
|
const outputDir = options.output ?? ".";
|
|
2989
|
-
if (!
|
|
4681
|
+
if (!existsSync5(outputDir)) {
|
|
2990
4682
|
process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
|
|
2991
4683
|
`);
|
|
2992
4684
|
process.exit(1);
|
|
2993
4685
|
}
|
|
2994
|
-
const workItem = await getWorkItem(context, id, credential
|
|
4686
|
+
const workItem = await getWorkItem(context, id, credential);
|
|
2995
4687
|
const attachment = workItem.attachments?.find(
|
|
2996
4688
|
(a) => a.name === filename
|
|
2997
4689
|
);
|
|
@@ -3002,9 +4694,9 @@ function createDownloadAttachmentCommand() {
|
|
|
3002
4694
|
);
|
|
3003
4695
|
process.exit(1);
|
|
3004
4696
|
}
|
|
3005
|
-
const data = await downloadAttachment(attachment.url, credential
|
|
3006
|
-
const outputPath =
|
|
3007
|
-
await
|
|
4697
|
+
const data = await downloadAttachment(attachment.url, credential);
|
|
4698
|
+
const outputPath = join3(outputDir, filename);
|
|
4699
|
+
await writeFile2(outputPath, Buffer.from(data));
|
|
3008
4700
|
process.stdout.write(
|
|
3009
4701
|
`Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
|
|
3010
4702
|
`
|
|
@@ -3017,9 +4709,415 @@ function createDownloadAttachmentCommand() {
|
|
|
3017
4709
|
return command;
|
|
3018
4710
|
}
|
|
3019
4711
|
|
|
4712
|
+
// src/commands/relations.ts
|
|
4713
|
+
import { Command as Command16 } from "commander";
|
|
4714
|
+
|
|
4715
|
+
// src/services/relations-client.ts
|
|
4716
|
+
var API_VERSION2 = "7.1";
|
|
4717
|
+
function buildRelationTypesUrl(context) {
|
|
4718
|
+
const url = new URL(
|
|
4719
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workitemrelationtypes`
|
|
4720
|
+
);
|
|
4721
|
+
url.searchParams.set("api-version", API_VERSION2);
|
|
4722
|
+
return url;
|
|
4723
|
+
}
|
|
4724
|
+
function buildWorkItemUrl2(context, id, expand) {
|
|
4725
|
+
const url = new URL(
|
|
4726
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
4727
|
+
);
|
|
4728
|
+
url.searchParams.set("api-version", API_VERSION2);
|
|
4729
|
+
if (expand) url.searchParams.set("$expand", expand);
|
|
4730
|
+
return url;
|
|
4731
|
+
}
|
|
4732
|
+
function buildBatchWorkItemsUrl(context, ids) {
|
|
4733
|
+
const url = new URL(
|
|
4734
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems`
|
|
4735
|
+
);
|
|
4736
|
+
url.searchParams.set("ids", ids.join(","));
|
|
4737
|
+
url.searchParams.set("fields", "System.Id,System.Title");
|
|
4738
|
+
url.searchParams.set("api-version", API_VERSION2);
|
|
4739
|
+
return url;
|
|
4740
|
+
}
|
|
4741
|
+
function mapRelationType(raw) {
|
|
4742
|
+
return {
|
|
4743
|
+
referenceName: raw.referenceName,
|
|
4744
|
+
name: raw.name,
|
|
4745
|
+
usage: raw.attributes?.usage ?? "unknown",
|
|
4746
|
+
enabled: raw.attributes?.enabled !== false,
|
|
4747
|
+
directional: raw.attributes?.directional ?? null
|
|
4748
|
+
};
|
|
4749
|
+
}
|
|
4750
|
+
async function readJsonResponse3(response) {
|
|
4751
|
+
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
|
4752
|
+
return await response.json();
|
|
4753
|
+
}
|
|
4754
|
+
function parseTargetId(url) {
|
|
4755
|
+
const match = /\/workItems\/(\d+)$/i.exec(url);
|
|
4756
|
+
return match ? Number(match[1]) : null;
|
|
4757
|
+
}
|
|
4758
|
+
async function getWorkItemRelationTypes(context, cred) {
|
|
4759
|
+
const url = buildRelationTypesUrl(context);
|
|
4760
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
4761
|
+
headers: authHeaders(cred)
|
|
4762
|
+
});
|
|
4763
|
+
const body = await readJsonResponse3(response);
|
|
4764
|
+
return (body.value ?? []).map(mapRelationType).filter((t) => t.usage === "workItemLink" && t.enabled);
|
|
4765
|
+
}
|
|
4766
|
+
async function resolveRelationType(context, cred, alias) {
|
|
4767
|
+
const types = await getWorkItemRelationTypes(context, cred);
|
|
4768
|
+
const lower = alias.toLowerCase();
|
|
4769
|
+
const match = types.find((t) => t.name.toLowerCase() === lower);
|
|
4770
|
+
if (!match) throw new Error(`UNKNOWN_RELATION_TYPE:${alias}`);
|
|
4771
|
+
return match;
|
|
4772
|
+
}
|
|
4773
|
+
async function getWorkItemWithRelations(context, cred, id) {
|
|
4774
|
+
const url = buildWorkItemUrl2(context, id, "relations");
|
|
4775
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
4776
|
+
headers: authHeaders(cred)
|
|
4777
|
+
});
|
|
4778
|
+
return readJsonResponse3(response);
|
|
4779
|
+
}
|
|
4780
|
+
async function addWorkItemRelation(context, cred, type, id1, id2) {
|
|
4781
|
+
if (id1 === id2) throw new Error("SELF_RELATION");
|
|
4782
|
+
const relType = await resolveRelationType(context, cred, type);
|
|
4783
|
+
const workItem = await getWorkItemWithRelations(context, cred, id1);
|
|
4784
|
+
const targetUrl = `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workItems/${id2}`;
|
|
4785
|
+
const existing = (workItem.relations ?? []).find(
|
|
4786
|
+
(r) => r.rel === relType.referenceName && r.url.toLowerCase() === targetUrl.toLowerCase()
|
|
4787
|
+
);
|
|
4788
|
+
if (existing) {
|
|
4789
|
+
return { status: "already_exists", type: relType.name, referenceName: relType.referenceName, id1, id2 };
|
|
4790
|
+
}
|
|
4791
|
+
const patchUrl = buildWorkItemUrl2(context, id1);
|
|
4792
|
+
const response = await fetchWithErrors(patchUrl.toString(), {
|
|
4793
|
+
method: "PATCH",
|
|
4794
|
+
headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
|
|
4795
|
+
body: JSON.stringify([
|
|
4796
|
+
{ op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
|
|
4797
|
+
])
|
|
4798
|
+
});
|
|
4799
|
+
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
|
4800
|
+
return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
|
|
4801
|
+
}
|
|
4802
|
+
async function removeWorkItemRelation(context, cred, type, id1, id2) {
|
|
4803
|
+
if (id1 === id2) throw new Error("SELF_RELATION");
|
|
4804
|
+
const relType = await resolveRelationType(context, cred, type);
|
|
4805
|
+
const workItem = await getWorkItemWithRelations(context, cred, id1);
|
|
4806
|
+
const relations = workItem.relations ?? [];
|
|
4807
|
+
const index = relations.findIndex(
|
|
4808
|
+
(r) => r.rel === relType.referenceName && parseTargetId(r.url) === id2
|
|
4809
|
+
);
|
|
4810
|
+
if (index === -1) {
|
|
4811
|
+
return { status: "not_found", type: relType.name, referenceName: relType.referenceName, id1, id2 };
|
|
4812
|
+
}
|
|
4813
|
+
const patchUrl = buildWorkItemUrl2(context, id1);
|
|
4814
|
+
const response = await fetchWithErrors(patchUrl.toString(), {
|
|
4815
|
+
method: "PATCH",
|
|
4816
|
+
headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
|
|
4817
|
+
body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
|
|
4818
|
+
});
|
|
4819
|
+
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
|
4820
|
+
return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
|
|
4821
|
+
}
|
|
4822
|
+
async function listWorkItemRelations(context, cred, id) {
|
|
4823
|
+
const workItem = await getWorkItemWithRelations(context, cred, id);
|
|
4824
|
+
const allRelations = workItem.relations ?? [];
|
|
4825
|
+
const wiRelations = allRelations.filter(
|
|
4826
|
+
(r) => !r.rel.startsWith("AttachedFile") && !r.rel.startsWith("Hyperlink") && !r.rel.startsWith("ArtifactLink")
|
|
4827
|
+
);
|
|
4828
|
+
if (wiRelations.length === 0) {
|
|
4829
|
+
return { workItemId: id, relations: [] };
|
|
4830
|
+
}
|
|
4831
|
+
const types = await getWorkItemRelationTypes(context, cred);
|
|
4832
|
+
const typeNameMap = new Map(types.map((t) => [t.referenceName, t.name]));
|
|
4833
|
+
const targetIds = wiRelations.map((r) => parseTargetId(r.url)).filter((n) => n !== null);
|
|
4834
|
+
const titleMap = /* @__PURE__ */ new Map();
|
|
4835
|
+
if (targetIds.length > 0) {
|
|
4836
|
+
try {
|
|
4837
|
+
const batchUrl = buildBatchWorkItemsUrl(context, targetIds);
|
|
4838
|
+
const batchResponse = await fetchWithErrors(batchUrl.toString(), {
|
|
4839
|
+
headers: authHeaders(cred)
|
|
4840
|
+
});
|
|
4841
|
+
const batchBody = await readJsonResponse3(batchResponse);
|
|
4842
|
+
for (const item of batchBody.value ?? []) {
|
|
4843
|
+
titleMap.set(item.id, item.fields["System.Title"] ?? "");
|
|
4844
|
+
}
|
|
4845
|
+
} catch {
|
|
4846
|
+
}
|
|
4847
|
+
}
|
|
4848
|
+
const relations = wiRelations.map((r) => {
|
|
4849
|
+
const targetId = parseTargetId(r.url);
|
|
4850
|
+
return {
|
|
4851
|
+
rel: r.rel,
|
|
4852
|
+
relName: typeNameMap.get(r.rel) ?? r.rel,
|
|
4853
|
+
targetId: targetId ?? 0,
|
|
4854
|
+
targetTitle: targetId !== null ? titleMap.get(targetId) ?? null : null,
|
|
4855
|
+
targetUrl: r.url,
|
|
4856
|
+
comment: r.attributes?.comment ?? null
|
|
4857
|
+
};
|
|
4858
|
+
});
|
|
4859
|
+
return { workItemId: id, relations };
|
|
4860
|
+
}
|
|
4861
|
+
|
|
4862
|
+
// src/commands/relations.ts
|
|
4863
|
+
function addCommonOptions(cmd) {
|
|
4864
|
+
return cmd.option("--json", "Output as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
|
|
4865
|
+
}
|
|
4866
|
+
async function resolveCredentials(opts) {
|
|
4867
|
+
const context = resolveContext(opts);
|
|
4868
|
+
const cred = await requireAuthCredential(context.org);
|
|
4869
|
+
return { context, cred };
|
|
4870
|
+
}
|
|
4871
|
+
function formatRelationTypes(types) {
|
|
4872
|
+
if (types.length === 0) return "No work item relation types found.";
|
|
4873
|
+
const nameWidth = Math.max(...types.map((t) => t.name.length), 4);
|
|
4874
|
+
const lines = ["Available work item relation types:", ""];
|
|
4875
|
+
for (const t of types) {
|
|
4876
|
+
lines.push(`${t.name.padEnd(nameWidth + 2)}${t.referenceName}`);
|
|
4877
|
+
}
|
|
4878
|
+
return lines.join("\n");
|
|
4879
|
+
}
|
|
4880
|
+
function formatRelationsList(workItemId, relations) {
|
|
4881
|
+
if (relations.length === 0) return `Work item #${workItemId} has no relations.`;
|
|
4882
|
+
const typeWidth = Math.max(...relations.map((r) => r.relName.length), 4) + 2;
|
|
4883
|
+
const idWidth = Math.max(...relations.map((r) => String(r.targetId).length), 4) + 1;
|
|
4884
|
+
const lines = [`Relations for work item #${workItemId}:`, ""];
|
|
4885
|
+
for (const r of relations) {
|
|
4886
|
+
const typeLabel = `[${r.relName}]`.padEnd(typeWidth);
|
|
4887
|
+
const idLabel = `#${r.targetId}`.padEnd(idWidth);
|
|
4888
|
+
lines.push(`${typeLabel} ${idLabel} ${r.targetTitle ?? "(unknown)"}`);
|
|
4889
|
+
}
|
|
4890
|
+
return lines.join("\n");
|
|
4891
|
+
}
|
|
4892
|
+
function handleRelationError(err, id1) {
|
|
4893
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4894
|
+
if (msg === "SELF_RELATION") {
|
|
4895
|
+
process.stderr.write(`Error: cannot relate a work item to itself (#${id1}).
|
|
4896
|
+
`);
|
|
4897
|
+
} else if (msg.startsWith("UNKNOWN_RELATION_TYPE:")) {
|
|
4898
|
+
const name = msg.replace("UNKNOWN_RELATION_TYPE:", "");
|
|
4899
|
+
process.stderr.write(
|
|
4900
|
+
`Error: unknown relation type "${name}". Run 'azdo relations types' to see valid names.
|
|
4901
|
+
`
|
|
4902
|
+
);
|
|
4903
|
+
} else if (msg.startsWith("NOT_FOUND")) {
|
|
4904
|
+
const target = id1 !== void 0 ? id1 : "unknown";
|
|
4905
|
+
process.stderr.write(`Error: work item #${target} not found.
|
|
4906
|
+
`);
|
|
4907
|
+
} else if (msg === "AUTH_FAILED") {
|
|
4908
|
+
process.stderr.write(
|
|
4909
|
+
`Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
|
|
4910
|
+
`
|
|
4911
|
+
);
|
|
4912
|
+
} else {
|
|
4913
|
+
process.stderr.write(`Error: ${msg}
|
|
4914
|
+
`);
|
|
4915
|
+
}
|
|
4916
|
+
process.exit(1);
|
|
4917
|
+
}
|
|
4918
|
+
function parsePositiveInt(value, label) {
|
|
4919
|
+
const n = Number(value);
|
|
4920
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
4921
|
+
process.stderr.write(`Error: ${label} must be a positive integer.
|
|
4922
|
+
`);
|
|
4923
|
+
process.exit(1);
|
|
4924
|
+
}
|
|
4925
|
+
return n;
|
|
4926
|
+
}
|
|
4927
|
+
function createRelationsCommand() {
|
|
4928
|
+
const relations = new Command16("relations").description("Manage work item relations");
|
|
4929
|
+
addCommonOptions(
|
|
4930
|
+
relations.command("types").description("List all available work item relation types")
|
|
4931
|
+
).action(async (opts) => {
|
|
4932
|
+
try {
|
|
4933
|
+
const { context, cred } = await resolveCredentials(opts);
|
|
4934
|
+
const types = await getWorkItemRelationTypes(context, cred);
|
|
4935
|
+
if (opts.json) {
|
|
4936
|
+
process.stdout.write(JSON.stringify(types, null, 2) + "\n");
|
|
4937
|
+
} else {
|
|
4938
|
+
process.stdout.write(formatRelationTypes(types) + "\n");
|
|
4939
|
+
}
|
|
4940
|
+
} catch (err) {
|
|
4941
|
+
handleRelationError(err);
|
|
4942
|
+
}
|
|
4943
|
+
});
|
|
4944
|
+
addCommonOptions(
|
|
4945
|
+
relations.command("add <type> <id1> <id2>").description("Add a directed relation from work item <id1> to <id2>")
|
|
4946
|
+
).action(async (type, id1Str, id2Str, opts) => {
|
|
4947
|
+
const id1 = parsePositiveInt(id1Str, "id1");
|
|
4948
|
+
const id2 = parsePositiveInt(id2Str, "id2");
|
|
4949
|
+
try {
|
|
4950
|
+
const { context, cred } = await resolveCredentials(opts);
|
|
4951
|
+
const result = await addWorkItemRelation(context, cred, type, id1, id2);
|
|
4952
|
+
if (opts.json) {
|
|
4953
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
4954
|
+
} else if (result.status === "already_exists") {
|
|
4955
|
+
process.stdout.write(`Relation already exists: #${id1} --[${result.type}]--> #${id2}
|
|
4956
|
+
`);
|
|
4957
|
+
} else {
|
|
4958
|
+
process.stdout.write(`Added relation: #${id1} --[${result.type}]--> #${id2}
|
|
4959
|
+
`);
|
|
4960
|
+
}
|
|
4961
|
+
} catch (err) {
|
|
4962
|
+
handleRelationError(err, id1);
|
|
4963
|
+
}
|
|
4964
|
+
});
|
|
4965
|
+
addCommonOptions(
|
|
4966
|
+
relations.command("remove <type> <id1> <id2>").description("Remove a directed relation of <type> from work item <id1> to <id2>")
|
|
4967
|
+
).action(async (type, id1Str, id2Str, opts) => {
|
|
4968
|
+
const id1 = parsePositiveInt(id1Str, "id1");
|
|
4969
|
+
const id2 = parsePositiveInt(id2Str, "id2");
|
|
4970
|
+
try {
|
|
4971
|
+
const { context, cred } = await resolveCredentials(opts);
|
|
4972
|
+
const result = await removeWorkItemRelation(context, cred, type, id1, id2);
|
|
4973
|
+
if (opts.json) {
|
|
4974
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
4975
|
+
} else if (result.status === "not_found") {
|
|
4976
|
+
process.stdout.write(`No relation of type '${result.type}' found between #${id1} and #${id2}
|
|
4977
|
+
`);
|
|
4978
|
+
} else {
|
|
4979
|
+
process.stdout.write(`Removed relation: #${id1} --[${result.type}]--> #${id2}
|
|
4980
|
+
`);
|
|
4981
|
+
}
|
|
4982
|
+
} catch (err) {
|
|
4983
|
+
handleRelationError(err, id1);
|
|
4984
|
+
}
|
|
4985
|
+
});
|
|
4986
|
+
addCommonOptions(
|
|
4987
|
+
relations.command("list <id>").description("List all work item link relations on a work item")
|
|
4988
|
+
).action(async (idStr, opts) => {
|
|
4989
|
+
const id = parsePositiveInt(idStr, "id");
|
|
4990
|
+
try {
|
|
4991
|
+
const { context, cred } = await resolveCredentials(opts);
|
|
4992
|
+
const result = await listWorkItemRelations(context, cred, id);
|
|
4993
|
+
if (opts.json) {
|
|
4994
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
4995
|
+
} else {
|
|
4996
|
+
process.stdout.write(formatRelationsList(result.workItemId, result.relations) + "\n");
|
|
4997
|
+
}
|
|
4998
|
+
} catch (err) {
|
|
4999
|
+
handleRelationError(err, id);
|
|
5000
|
+
}
|
|
5001
|
+
});
|
|
5002
|
+
return relations;
|
|
5003
|
+
}
|
|
5004
|
+
|
|
5005
|
+
// src/services/update-check.ts
|
|
5006
|
+
import fs2 from "fs";
|
|
5007
|
+
import path2 from "path";
|
|
5008
|
+
import os from "os";
|
|
5009
|
+
var THROTTLE_MS = 10 * 60 * 1e3;
|
|
5010
|
+
var FETCH_TIMEOUT_MS = 1500;
|
|
5011
|
+
var REGISTRY_URL = "https://registry.npmjs.org/azdo-cli/latest";
|
|
5012
|
+
function getCachePath() {
|
|
5013
|
+
return path2.join(os.homedir(), ".azdo", "update-check.json");
|
|
5014
|
+
}
|
|
5015
|
+
function defaultReadCache() {
|
|
5016
|
+
try {
|
|
5017
|
+
return fs2.readFileSync(getCachePath(), "utf-8");
|
|
5018
|
+
} catch {
|
|
5019
|
+
return null;
|
|
5020
|
+
}
|
|
5021
|
+
}
|
|
5022
|
+
function defaultWriteCache(data) {
|
|
5023
|
+
try {
|
|
5024
|
+
const cachePath = getCachePath();
|
|
5025
|
+
fs2.mkdirSync(path2.dirname(cachePath), { recursive: true });
|
|
5026
|
+
fs2.writeFileSync(cachePath, data);
|
|
5027
|
+
} catch {
|
|
5028
|
+
}
|
|
5029
|
+
}
|
|
5030
|
+
function parseCache(raw) {
|
|
5031
|
+
if (!raw) return null;
|
|
5032
|
+
let parsed;
|
|
5033
|
+
try {
|
|
5034
|
+
parsed = JSON.parse(raw);
|
|
5035
|
+
} catch {
|
|
5036
|
+
return null;
|
|
5037
|
+
}
|
|
5038
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
5039
|
+
const { lastCheck, latestVersion } = parsed;
|
|
5040
|
+
if (typeof lastCheck !== "number" || !Number.isFinite(lastCheck)) return null;
|
|
5041
|
+
if (typeof latestVersion !== "string" || latestVersion.length === 0) return null;
|
|
5042
|
+
return { lastCheck, latestVersion };
|
|
5043
|
+
}
|
|
5044
|
+
async function defaultFetchLatest() {
|
|
5045
|
+
const controller = new AbortController();
|
|
5046
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
5047
|
+
try {
|
|
5048
|
+
const res = await fetch(REGISTRY_URL, { signal: controller.signal });
|
|
5049
|
+
if (!res.ok) return null;
|
|
5050
|
+
const body = await res.json();
|
|
5051
|
+
if (typeof body !== "object" || body === null) return null;
|
|
5052
|
+
const v = body.version;
|
|
5053
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
5054
|
+
} catch {
|
|
5055
|
+
return null;
|
|
5056
|
+
} finally {
|
|
5057
|
+
clearTimeout(timer);
|
|
5058
|
+
}
|
|
5059
|
+
}
|
|
5060
|
+
function parseVersion(v) {
|
|
5061
|
+
if (typeof v !== "string") return null;
|
|
5062
|
+
const trimmed = v.trim().replace(/^v/, "");
|
|
5063
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(trimmed);
|
|
5064
|
+
if (!match) return null;
|
|
5065
|
+
const release = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
5066
|
+
if (!release.every(Number.isFinite)) return null;
|
|
5067
|
+
return { release, prerelease: match[4] !== void 0 };
|
|
5068
|
+
}
|
|
5069
|
+
function isNewer(latest, current) {
|
|
5070
|
+
const a = parseVersion(latest);
|
|
5071
|
+
const b = parseVersion(current);
|
|
5072
|
+
if (!a || !b) return false;
|
|
5073
|
+
for (let i = 0; i < 3; i++) {
|
|
5074
|
+
if (a.release[i] > b.release[i]) return true;
|
|
5075
|
+
if (a.release[i] < b.release[i]) return false;
|
|
5076
|
+
}
|
|
5077
|
+
if (a.prerelease && !b.prerelease) return false;
|
|
5078
|
+
if (!a.prerelease && b.prerelease) return true;
|
|
5079
|
+
return false;
|
|
5080
|
+
}
|
|
5081
|
+
async function getUpdateNotice(opts) {
|
|
5082
|
+
try {
|
|
5083
|
+
const {
|
|
5084
|
+
enabled = true,
|
|
5085
|
+
now = Date.now,
|
|
5086
|
+
readCache = defaultReadCache,
|
|
5087
|
+
writeCache = defaultWriteCache,
|
|
5088
|
+
fetchLatest = defaultFetchLatest,
|
|
5089
|
+
isTTY = () => Boolean(process.stderr.isTTY),
|
|
5090
|
+
currentVersion = version
|
|
5091
|
+
} = opts ?? {};
|
|
5092
|
+
if (enabled === false) return null;
|
|
5093
|
+
if (!isTTY()) return null;
|
|
5094
|
+
const cache = parseCache(readCache());
|
|
5095
|
+
const lastCheck = cache?.lastCheck ?? 0;
|
|
5096
|
+
if (now() - lastCheck < THROTTLE_MS) return null;
|
|
5097
|
+
const latest = await fetchLatest();
|
|
5098
|
+
if (!latest) return null;
|
|
5099
|
+
writeCache(JSON.stringify({ lastCheck: now(), latestVersion: latest }));
|
|
5100
|
+
if (isNewer(latest, currentVersion)) {
|
|
5101
|
+
return `A new version of azdo-cli is available: ${currentVersion} \u2192 ${latest}. Run \`npm i -g azdo-cli\` to update.`;
|
|
5102
|
+
}
|
|
5103
|
+
return null;
|
|
5104
|
+
} catch {
|
|
5105
|
+
return null;
|
|
5106
|
+
}
|
|
5107
|
+
}
|
|
5108
|
+
|
|
3020
5109
|
// src/index.ts
|
|
3021
|
-
|
|
5110
|
+
function exitOnEpipe(err) {
|
|
5111
|
+
if (err.code === "EPIPE") {
|
|
5112
|
+
process.exit(0);
|
|
5113
|
+
}
|
|
5114
|
+
throw err;
|
|
5115
|
+
}
|
|
5116
|
+
process.stdout.on("error", exitOnEpipe);
|
|
5117
|
+
process.stderr.on("error", exitOnEpipe);
|
|
5118
|
+
var program = new Command17();
|
|
3022
5119
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
5120
|
+
program.option("--no-update-check", "Skip the check for a newer published version");
|
|
3023
5121
|
program.addCommand(createGetItemCommand());
|
|
3024
5122
|
program.addCommand(createAuthCommand());
|
|
3025
5123
|
program.addCommand(createClearPatCommand());
|
|
@@ -3032,10 +5130,21 @@ program.addCommand(createSetMdFieldCommand());
|
|
|
3032
5130
|
program.addCommand(createUpsertCommand());
|
|
3033
5131
|
program.addCommand(createListFieldsCommand());
|
|
3034
5132
|
program.addCommand(createPrCommand());
|
|
5133
|
+
program.addCommand(createPipelineCommand());
|
|
3035
5134
|
program.addCommand(createCommentsCommand());
|
|
3036
5135
|
program.addCommand(createDownloadAttachmentCommand());
|
|
5136
|
+
program.addCommand(createRelationsCommand());
|
|
3037
5137
|
program.showHelpAfterError();
|
|
3038
|
-
program.
|
|
3039
|
-
|
|
3040
|
-
|
|
5138
|
+
program.hook("postAction", async () => {
|
|
5139
|
+
const notice = await getUpdateNotice({ enabled: program.opts().updateCheck });
|
|
5140
|
+
if (notice) {
|
|
5141
|
+
process.stderr.write(notice + "\n");
|
|
5142
|
+
}
|
|
5143
|
+
});
|
|
5144
|
+
async function main() {
|
|
5145
|
+
await program.parseAsync();
|
|
5146
|
+
if (process.argv.length <= 2) {
|
|
5147
|
+
program.help();
|
|
5148
|
+
}
|
|
3041
5149
|
}
|
|
5150
|
+
void main();
|