azdo-cli 0.5.0-next.155 → 0.5.0-next.349
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 +25 -340
- package/dist/chunk-C7RAZJHV.js +1087 -0
- package/dist/index.js +1409 -291
- package/dist/oauth-token-refresh-PHW66RC4.js +15 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
AZDO_RESOURCE_ID,
|
|
4
|
+
CredentialMissingError,
|
|
5
|
+
CredentialStoreUnavailableError,
|
|
6
|
+
SETTINGS,
|
|
7
|
+
appendAuthAuditEvent,
|
|
8
|
+
buildScopeString,
|
|
9
|
+
defaultScopes,
|
|
10
|
+
deletePat,
|
|
11
|
+
firstPartyShippedScopes,
|
|
12
|
+
getConfigValue,
|
|
13
|
+
getPat,
|
|
14
|
+
getStoredCredential,
|
|
15
|
+
listOrgsWithStoredPat,
|
|
16
|
+
loadConfig,
|
|
17
|
+
maskedDisplay,
|
|
18
|
+
normalizePat,
|
|
19
|
+
openUrl,
|
|
20
|
+
probeBackend,
|
|
21
|
+
readAuditEvents,
|
|
22
|
+
readTokenResponse,
|
|
23
|
+
refreshIfNeeded,
|
|
24
|
+
resolveOAuthConfig,
|
|
25
|
+
runAuthCodeFlow,
|
|
26
|
+
setConfigValue,
|
|
27
|
+
storeOAuthCredential,
|
|
28
|
+
storePat,
|
|
29
|
+
tokenResponseToCredential,
|
|
30
|
+
unsetConfigValue
|
|
31
|
+
} from "./chunk-C7RAZJHV.js";
|
|
2
32
|
|
|
3
33
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
34
|
+
import { Command as Command15 } from "commander";
|
|
5
35
|
|
|
6
36
|
// src/version.ts
|
|
7
37
|
import { readFileSync } from "fs";
|
|
@@ -26,8 +56,15 @@ var DEFAULT_FIELDS = [
|
|
|
26
56
|
"System.AreaPath",
|
|
27
57
|
"System.IterationPath"
|
|
28
58
|
];
|
|
29
|
-
function authHeaders(
|
|
30
|
-
|
|
59
|
+
function authHeaders(credentialOrPat) {
|
|
60
|
+
if (typeof credentialOrPat === "string") {
|
|
61
|
+
const token2 = Buffer.from(`:${credentialOrPat}`).toString("base64");
|
|
62
|
+
return { Authorization: `Basic ${token2}` };
|
|
63
|
+
}
|
|
64
|
+
if (credentialOrPat.kind === "oauth") {
|
|
65
|
+
return { Authorization: `Bearer ${credentialOrPat.pat}` };
|
|
66
|
+
}
|
|
67
|
+
const token = Buffer.from(`:${credentialOrPat.pat}`).toString("base64");
|
|
31
68
|
return { Authorization: `Basic ${token}` };
|
|
32
69
|
}
|
|
33
70
|
async function fetchWithErrors(url, init) {
|
|
@@ -48,6 +85,10 @@ async function fetchWithErrors(url, init) {
|
|
|
48
85
|
}
|
|
49
86
|
throw new Error(`NOT_FOUND${detail}`);
|
|
50
87
|
}
|
|
88
|
+
const contentType = response.headers?.get("content-type") ?? "";
|
|
89
|
+
if (contentType.toLowerCase().startsWith("text/html")) {
|
|
90
|
+
throw new Error("AUTH_FAILED");
|
|
91
|
+
}
|
|
51
92
|
return response;
|
|
52
93
|
}
|
|
53
94
|
async function readResponseMessage(response) {
|
|
@@ -90,9 +131,9 @@ function buildExtraFields(fields, requested) {
|
|
|
90
131
|
}
|
|
91
132
|
return Object.keys(result).length > 0 ? result : null;
|
|
92
133
|
}
|
|
93
|
-
function writeHeaders(
|
|
134
|
+
function writeHeaders(cred) {
|
|
94
135
|
return {
|
|
95
|
-
...authHeaders(
|
|
136
|
+
...authHeaders(cred),
|
|
96
137
|
"Content-Type": "application/json-patch+json"
|
|
97
138
|
};
|
|
98
139
|
}
|
|
@@ -147,13 +188,13 @@ async function readWriteResponse(response, errorCode) {
|
|
|
147
188
|
fields: data.fields
|
|
148
189
|
};
|
|
149
190
|
}
|
|
150
|
-
async function getWorkItemFields(context, id,
|
|
191
|
+
async function getWorkItemFields(context, id, cred) {
|
|
151
192
|
const url = new URL(
|
|
152
193
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
153
194
|
);
|
|
154
195
|
url.searchParams.set("api-version", "7.1");
|
|
155
196
|
url.searchParams.set("$expand", "all");
|
|
156
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
197
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
157
198
|
if (response.status === 400) {
|
|
158
199
|
const serverMessage = await readResponseMessage(response);
|
|
159
200
|
if (serverMessage) {
|
|
@@ -166,17 +207,33 @@ async function getWorkItemFields(context, id, pat) {
|
|
|
166
207
|
const data = await response.json();
|
|
167
208
|
return data.fields;
|
|
168
209
|
}
|
|
169
|
-
|
|
210
|
+
function extractAttachments(relations) {
|
|
211
|
+
if (!relations) return null;
|
|
212
|
+
const attachments = relations.filter((r) => r.rel === "AttachedFile").map((r) => ({
|
|
213
|
+
name: r.attributes.name ?? "unknown",
|
|
214
|
+
size: r.attributes.resourceSize ?? 0,
|
|
215
|
+
url: r.url
|
|
216
|
+
}));
|
|
217
|
+
return attachments.length > 0 ? attachments : null;
|
|
218
|
+
}
|
|
219
|
+
function buildWorkItemUrl(context, id, options = {}) {
|
|
170
220
|
const url = new URL(
|
|
171
221
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
172
222
|
);
|
|
173
223
|
url.searchParams.set("api-version", "7.1");
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const allFields = normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields]);
|
|
177
|
-
url.searchParams.set("fields", allFields.join(","));
|
|
224
|
+
if (options.includeRelations) {
|
|
225
|
+
url.searchParams.set("$expand", "relations");
|
|
178
226
|
}
|
|
179
|
-
|
|
227
|
+
if (options.fields && options.fields.length > 0) {
|
|
228
|
+
url.searchParams.set("fields", options.fields.join(","));
|
|
229
|
+
}
|
|
230
|
+
return url;
|
|
231
|
+
}
|
|
232
|
+
async function fetchWorkItemResponse(context, id, cred, options = {}) {
|
|
233
|
+
const response = await fetchWithErrors(
|
|
234
|
+
buildWorkItemUrl(context, id, options).toString(),
|
|
235
|
+
{ headers: authHeaders(cred) }
|
|
236
|
+
);
|
|
180
237
|
if (response.status === 400) {
|
|
181
238
|
const serverMessage = await readResponseMessage(response);
|
|
182
239
|
if (serverMessage) {
|
|
@@ -186,7 +243,14 @@ async function getWorkItem(context, id, pat, extraFields) {
|
|
|
186
243
|
if (!response.ok) {
|
|
187
244
|
throw new Error(`HTTP_${response.status}`);
|
|
188
245
|
}
|
|
189
|
-
|
|
246
|
+
return await response.json();
|
|
247
|
+
}
|
|
248
|
+
async function getWorkItem(context, id, cred, extraFields) {
|
|
249
|
+
const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
|
|
250
|
+
const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, {
|
|
251
|
+
fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
|
|
252
|
+
}) : await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
|
|
253
|
+
const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
|
|
190
254
|
const descriptionParts = [];
|
|
191
255
|
if (data.fields["System.Description"]) {
|
|
192
256
|
descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
|
|
@@ -214,16 +278,17 @@ async function getWorkItem(context, id, pat, extraFields) {
|
|
|
214
278
|
areaPath: data.fields["System.AreaPath"],
|
|
215
279
|
iterationPath: data.fields["System.IterationPath"],
|
|
216
280
|
url: data._links.html.href,
|
|
217
|
-
extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null
|
|
281
|
+
extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null,
|
|
282
|
+
attachments: extractAttachments(relationsData.relations)
|
|
218
283
|
};
|
|
219
284
|
}
|
|
220
|
-
async function getWorkItemFieldValue(context, id,
|
|
285
|
+
async function getWorkItemFieldValue(context, id, cred, fieldName) {
|
|
221
286
|
const url = new URL(
|
|
222
287
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
223
288
|
);
|
|
224
289
|
url.searchParams.set("api-version", "7.1");
|
|
225
290
|
url.searchParams.set("fields", fieldName);
|
|
226
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
291
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
227
292
|
if (response.status === 400) {
|
|
228
293
|
const serverMessage = await readResponseMessage(response);
|
|
229
294
|
if (serverMessage) {
|
|
@@ -240,13 +305,13 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
|
|
|
240
305
|
}
|
|
241
306
|
return stringifyFieldValue(value);
|
|
242
307
|
}
|
|
243
|
-
async function listWorkItemComments(context, id,
|
|
308
|
+
async function listWorkItemComments(context, id, cred) {
|
|
244
309
|
const comments = [];
|
|
245
310
|
let continuationToken = null;
|
|
246
311
|
do {
|
|
247
312
|
const response = await fetchWithErrors(
|
|
248
313
|
buildWorkItemCommentsListUrl(context, id, continuationToken ?? void 0).toString(),
|
|
249
|
-
{ headers: authHeaders(
|
|
314
|
+
{ headers: authHeaders(cred) }
|
|
250
315
|
);
|
|
251
316
|
if (!response.ok) {
|
|
252
317
|
throw new Error(`HTTP_${response.status}`);
|
|
@@ -263,11 +328,13 @@ async function listWorkItemComments(context, id, pat) {
|
|
|
263
328
|
comments
|
|
264
329
|
};
|
|
265
330
|
}
|
|
266
|
-
async function addWorkItemComment(context, id,
|
|
267
|
-
const
|
|
331
|
+
async function addWorkItemComment(context, id, cred, text, format = "html") {
|
|
332
|
+
const url = buildWorkItemCommentsUrl(context, id);
|
|
333
|
+
url.searchParams.set("format", format);
|
|
334
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
268
335
|
method: "POST",
|
|
269
336
|
headers: {
|
|
270
|
-
...authHeaders(
|
|
337
|
+
...authHeaders(cred),
|
|
271
338
|
"Content-Type": "application/json"
|
|
272
339
|
},
|
|
273
340
|
body: JSON.stringify({ text })
|
|
@@ -289,8 +356,8 @@ async function addWorkItemComment(context, id, pat, text) {
|
|
|
289
356
|
url: data.url ?? null
|
|
290
357
|
};
|
|
291
358
|
}
|
|
292
|
-
async function updateWorkItem(context, id,
|
|
293
|
-
const result = await applyWorkItemPatch(context, id,
|
|
359
|
+
async function updateWorkItem(context, id, cred, fieldName, operations) {
|
|
360
|
+
const result = await applyWorkItemPatch(context, id, cred, operations);
|
|
294
361
|
const title = result.fields["System.Title"];
|
|
295
362
|
const lastOp = operations.at(-1);
|
|
296
363
|
const fieldValue = lastOp?.value ?? null;
|
|
@@ -302,68 +369,165 @@ async function updateWorkItem(context, id, pat, fieldName, operations) {
|
|
|
302
369
|
fieldValue
|
|
303
370
|
};
|
|
304
371
|
}
|
|
305
|
-
async function createWorkItem(context, workItemType,
|
|
372
|
+
async function createWorkItem(context, workItemType, cred, operations) {
|
|
306
373
|
const url = new URL(
|
|
307
374
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
|
|
308
375
|
);
|
|
309
376
|
url.searchParams.set("api-version", "7.1");
|
|
310
377
|
const response = await fetchWithErrors(url.toString(), {
|
|
311
378
|
method: "POST",
|
|
312
|
-
headers: writeHeaders(
|
|
379
|
+
headers: writeHeaders(cred),
|
|
313
380
|
body: JSON.stringify(operations)
|
|
314
381
|
});
|
|
315
382
|
return readWriteResponse(response, "CREATE_REJECTED");
|
|
316
383
|
}
|
|
317
|
-
async function applyWorkItemPatch(context, id,
|
|
384
|
+
async function applyWorkItemPatch(context, id, cred, operations) {
|
|
318
385
|
const url = new URL(
|
|
319
386
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
320
387
|
);
|
|
321
388
|
url.searchParams.set("api-version", "7.1");
|
|
322
389
|
const response = await fetchWithErrors(url.toString(), {
|
|
323
390
|
method: "PATCH",
|
|
324
|
-
headers: writeHeaders(
|
|
391
|
+
headers: writeHeaders(cred),
|
|
325
392
|
body: JSON.stringify(operations)
|
|
326
393
|
});
|
|
327
394
|
return readWriteResponse(response, "UPDATE_REJECTED");
|
|
328
395
|
}
|
|
396
|
+
async function downloadAttachment(url, cred) {
|
|
397
|
+
const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
|
|
398
|
+
if (!response.ok) {
|
|
399
|
+
throw new Error(`HTTP_${response.status}`);
|
|
400
|
+
}
|
|
401
|
+
return response.arrayBuffer();
|
|
402
|
+
}
|
|
329
403
|
|
|
330
404
|
// src/services/auth.ts
|
|
331
405
|
import { createInterface } from "readline";
|
|
406
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
407
|
+
import { dirname as dirname2, join } from "path";
|
|
332
408
|
|
|
333
|
-
// src/services/
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
409
|
+
// src/services/oauth-device-code.ts
|
|
410
|
+
var DeviceCodeFlowError = class extends Error {
|
|
411
|
+
reason;
|
|
412
|
+
constructor(reason, message, cause) {
|
|
413
|
+
super(message);
|
|
414
|
+
this.name = "DeviceCodeFlowError";
|
|
415
|
+
this.reason = reason;
|
|
416
|
+
if (cause instanceof Error) {
|
|
417
|
+
this.cause = cause;
|
|
418
|
+
}
|
|
343
419
|
}
|
|
420
|
+
};
|
|
421
|
+
var MIN_INTERVAL_SEC = 5;
|
|
422
|
+
async function defaultSleep(ms) {
|
|
423
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
344
424
|
}
|
|
345
|
-
async function
|
|
425
|
+
async function requestDeviceCode(oauthConfig, fetchFn) {
|
|
426
|
+
const body = new URLSearchParams({
|
|
427
|
+
client_id: oauthConfig.clientId,
|
|
428
|
+
scope: buildScopeString(oauthConfig.scopes)
|
|
429
|
+
});
|
|
430
|
+
const response = await fetchFn(oauthConfig.deviceCodeEndpoint, {
|
|
431
|
+
method: "POST",
|
|
432
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
433
|
+
body: body.toString()
|
|
434
|
+
});
|
|
435
|
+
const text = await response.text();
|
|
436
|
+
let parsed;
|
|
346
437
|
try {
|
|
347
|
-
|
|
348
|
-
entry.setPassword(pat);
|
|
438
|
+
parsed = JSON.parse(text);
|
|
349
439
|
} catch {
|
|
440
|
+
throw new DeviceCodeFlowError("idp-error", `device-code endpoint returned non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
441
|
+
}
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
const err = parsed;
|
|
444
|
+
const code = err.error ?? "unknown";
|
|
445
|
+
const desc = err.error_description ? `: ${err.error_description}` : "";
|
|
446
|
+
throw new DeviceCodeFlowError(
|
|
447
|
+
"idp-error",
|
|
448
|
+
`device-code endpoint rejected request (${response.status}): ${code}${desc}`
|
|
449
|
+
);
|
|
350
450
|
}
|
|
451
|
+
return parsed;
|
|
351
452
|
}
|
|
352
|
-
async function
|
|
453
|
+
async function classifyDeviceTokenResponse(response) {
|
|
454
|
+
if (response.ok) {
|
|
455
|
+
return { kind: "success", token: await readTokenResponse(response) };
|
|
456
|
+
}
|
|
457
|
+
const text = await response.text();
|
|
458
|
+
let parsed;
|
|
353
459
|
try {
|
|
354
|
-
|
|
355
|
-
entry.deletePassword();
|
|
356
|
-
return true;
|
|
460
|
+
parsed = JSON.parse(text);
|
|
357
461
|
} catch {
|
|
358
|
-
|
|
462
|
+
throw new DeviceCodeFlowError("idp-error", `non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
463
|
+
}
|
|
464
|
+
const errCode = parsed.error ?? "";
|
|
465
|
+
if (errCode === "authorization_pending") return { kind: "pending" };
|
|
466
|
+
if (errCode === "slow_down") return { kind: "slow_down" };
|
|
467
|
+
if (errCode === "expired_token") {
|
|
468
|
+
throw new DeviceCodeFlowError("expired_token", "device code expired before authorisation completed");
|
|
469
|
+
}
|
|
470
|
+
if (errCode === "access_denied") {
|
|
471
|
+
throw new DeviceCodeFlowError("access_denied", "authorisation denied by user");
|
|
472
|
+
}
|
|
473
|
+
const desc = parsed.error_description ? `: ${parsed.error_description}` : "";
|
|
474
|
+
throw new DeviceCodeFlowError(
|
|
475
|
+
"idp-error",
|
|
476
|
+
`IdP rejected device-token poll (${response.status}): ${errCode}${desc}`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
async function pollForDeviceToken(deviceCode, oauthConfig, initialIntervalSec, expiresAtMs, deps) {
|
|
480
|
+
const fetchFn = deps.fetch ?? fetch;
|
|
481
|
+
const now = deps.now ?? (() => Date.now());
|
|
482
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
483
|
+
let intervalSec = Math.max(MIN_INTERVAL_SEC, initialIntervalSec);
|
|
484
|
+
for (; ; ) {
|
|
485
|
+
if (now() >= expiresAtMs) {
|
|
486
|
+
throw new DeviceCodeFlowError("expired_token", "device-code flow expired before authorisation completed");
|
|
487
|
+
}
|
|
488
|
+
await sleep(intervalSec * 1e3);
|
|
489
|
+
const body = new URLSearchParams({
|
|
490
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
491
|
+
client_id: oauthConfig.clientId,
|
|
492
|
+
device_code: deviceCode
|
|
493
|
+
});
|
|
494
|
+
const response = await fetchFn(oauthConfig.tokenEndpoint, {
|
|
495
|
+
method: "POST",
|
|
496
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
497
|
+
body: body.toString()
|
|
498
|
+
});
|
|
499
|
+
const outcome = await classifyDeviceTokenResponse(response);
|
|
500
|
+
if (outcome.kind === "success") return outcome.token;
|
|
501
|
+
if (outcome.kind === "slow_down") {
|
|
502
|
+
intervalSec += 5;
|
|
503
|
+
}
|
|
359
504
|
}
|
|
360
505
|
}
|
|
506
|
+
async function runDeviceCodeFlow(org, oauthConfig, deps = {}) {
|
|
507
|
+
const fetchFn = deps.fetch ?? fetch;
|
|
508
|
+
const now = deps.now ?? (() => Date.now());
|
|
509
|
+
const writePrompt = deps.writePrompt ?? ((m) => {
|
|
510
|
+
process.stderr.write(m);
|
|
511
|
+
});
|
|
512
|
+
const dc = await requestDeviceCode(oauthConfig, fetchFn);
|
|
513
|
+
const expiryMin = Math.round(dc.expires_in / 60);
|
|
514
|
+
writePrompt(
|
|
515
|
+
`
|
|
516
|
+
To authenticate, open ${dc.verification_uri} in a browser and enter the code:
|
|
361
517
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
518
|
+
${dc.user_code}
|
|
519
|
+
|
|
520
|
+
Waiting for authorisation (expires in ${expiryMin} min)\u2026
|
|
521
|
+
`
|
|
522
|
+
);
|
|
523
|
+
const expiresAtMs = now() + dc.expires_in * 1e3;
|
|
524
|
+
const token = await pollForDeviceToken(dc.device_code, oauthConfig, dc.interval, expiresAtMs, deps);
|
|
525
|
+
const credential = tokenResponseToCredential(org, oauthConfig, token, now());
|
|
526
|
+
return { credential, flowUsed: "device-code" };
|
|
366
527
|
}
|
|
528
|
+
|
|
529
|
+
// src/services/auth.ts
|
|
530
|
+
var PAT_PROMPT = "Enter your Azure DevOps PAT: ";
|
|
367
531
|
async function promptForPat() {
|
|
368
532
|
if (!process.stdin.isTTY) {
|
|
369
533
|
return null;
|
|
@@ -371,12 +535,16 @@ async function promptForPat() {
|
|
|
371
535
|
return new Promise((resolve2) => {
|
|
372
536
|
const rl = createInterface({
|
|
373
537
|
input: process.stdin,
|
|
374
|
-
|
|
538
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
539
|
+
output: null
|
|
375
540
|
});
|
|
376
|
-
process.stderr.write(
|
|
541
|
+
process.stderr.write(PAT_PROMPT);
|
|
377
542
|
process.stdin.setRawMode(true);
|
|
378
543
|
process.stdin.resume();
|
|
379
544
|
let pat = "";
|
|
545
|
+
const redraw = () => {
|
|
546
|
+
process.stderr.write(`\r${PAT_PROMPT}${maskedDisplay(pat)}\x1B[K`);
|
|
547
|
+
};
|
|
380
548
|
const onData = (key) => {
|
|
381
549
|
const ch = key.toString("utf8");
|
|
382
550
|
if (ch === "") {
|
|
@@ -394,56 +562,246 @@ async function promptForPat() {
|
|
|
394
562
|
} else if (ch === "\x7F" || ch === "\b") {
|
|
395
563
|
if (pat.length > 0) {
|
|
396
564
|
pat = pat.slice(0, -1);
|
|
397
|
-
|
|
565
|
+
redraw();
|
|
398
566
|
}
|
|
399
567
|
} else {
|
|
400
568
|
pat += ch;
|
|
401
|
-
|
|
569
|
+
redraw();
|
|
402
570
|
}
|
|
403
571
|
};
|
|
404
572
|
process.stdin.on("data", onData);
|
|
405
573
|
});
|
|
406
574
|
}
|
|
407
|
-
|
|
575
|
+
function findDotEnvPat(startDir = process.cwd()) {
|
|
576
|
+
let current = startDir;
|
|
577
|
+
while (true) {
|
|
578
|
+
const envFile = join(current, ".env");
|
|
579
|
+
if (existsSync(envFile)) {
|
|
580
|
+
const contents = readFileSync2(envFile, "utf8");
|
|
581
|
+
for (const line of contents.split("\n")) {
|
|
582
|
+
const match = line.match(/^AZDO_PAT\s*=\s*(.+)$/);
|
|
583
|
+
if (match) {
|
|
584
|
+
const value = match[1].trim().replace(/^["']|["']$/g, "");
|
|
585
|
+
if (value.length > 0) return value;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const parent = dirname2(current);
|
|
590
|
+
if (parent === current) break;
|
|
591
|
+
current = parent;
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
async function resolveAuthCredential(org) {
|
|
408
596
|
const envPat = process.env.AZDO_PAT;
|
|
409
|
-
if (envPat) {
|
|
410
|
-
return { pat: envPat, source: "env" };
|
|
597
|
+
if (envPat && envPat.length > 0) {
|
|
598
|
+
return { pat: envPat, source: "env", kind: "pat" };
|
|
411
599
|
}
|
|
412
|
-
const
|
|
413
|
-
if (
|
|
414
|
-
|
|
600
|
+
const stored = await getStoredCredential(org);
|
|
601
|
+
if (stored !== null) {
|
|
602
|
+
if (stored.kind === "pat") {
|
|
603
|
+
return { pat: stored.token, source: "credential-store", kind: "pat" };
|
|
604
|
+
}
|
|
605
|
+
const fresh = await refreshIfNeeded(org, stored);
|
|
606
|
+
return {
|
|
607
|
+
pat: fresh.accessToken,
|
|
608
|
+
source: "credential-store",
|
|
609
|
+
kind: "oauth",
|
|
610
|
+
accountId: fresh.accountId
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const dotEnvPat = findDotEnvPat();
|
|
614
|
+
if (dotEnvPat !== null) {
|
|
615
|
+
return { pat: dotEnvPat, source: "env", kind: "pat" };
|
|
415
616
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
async function requireAuthCredential(org) {
|
|
620
|
+
const cred = await resolveAuthCredential(org);
|
|
621
|
+
if (cred !== null) {
|
|
622
|
+
return cred;
|
|
623
|
+
}
|
|
624
|
+
throw new CredentialMissingError(org);
|
|
625
|
+
}
|
|
626
|
+
async function validatePatAgainstAzdo(pat, org) {
|
|
627
|
+
const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?$top=1&api-version=7.1`;
|
|
628
|
+
const auth = Buffer.from(`:${pat}`).toString("base64");
|
|
629
|
+
const response = await fetch(url, {
|
|
630
|
+
headers: {
|
|
631
|
+
Authorization: `Basic ${auth}`,
|
|
632
|
+
Accept: "application/json"
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
if (response.status === 200) {
|
|
636
|
+
return { ok: true, status: 200 };
|
|
637
|
+
}
|
|
638
|
+
if (response.status === 401 || response.status === 403) {
|
|
639
|
+
return { ok: false, status: response.status };
|
|
640
|
+
}
|
|
641
|
+
throw new Error(`Azure DevOps returned HTTP ${response.status} while validating PAT for org "${org}".`);
|
|
642
|
+
}
|
|
643
|
+
var LOGIN_FAILURE_REASONS = /* @__PURE__ */ new Set([
|
|
644
|
+
"user-cancelled",
|
|
645
|
+
"port-conflict",
|
|
646
|
+
"state-mismatch",
|
|
647
|
+
"redirect-mismatch",
|
|
648
|
+
"idp-error",
|
|
649
|
+
"timeout",
|
|
650
|
+
"expired_token",
|
|
651
|
+
"access_denied"
|
|
652
|
+
]);
|
|
653
|
+
function extractLoginFailureReason(err) {
|
|
654
|
+
if (typeof err === "object" && err !== null && "reason" in err) {
|
|
655
|
+
const r = err.reason;
|
|
656
|
+
if (typeof r === "string" && LOGIN_FAILURE_REASONS.has(r)) {
|
|
657
|
+
return r;
|
|
422
658
|
}
|
|
423
659
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
660
|
+
return "unknown";
|
|
661
|
+
}
|
|
662
|
+
async function loginWithOAuth(org, opts = {}) {
|
|
663
|
+
const oauthConfig = resolveOAuthConfig({
|
|
664
|
+
clientIdOverride: opts.clientIdOverride,
|
|
665
|
+
tenantIdOverride: opts.tenantIdOverride,
|
|
666
|
+
scopesOverride: opts.scopesOverride
|
|
667
|
+
});
|
|
668
|
+
const isHeadlessRuntime = () => {
|
|
669
|
+
if (opts.forceHeadless) return true;
|
|
670
|
+
if (process.platform === "linux") {
|
|
671
|
+
return !process.env.DISPLAY || process.env.DISPLAY.length === 0;
|
|
672
|
+
}
|
|
673
|
+
return false;
|
|
674
|
+
};
|
|
675
|
+
const useDeviceCode = opts.flow === "device-code" || opts.flow !== "auth-code" && isHeadlessRuntime();
|
|
676
|
+
appendAuthAuditEvent({
|
|
677
|
+
event: "oauth-login-started",
|
|
678
|
+
org,
|
|
679
|
+
backend: probeBackend(),
|
|
680
|
+
flow: useDeviceCode ? "device-code" : "auth-code",
|
|
681
|
+
clientIdSource: oauthConfig.clientIdSource
|
|
682
|
+
});
|
|
683
|
+
let credential;
|
|
684
|
+
let flowUsed;
|
|
685
|
+
try {
|
|
686
|
+
if (useDeviceCode) {
|
|
687
|
+
const r = await runDeviceCodeFlow(org, oauthConfig);
|
|
688
|
+
credential = r.credential;
|
|
689
|
+
flowUsed = "device-code";
|
|
690
|
+
} else {
|
|
691
|
+
const r = await runAuthCodeFlow(org, oauthConfig);
|
|
692
|
+
credential = r.credential;
|
|
693
|
+
flowUsed = "auth-code";
|
|
694
|
+
}
|
|
695
|
+
} catch (err) {
|
|
696
|
+
appendAuthAuditEvent({
|
|
697
|
+
event: "oauth-login-failed",
|
|
698
|
+
org,
|
|
699
|
+
backend: probeBackend(),
|
|
700
|
+
flow: useDeviceCode ? "device-code" : "auth-code",
|
|
701
|
+
reason: extractLoginFailureReason(err)
|
|
702
|
+
});
|
|
703
|
+
throw err;
|
|
704
|
+
}
|
|
705
|
+
await storeOAuthCredential(org, credential);
|
|
706
|
+
appendAuthAuditEvent({
|
|
707
|
+
event: "oauth-login-success",
|
|
708
|
+
org,
|
|
709
|
+
backend: probeBackend(),
|
|
710
|
+
flow: flowUsed,
|
|
711
|
+
clientIdSource: oauthConfig.clientIdSource,
|
|
712
|
+
accountId: credential.accountId,
|
|
713
|
+
scope: credential.scope,
|
|
714
|
+
tokenLifetimeSec: credential.expiresAt - credential.issuedAt
|
|
715
|
+
});
|
|
716
|
+
return {
|
|
717
|
+
org,
|
|
718
|
+
kind: "oauth",
|
|
719
|
+
accountId: credential.accountId,
|
|
720
|
+
expiresAt: credential.expiresAt,
|
|
721
|
+
scope: credential.scope,
|
|
722
|
+
flowUsed
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
async function logout(opts = {}) {
|
|
726
|
+
if (opts.all) {
|
|
727
|
+
const orgs = await listOrgsWithStoredPat();
|
|
728
|
+
const removed = [];
|
|
729
|
+
for (const o of orgs) {
|
|
730
|
+
const cred2 = await getStoredCredential(o);
|
|
731
|
+
const ok2 = await deletePat(o);
|
|
732
|
+
if (ok2 && cred2 !== null) {
|
|
733
|
+
removed.push({ org: o, kind: cred2.kind });
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return { removed };
|
|
737
|
+
}
|
|
738
|
+
if (!opts.org) {
|
|
739
|
+
throw new Error("logout requires an org or --all");
|
|
740
|
+
}
|
|
741
|
+
const cred = await getStoredCredential(opts.org);
|
|
742
|
+
const ok = await deletePat(opts.org);
|
|
743
|
+
return { removed: ok && cred !== null ? [{ org: opts.org, kind: cred.kind }] : [] };
|
|
744
|
+
}
|
|
745
|
+
async function status() {
|
|
746
|
+
const orgs = await listOrgsWithStoredPat();
|
|
747
|
+
const out = [];
|
|
748
|
+
for (const org of orgs) {
|
|
749
|
+
const cred = await getStoredCredential(org);
|
|
750
|
+
if (cred === null) continue;
|
|
751
|
+
if (cred.kind === "pat") {
|
|
752
|
+
out.push({ org, kind: "pat", backend: probeBackend() });
|
|
753
|
+
} else {
|
|
754
|
+
out.push({
|
|
755
|
+
org,
|
|
756
|
+
kind: "oauth",
|
|
757
|
+
accountId: cred.accountId,
|
|
758
|
+
expiresAt: cred.expiresAt,
|
|
759
|
+
scope: cred.scope,
|
|
760
|
+
backend: probeBackend()
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return { orgs: out };
|
|
427
765
|
}
|
|
428
766
|
|
|
429
767
|
// src/services/git-remote.ts
|
|
430
768
|
import { execSync } from "child_process";
|
|
769
|
+
|
|
770
|
+
// src/services/remote-warning.ts
|
|
771
|
+
var WARNING = "azdo: warning: origin includes embedded credentials; consider removing them with 'git remote set-url origin <clean-url>'\n";
|
|
772
|
+
var warned = false;
|
|
773
|
+
function noticeCredentialBearingRemote() {
|
|
774
|
+
if (warned) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
warned = true;
|
|
778
|
+
try {
|
|
779
|
+
process.stderr.write(WARNING);
|
|
780
|
+
} catch {
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/services/git-remote.ts
|
|
431
785
|
var patterns = [
|
|
432
|
-
// HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
|
|
433
|
-
/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]
|
|
434
|
-
// HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
|
|
435
|
-
/^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]
|
|
436
|
-
// HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
|
|
437
|
-
/^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]
|
|
438
|
-
// SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
|
|
439
|
-
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]
|
|
440
|
-
// SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
|
|
441
|
-
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]
|
|
786
|
+
// HTTPS (current): https://[user[:token]@]dev.azure.com/{org}/{project}/_git/{repo}[.git]
|
|
787
|
+
/^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
|
|
788
|
+
// HTTPS (legacy + DefaultCollection): https://[user[:token]@]{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}[.git]
|
|
789
|
+
/^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
|
|
790
|
+
// HTTPS (legacy): https://[user[:token]@]{org}.visualstudio.com/{project}/_git/{repo}[.git]
|
|
791
|
+
/^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
|
|
792
|
+
// SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}[.git]
|
|
793
|
+
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/,
|
|
794
|
+
// SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}[.git]
|
|
795
|
+
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/
|
|
442
796
|
];
|
|
797
|
+
var httpsUserinfo = /^https?:\/\/[^@/]+@/;
|
|
443
798
|
function parseAzdoRemote(url) {
|
|
444
799
|
for (const pattern of patterns) {
|
|
445
800
|
const match = pattern.exec(url);
|
|
446
801
|
if (match) {
|
|
802
|
+
if (httpsUserinfo.test(url)) {
|
|
803
|
+
noticeCredentialBearingRemote();
|
|
804
|
+
}
|
|
447
805
|
const project = match[2];
|
|
448
806
|
if (/^DefaultCollection$/i.test(project)) {
|
|
449
807
|
return { org: match[1], project: "" };
|
|
@@ -470,6 +828,9 @@ function parseRepoName(url) {
|
|
|
470
828
|
for (const pattern of patterns) {
|
|
471
829
|
const match = pattern.exec(url);
|
|
472
830
|
if (match) {
|
|
831
|
+
if (httpsUserinfo.test(url)) {
|
|
832
|
+
noticeCredentialBearingRemote();
|
|
833
|
+
}
|
|
473
834
|
return match[3];
|
|
474
835
|
}
|
|
475
836
|
}
|
|
@@ -496,122 +857,57 @@ function getCurrentBranch() {
|
|
|
496
857
|
return branch;
|
|
497
858
|
}
|
|
498
859
|
|
|
499
|
-
// src/services/
|
|
500
|
-
|
|
501
|
-
import path from "path";
|
|
502
|
-
import os from "os";
|
|
503
|
-
var SETTINGS = [
|
|
504
|
-
{
|
|
505
|
-
key: "org",
|
|
506
|
-
description: "Azure DevOps organization name",
|
|
507
|
-
type: "string",
|
|
508
|
-
example: "mycompany",
|
|
509
|
-
required: true
|
|
510
|
-
},
|
|
511
|
-
{
|
|
512
|
-
key: "project",
|
|
513
|
-
description: "Azure DevOps project name",
|
|
514
|
-
type: "string",
|
|
515
|
-
example: "MyProject",
|
|
516
|
-
required: true
|
|
517
|
-
},
|
|
518
|
-
{
|
|
519
|
-
key: "fields",
|
|
520
|
-
description: "Extra work item fields to include (comma-separated reference names)",
|
|
521
|
-
type: "string[]",
|
|
522
|
-
example: "System.Tags,Custom.Priority",
|
|
523
|
-
required: false
|
|
524
|
-
},
|
|
525
|
-
{
|
|
526
|
-
key: "markdown",
|
|
527
|
-
description: "Convert rich text fields to markdown on display",
|
|
528
|
-
type: "boolean",
|
|
529
|
-
example: "true",
|
|
530
|
-
required: false
|
|
531
|
-
}
|
|
532
|
-
];
|
|
533
|
-
var VALID_KEYS = SETTINGS.map((s) => s.key);
|
|
534
|
-
function getConfigPath() {
|
|
535
|
-
return path.join(os.homedir(), ".azdo", "config.json");
|
|
536
|
-
}
|
|
537
|
-
function loadConfig() {
|
|
538
|
-
const configPath = getConfigPath();
|
|
539
|
-
let raw;
|
|
540
|
-
try {
|
|
541
|
-
raw = fs.readFileSync(configPath, "utf-8");
|
|
542
|
-
} catch (err) {
|
|
543
|
-
if (err.code === "ENOENT") {
|
|
544
|
-
return {};
|
|
545
|
-
}
|
|
546
|
-
throw err;
|
|
547
|
-
}
|
|
860
|
+
// src/services/org-resolver.ts
|
|
861
|
+
function defaultDetectFromGit() {
|
|
548
862
|
try {
|
|
549
|
-
return
|
|
863
|
+
return detectAzdoContext().org ?? null;
|
|
550
864
|
} catch {
|
|
551
|
-
|
|
552
|
-
`);
|
|
553
|
-
return {};
|
|
865
|
+
return null;
|
|
554
866
|
}
|
|
555
867
|
}
|
|
556
|
-
function
|
|
557
|
-
|
|
558
|
-
const dir = path.dirname(configPath);
|
|
559
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
560
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
868
|
+
function defaultReadConfig() {
|
|
869
|
+
return loadConfig();
|
|
561
870
|
}
|
|
562
|
-
function
|
|
563
|
-
if (
|
|
564
|
-
|
|
871
|
+
function resolveOrg(options) {
|
|
872
|
+
if (options.org && options.org.length > 0) {
|
|
873
|
+
return { org: options.org, source: "flag" };
|
|
565
874
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
validateKey(key);
|
|
574
|
-
const config = loadConfig();
|
|
575
|
-
if (value === "") {
|
|
576
|
-
delete config[key];
|
|
577
|
-
} else if (key === "markdown") {
|
|
578
|
-
if (value !== "true" && value !== "false") {
|
|
579
|
-
throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
|
|
580
|
-
}
|
|
581
|
-
config.markdown = value === "true";
|
|
582
|
-
} else if (key === "fields") {
|
|
583
|
-
config.fields = value.split(",").map((s) => s.trim());
|
|
584
|
-
} else {
|
|
585
|
-
config[key] = value;
|
|
875
|
+
const gitOrg = (options.detectFromGit ?? defaultDetectFromGit)();
|
|
876
|
+
if (gitOrg && gitOrg.length > 0) {
|
|
877
|
+
return { org: gitOrg, source: "git" };
|
|
878
|
+
}
|
|
879
|
+
const configOrg = (options.readConfig ?? defaultReadConfig)().org;
|
|
880
|
+
if (configOrg && configOrg.length > 0) {
|
|
881
|
+
return { org: configOrg, source: "config" };
|
|
586
882
|
}
|
|
587
|
-
|
|
883
|
+
return null;
|
|
588
884
|
}
|
|
589
|
-
function
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
885
|
+
function formatResolutionError() {
|
|
886
|
+
return [
|
|
887
|
+
"Could not resolve an Azure DevOps organization. Options (in priority order):",
|
|
888
|
+
" 1. Pass --org <name> on the command line.",
|
|
889
|
+
" 2. Run this command from a git repo whose origin remote is an Azure DevOps URL.",
|
|
890
|
+
" 3. Run `azdo config set org <name>` once to set a persistent default."
|
|
891
|
+
].join("\n");
|
|
594
892
|
}
|
|
595
893
|
|
|
596
894
|
// src/services/context.ts
|
|
597
895
|
function resolveContext(options) {
|
|
598
|
-
|
|
599
|
-
return { org: options.org, project: options.project };
|
|
600
|
-
}
|
|
601
|
-
const config = loadConfig();
|
|
602
|
-
if (config.org && config.project) {
|
|
603
|
-
return { org: config.org, project: config.project };
|
|
604
|
-
}
|
|
896
|
+
const resolvedOrg = resolveOrg({ org: options.org });
|
|
605
897
|
let gitContext = null;
|
|
606
898
|
try {
|
|
607
899
|
gitContext = detectAzdoContext();
|
|
608
900
|
} catch {
|
|
609
901
|
}
|
|
610
|
-
const
|
|
611
|
-
const
|
|
902
|
+
const config = loadConfig();
|
|
903
|
+
const org = resolvedOrg?.org;
|
|
904
|
+
const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || config.project;
|
|
612
905
|
if (org && project) {
|
|
613
906
|
return { org, project };
|
|
614
907
|
}
|
|
908
|
+
if (!org) {
|
|
909
|
+
throw new Error(formatResolutionError());
|
|
910
|
+
}
|
|
615
911
|
throw new Error(
|
|
616
912
|
'Could not determine org/project. Use --org and --project flags, work from an Azure DevOps git repo, or run "azdo config set org/project".'
|
|
617
913
|
);
|
|
@@ -771,32 +1067,81 @@ function stripHtml(html) {
|
|
|
771
1067
|
text = text.replaceAll(/<br\s*\/?>/gi, "\n");
|
|
772
1068
|
text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
|
|
773
1069
|
text = text.replaceAll(/<li>/gi, "\n");
|
|
774
|
-
text = text
|
|
775
|
-
text = text.replaceAll("&", "&");
|
|
1070
|
+
text = removeHtmlTags(text);
|
|
776
1071
|
text = text.replaceAll("<", "<");
|
|
777
1072
|
text = text.replaceAll(">", ">");
|
|
778
1073
|
text = text.replaceAll(""", '"');
|
|
779
1074
|
text = text.replaceAll("'", "'");
|
|
780
1075
|
text = text.replaceAll(" ", " ");
|
|
1076
|
+
text = text.replaceAll("&", "&");
|
|
781
1077
|
text = text.replaceAll(/\n{3,}/g, "\n\n");
|
|
782
1078
|
return text.trim();
|
|
783
1079
|
}
|
|
1080
|
+
function removeHtmlTags(value) {
|
|
1081
|
+
let result = "";
|
|
1082
|
+
let insideTag = false;
|
|
1083
|
+
for (const char of value) {
|
|
1084
|
+
if (char === "<") {
|
|
1085
|
+
insideTag = true;
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (char === ">" && insideTag) {
|
|
1089
|
+
insideTag = false;
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
if (!insideTag) {
|
|
1093
|
+
result += char;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return result;
|
|
1097
|
+
}
|
|
784
1098
|
function convertRichText(html, markdown) {
|
|
785
1099
|
if (!html) return "";
|
|
786
1100
|
return markdown ? toMarkdown(html) : stripHtml(html);
|
|
787
1101
|
}
|
|
1102
|
+
function formatMarkdownField(fieldLabel, value) {
|
|
1103
|
+
if (value.includes("\n")) {
|
|
1104
|
+
return `${fieldLabel}:
|
|
1105
|
+
${value}`;
|
|
1106
|
+
}
|
|
1107
|
+
return `${fieldLabel}: ${value}`;
|
|
1108
|
+
}
|
|
788
1109
|
function formatExtraFields(extraFields, markdown) {
|
|
789
1110
|
return Object.entries(extraFields).map(([refName, value]) => {
|
|
790
1111
|
const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
|
|
791
|
-
|
|
792
|
-
|
|
1112
|
+
if (markdown) {
|
|
1113
|
+
const displayValue = toMarkdown(value);
|
|
1114
|
+
return formatMarkdownField(fieldLabel, displayValue);
|
|
1115
|
+
}
|
|
1116
|
+
return formatMarkdownField(fieldLabel, value);
|
|
793
1117
|
});
|
|
794
1118
|
}
|
|
795
|
-
function summarizeDescription(text, label) {
|
|
1119
|
+
function summarizeDescription(text, label, markdown) {
|
|
796
1120
|
const descLines = text.split("\n").filter((l) => l.trim() !== "");
|
|
797
1121
|
const firstThree = descLines.slice(0, 3);
|
|
798
1122
|
const suffix = descLines.length > 3 ? "\n..." : "";
|
|
799
|
-
|
|
1123
|
+
const content = `${firstThree.join("\n")}${suffix}`;
|
|
1124
|
+
if (markdown) {
|
|
1125
|
+
return [formatMarkdownField("Description", content)];
|
|
1126
|
+
}
|
|
1127
|
+
return [`${label("Description:")}${content}`];
|
|
1128
|
+
}
|
|
1129
|
+
function formatFileSize(bytes) {
|
|
1130
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1131
|
+
const kb = bytes / 1024;
|
|
1132
|
+
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
|
1133
|
+
const mb = kb / 1024;
|
|
1134
|
+
return `${mb.toFixed(1)} MB`;
|
|
1135
|
+
}
|
|
1136
|
+
function formatAttachments(attachments, short) {
|
|
1137
|
+
if (short) {
|
|
1138
|
+
return [`Attachments: ${attachments.length}`];
|
|
1139
|
+
}
|
|
1140
|
+
const lines = ["", "Attachments:"];
|
|
1141
|
+
for (const att of attachments) {
|
|
1142
|
+
lines.push(` ${att.name} (${formatFileSize(att.size)})`);
|
|
1143
|
+
}
|
|
1144
|
+
return lines;
|
|
800
1145
|
}
|
|
801
1146
|
function formatWorkItem(workItem, short, markdown = false) {
|
|
802
1147
|
const label = (name) => name.padEnd(13);
|
|
@@ -820,10 +1165,13 @@ function formatWorkItem(workItem, short, markdown = false) {
|
|
|
820
1165
|
lines.push("");
|
|
821
1166
|
const descriptionText = convertRichText(workItem.description, markdown);
|
|
822
1167
|
if (short) {
|
|
823
|
-
lines.push(...summarizeDescription(descriptionText, label));
|
|
1168
|
+
lines.push(...summarizeDescription(descriptionText, label, markdown));
|
|
824
1169
|
} else {
|
|
825
1170
|
lines.push("Description:", descriptionText);
|
|
826
1171
|
}
|
|
1172
|
+
if (workItem.attachments) {
|
|
1173
|
+
lines.push(...formatAttachments(workItem.attachments, short));
|
|
1174
|
+
}
|
|
827
1175
|
return lines.join("\n");
|
|
828
1176
|
}
|
|
829
1177
|
function createGetItemCommand() {
|
|
@@ -835,9 +1183,9 @@ function createGetItemCommand() {
|
|
|
835
1183
|
let context;
|
|
836
1184
|
try {
|
|
837
1185
|
context = resolveContext(options);
|
|
838
|
-
const credential = await
|
|
1186
|
+
const credential = await requireAuthCredential(context.org);
|
|
839
1187
|
const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
|
|
840
|
-
const workItem = await getWorkItem(context, id, credential
|
|
1188
|
+
const workItem = await getWorkItem(context, id, credential, fieldsList);
|
|
841
1189
|
const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
|
|
842
1190
|
const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
|
|
843
1191
|
process.stdout.write(output + "\n");
|
|
@@ -853,19 +1201,455 @@ function createGetItemCommand() {
|
|
|
853
1201
|
import { Command as Command2 } from "commander";
|
|
854
1202
|
function createClearPatCommand() {
|
|
855
1203
|
const command = new Command2("clear-pat");
|
|
856
|
-
command.description("Remove
|
|
857
|
-
|
|
1204
|
+
command.description("Remove a stored Azure DevOps PAT (deprecated: use `azdo auth logout`)").option("--org <name>", "Azure DevOps organization (overrides auto-detect / config)").action(async (options) => {
|
|
1205
|
+
process.stderr.write("`azdo clear-pat` is deprecated; use `azdo auth logout [--org <name>]` instead.\n");
|
|
1206
|
+
const resolved = resolveOrg({ org: options.org });
|
|
1207
|
+
if (!resolved) {
|
|
1208
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1209
|
+
`);
|
|
1210
|
+
process.exitCode = 3;
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const deleted = await deletePat(resolved.org);
|
|
858
1214
|
if (deleted) {
|
|
859
|
-
process.stdout.write(
|
|
1215
|
+
process.stdout.write(`PAT removed for org ${resolved.org}.
|
|
1216
|
+
`);
|
|
860
1217
|
} else {
|
|
861
|
-
process.stdout.write(
|
|
1218
|
+
process.stdout.write(`No stored PAT found for org ${resolved.org}.
|
|
1219
|
+
`);
|
|
862
1220
|
}
|
|
863
1221
|
});
|
|
864
1222
|
return command;
|
|
865
1223
|
}
|
|
866
1224
|
|
|
867
|
-
// src/commands/
|
|
1225
|
+
// src/commands/auth.ts
|
|
868
1226
|
import { Command as Command3 } from "commander";
|
|
1227
|
+
async function readStdinToString() {
|
|
1228
|
+
const chunks = [];
|
|
1229
|
+
for await (const chunk of process.stdin) {
|
|
1230
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1231
|
+
}
|
|
1232
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1233
|
+
}
|
|
1234
|
+
async function promptYesNo(prompt) {
|
|
1235
|
+
if (!process.stdin.isTTY) return true;
|
|
1236
|
+
process.stderr.write(prompt);
|
|
1237
|
+
return await new Promise((resolve2) => {
|
|
1238
|
+
process.stdin.setEncoding("utf8");
|
|
1239
|
+
let answered = false;
|
|
1240
|
+
const handler = (data) => {
|
|
1241
|
+
if (answered) return;
|
|
1242
|
+
answered = true;
|
|
1243
|
+
process.stdin.removeListener("data", handler);
|
|
1244
|
+
process.stdin.pause();
|
|
1245
|
+
const trimmed = data.trim().toLowerCase();
|
|
1246
|
+
resolve2(trimmed === "y" || trimmed === "yes");
|
|
1247
|
+
};
|
|
1248
|
+
process.stdin.resume();
|
|
1249
|
+
process.stdin.on("data", handler);
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
async function confirmOverwrite(org) {
|
|
1253
|
+
return promptYesNo(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
|
|
1254
|
+
}
|
|
1255
|
+
async function confirmOverwriteCredential(org, existingKind) {
|
|
1256
|
+
const label = existingKind === "oauth" ? "OAuth credential" : "PAT";
|
|
1257
|
+
return promptYesNo(`A ${label} is already stored for org ${org}. The new login will replace it. Continue? [y/N] `);
|
|
1258
|
+
}
|
|
1259
|
+
function rejectMutuallyExclusive(opts) {
|
|
1260
|
+
if (opts.usePat && opts.deviceCode) {
|
|
1261
|
+
return "--use-pat and --device-code are mutually exclusive (PAT has no device-code flow).";
|
|
1262
|
+
}
|
|
1263
|
+
if (opts.usePat && (opts.clientId || opts.tenantId || opts.scopes)) {
|
|
1264
|
+
return "--use-pat cannot be combined with OAuth-only flags (--client-id / --tenant-id / --scopes).";
|
|
1265
|
+
}
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
async function handlePatLogin(options) {
|
|
1269
|
+
const resolved = resolveOrg({ org: options.org });
|
|
1270
|
+
if (!resolved) {
|
|
1271
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1272
|
+
`);
|
|
1273
|
+
process.exitCode = 3;
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const org = resolved.org;
|
|
1277
|
+
const wantBrowser = options.browser !== false && !options.fromStdin;
|
|
1278
|
+
if (wantBrowser) {
|
|
1279
|
+
const url = `https://dev.azure.com/${encodeURIComponent(org)}/_usersSettings/tokens`;
|
|
1280
|
+
await openUrl(url);
|
|
1281
|
+
}
|
|
1282
|
+
const raw = options.fromStdin ? await readStdinToString() : await promptForPat();
|
|
1283
|
+
const pat = raw ? normalizePat(raw) : null;
|
|
1284
|
+
if (!pat) {
|
|
1285
|
+
process.stderr.write("No PAT provided. Aborting.\n");
|
|
1286
|
+
process.exitCode = 1;
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
let validation;
|
|
1290
|
+
try {
|
|
1291
|
+
validation = await validatePatAgainstAzdo(pat, org);
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
process.stderr.write(`Could not reach Azure DevOps to validate PAT: ${err.message}
|
|
1294
|
+
`);
|
|
1295
|
+
process.exitCode = 1;
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (!validation.ok) {
|
|
1299
|
+
appendAuthAuditEvent({ event: "auth.validate.fail", org, backend: probeBackend() });
|
|
1300
|
+
process.stderr.write(`PAT validation failed (HTTP ${validation.status}). Token NOT stored.
|
|
1301
|
+
`);
|
|
1302
|
+
process.exitCode = 2;
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
appendAuthAuditEvent({
|
|
1306
|
+
event: "auth.validate.ok",
|
|
1307
|
+
org,
|
|
1308
|
+
backend: probeBackend(),
|
|
1309
|
+
masked_pat: maskedDisplay(pat)
|
|
1310
|
+
});
|
|
1311
|
+
try {
|
|
1312
|
+
const existing = await getPat(org);
|
|
1313
|
+
if (existing !== null) {
|
|
1314
|
+
const overwrite = await confirmOverwrite(org);
|
|
1315
|
+
if (!overwrite) {
|
|
1316
|
+
process.stderr.write("Aborted. Existing PAT preserved.\n");
|
|
1317
|
+
process.exitCode = 1;
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
await storePat(org, pat);
|
|
1322
|
+
} catch (err) {
|
|
1323
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1324
|
+
process.stderr.write(`${err.message}
|
|
1325
|
+
`);
|
|
1326
|
+
process.exitCode = 4;
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
throw err;
|
|
1330
|
+
}
|
|
1331
|
+
process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
|
|
1332
|
+
`);
|
|
1333
|
+
}
|
|
1334
|
+
async function ensureOverwriteConfirmed(org) {
|
|
1335
|
+
try {
|
|
1336
|
+
const existing = await getStoredCredential(org);
|
|
1337
|
+
if (existing === null || !process.stdin.isTTY) return "ok";
|
|
1338
|
+
const ok = await confirmOverwriteCredential(org, existing.kind);
|
|
1339
|
+
return ok ? "ok" : "aborted";
|
|
1340
|
+
} catch (err) {
|
|
1341
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1342
|
+
process.stderr.write(`${err.message}
|
|
1343
|
+
`);
|
|
1344
|
+
return "unavailable";
|
|
1345
|
+
}
|
|
1346
|
+
throw err;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
function buildOAuthLoginOptions(options) {
|
|
1350
|
+
return {
|
|
1351
|
+
flow: options.deviceCode ? "device-code" : "auto",
|
|
1352
|
+
clientIdOverride: options.clientId,
|
|
1353
|
+
tenantIdOverride: options.tenantId,
|
|
1354
|
+
scopesOverride: options.scopes ? options.scopes.split(/\s+/).filter(Boolean) : void 0
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
function reportOAuthFailure(err) {
|
|
1358
|
+
const reason = typeof err === "object" && err !== null && "reason" in err ? err.reason : null;
|
|
1359
|
+
const msg = err.message;
|
|
1360
|
+
process.stderr.write(reason ? `OAuth login failed (${reason}): ${msg}
|
|
1361
|
+
` : `OAuth login failed: ${msg}
|
|
1362
|
+
`);
|
|
1363
|
+
const noDisplay = process.platform === "linux" && (!process.env.DISPLAY || process.env.DISPLAY.length === 0);
|
|
1364
|
+
if (noDisplay) {
|
|
1365
|
+
process.stderr.write("Tip: this host has no DISPLAY; pass --device-code to use the headless flow.\n");
|
|
1366
|
+
} else if (reason === "port-conflict") {
|
|
1367
|
+
process.stderr.write("Tip: another process is using the loopback callback port. Try again or pass --device-code.\n");
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
async function handleOAuthLogin(options) {
|
|
1371
|
+
const resolved = resolveOrg({ org: options.org });
|
|
1372
|
+
if (!resolved) {
|
|
1373
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1374
|
+
`);
|
|
1375
|
+
process.exitCode = 3;
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
const org = resolved.org;
|
|
1379
|
+
const confirm = await ensureOverwriteConfirmed(org);
|
|
1380
|
+
if (confirm === "aborted") {
|
|
1381
|
+
process.stderr.write("Aborted. Existing credential preserved.\n");
|
|
1382
|
+
process.exitCode = 1;
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
if (confirm === "unavailable") {
|
|
1386
|
+
process.exitCode = 4;
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
try {
|
|
1390
|
+
const result = await loginWithOAuth(org, buildOAuthLoginOptions(options));
|
|
1391
|
+
process.stdout.write(
|
|
1392
|
+
`Logged in to ${org} via OAuth (${result.flowUsed}). Account: ${result.accountId}; expires ${new Date(result.expiresAt * 1e3).toISOString()}.
|
|
1393
|
+
`
|
|
1394
|
+
);
|
|
1395
|
+
} catch (err) {
|
|
1396
|
+
reportOAuthFailure(err);
|
|
1397
|
+
process.exitCode = 1;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
async function handleAuthRoot(options) {
|
|
1401
|
+
const conflict = rejectMutuallyExclusive(options);
|
|
1402
|
+
if (conflict) {
|
|
1403
|
+
process.stderr.write(`${conflict}
|
|
1404
|
+
`);
|
|
1405
|
+
process.exitCode = 2;
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
await handlePatLogin(options);
|
|
1409
|
+
}
|
|
1410
|
+
async function handleLoginSubcommand(options) {
|
|
1411
|
+
const conflict = rejectMutuallyExclusive(options);
|
|
1412
|
+
if (conflict) {
|
|
1413
|
+
process.stderr.write(`${conflict}
|
|
1414
|
+
`);
|
|
1415
|
+
process.exitCode = 2;
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
if (options.fromStdin || options.usePat) {
|
|
1419
|
+
await handlePatLogin(options);
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
await handleOAuthLogin(options);
|
|
1423
|
+
}
|
|
1424
|
+
async function handleStatusJson() {
|
|
1425
|
+
try {
|
|
1426
|
+
const report = await status();
|
|
1427
|
+
process.stdout.write(`${JSON.stringify(report)}
|
|
1428
|
+
`);
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1431
|
+
process.stderr.write(`${err.message}
|
|
1432
|
+
`);
|
|
1433
|
+
process.exitCode = 4;
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
throw err;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
async function handleStatus(options, org) {
|
|
1440
|
+
if (options.json) {
|
|
1441
|
+
let backend2;
|
|
1442
|
+
let value2;
|
|
1443
|
+
try {
|
|
1444
|
+
backend2 = probeBackend();
|
|
1445
|
+
value2 = await getPat(org);
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1448
|
+
process.stderr.write(`${err.message}
|
|
1449
|
+
`);
|
|
1450
|
+
process.exitCode = 4;
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
throw err;
|
|
1454
|
+
}
|
|
1455
|
+
const storedEvents2 = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
|
|
1456
|
+
const last2 = storedEvents2.at(-1);
|
|
1457
|
+
const updatedAt2 = last2?.ts ?? null;
|
|
1458
|
+
if (!value2) {
|
|
1459
|
+
process.stdout.write(
|
|
1460
|
+
`${JSON.stringify({ org, backend: backend2, stored: false, masked: null, updated_at: updatedAt2 })}
|
|
1461
|
+
`
|
|
1462
|
+
);
|
|
1463
|
+
process.exitCode = 1;
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
const masked2 = maskedDisplay(value2);
|
|
1467
|
+
process.stdout.write(
|
|
1468
|
+
`${JSON.stringify({ org, backend: backend2, stored: true, masked: masked2, updated_at: updatedAt2 })}
|
|
1469
|
+
`
|
|
1470
|
+
);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
let backend;
|
|
1474
|
+
let value;
|
|
1475
|
+
try {
|
|
1476
|
+
backend = probeBackend();
|
|
1477
|
+
value = await getPat(org);
|
|
1478
|
+
} catch (err) {
|
|
1479
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1480
|
+
process.stderr.write(`${err.message}
|
|
1481
|
+
`);
|
|
1482
|
+
process.exitCode = 4;
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
throw err;
|
|
1486
|
+
}
|
|
1487
|
+
const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
|
|
1488
|
+
const last = storedEvents.at(-1);
|
|
1489
|
+
const updatedAt = last?.ts ?? null;
|
|
1490
|
+
if (!value) {
|
|
1491
|
+
process.stdout.write(`Organization: ${org}
|
|
1492
|
+
Backend: ${backend}
|
|
1493
|
+
Stored: no
|
|
1494
|
+
`);
|
|
1495
|
+
process.exitCode = 1;
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const masked = maskedDisplay(value);
|
|
1499
|
+
process.stdout.write(
|
|
1500
|
+
`Organization: ${org}
|
|
1501
|
+
Backend: ${backend}
|
|
1502
|
+
Stored: yes
|
|
1503
|
+
Identifier: ${masked}
|
|
1504
|
+
` + (updatedAt ? `Last updated: ${updatedAt}
|
|
1505
|
+
` : "")
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
async function handleLogout(options, orgFromGlobal) {
|
|
1509
|
+
if (options.all && orgFromGlobal) {
|
|
1510
|
+
process.stderr.write("--org and --all are mutually exclusive.\n");
|
|
1511
|
+
process.exitCode = 1;
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
if (options.all) {
|
|
1515
|
+
try {
|
|
1516
|
+
const result = await logout({ all: true });
|
|
1517
|
+
if (result.removed.length === 0) {
|
|
1518
|
+
process.stdout.write("No stored credentials to remove.\n");
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
for (const r of result.removed) {
|
|
1522
|
+
process.stdout.write(`Removed ${r.kind} credential for org ${r.org}.
|
|
1523
|
+
`);
|
|
1524
|
+
}
|
|
1525
|
+
} catch (err) {
|
|
1526
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1527
|
+
process.stderr.write(`${err.message}
|
|
1528
|
+
`);
|
|
1529
|
+
process.exitCode = 4;
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
process.stderr.write(`Failed to remove credentials: ${err.message}
|
|
1533
|
+
`);
|
|
1534
|
+
process.exitCode = 1;
|
|
1535
|
+
}
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
const resolved = resolveOrg({ org: orgFromGlobal });
|
|
1539
|
+
if (!resolved) {
|
|
1540
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1541
|
+
`);
|
|
1542
|
+
process.exitCode = 3;
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
try {
|
|
1546
|
+
const removed = await deletePat(resolved.org);
|
|
1547
|
+
if (removed) {
|
|
1548
|
+
process.stdout.write(`Credential removed for org ${resolved.org}.
|
|
1549
|
+
`);
|
|
1550
|
+
} else {
|
|
1551
|
+
process.stdout.write(`No stored credential for org ${resolved.org}.
|
|
1552
|
+
`);
|
|
1553
|
+
}
|
|
1554
|
+
} catch (err) {
|
|
1555
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1556
|
+
process.stderr.write(`${err.message}
|
|
1557
|
+
`);
|
|
1558
|
+
process.exitCode = 4;
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
throw err;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
function createAuthCommand() {
|
|
1565
|
+
const command = new Command3("auth");
|
|
1566
|
+
command.description(
|
|
1567
|
+
"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."
|
|
1568
|
+
);
|
|
1569
|
+
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)");
|
|
1570
|
+
command.addHelpText(
|
|
1571
|
+
"after",
|
|
1572
|
+
`
|
|
1573
|
+
Default flow (OAuth, browser-based):
|
|
1574
|
+
azdo auth login --org <name>
|
|
1575
|
+
\u2192 opens the default browser for OAuth (Microsoft Entra v2 + PKCE).
|
|
1576
|
+
|
|
1577
|
+
Headless / no-browser:
|
|
1578
|
+
azdo auth login --org <name> --device-code
|
|
1579
|
+
|
|
1580
|
+
PAT path (legacy, opt-in):
|
|
1581
|
+
azdo auth login --org <name> --use-pat
|
|
1582
|
+
azdo auth --org <name> # back-compat alias of the above
|
|
1583
|
+
|
|
1584
|
+
OAuth scope set \u2014 shipped first-party client (default install):
|
|
1585
|
+
${firstPartyShippedScopes().join("\n ")}
|
|
1586
|
+
(uses ${AZDO_RESOURCE_ID}/.default \u2014 per-scope consent is unavailable
|
|
1587
|
+
against a client we do not own; .default grants the VS client's
|
|
1588
|
+
pre-authorized AzDO permissions in one step.)
|
|
1589
|
+
|
|
1590
|
+
OAuth scope set \u2014 self-registered apps (--client-id / AZDO_OAUTH_CLIENT_ID):
|
|
1591
|
+
${defaultScopes().join("\n ")}
|
|
1592
|
+
(FR-016, mirrors the PAT scope table \u2014 see docs/oauth-app-registration.md)
|
|
1593
|
+
|
|
1594
|
+
For self-registered OAuth apps (locked-down tenants), see docs/oauth-app-registration.md
|
|
1595
|
+
\u2014 that same guide is the maintainer reference for the project's shared client id.
|
|
1596
|
+
|
|
1597
|
+
Note: stored credentials may coexist as 'pat' or 'oauth' across orgs (FR-007).
|
|
1598
|
+
Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
|
|
1599
|
+
\`azdo auth login\` is the spec-canonical name and defaults to OAuth.`
|
|
1600
|
+
).action(async (options) => {
|
|
1601
|
+
await handleAuthRoot(options);
|
|
1602
|
+
});
|
|
1603
|
+
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)");
|
|
1604
|
+
loginCmd.action(async () => {
|
|
1605
|
+
const merged = loginCmd.optsWithGlobals();
|
|
1606
|
+
await handleLoginSubcommand(merged);
|
|
1607
|
+
});
|
|
1608
|
+
const statusCmd = command.command("status").description("Report stored credentials (kind, org, account/expiry, backend) \u2014 never the token").option("--json", "emit JSON", false);
|
|
1609
|
+
statusCmd.action(async (options) => {
|
|
1610
|
+
const globals = statusCmd.optsWithGlobals();
|
|
1611
|
+
if (!globals.org) {
|
|
1612
|
+
if (options.json) {
|
|
1613
|
+
await handleStatusJson();
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const report = await status();
|
|
1617
|
+
if (report.orgs.length === 0) {
|
|
1618
|
+
process.stdout.write("No stored credentials.\n");
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
for (const e of report.orgs) {
|
|
1622
|
+
const expiry = e.expiresAt ? new Date(e.expiresAt * 1e3).toISOString() : "n/a";
|
|
1623
|
+
process.stdout.write(
|
|
1624
|
+
`${e.org} ${e.kind} ${e.accountId ?? ""} ${expiry}
|
|
1625
|
+
`
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const resolved = resolveOrg({ org: globals.org });
|
|
1631
|
+
if (!resolved) {
|
|
1632
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1633
|
+
`);
|
|
1634
|
+
process.exitCode = 3;
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
await handleStatus(options, resolved.org);
|
|
1638
|
+
});
|
|
1639
|
+
statusCmd.addHelpText(
|
|
1640
|
+
"after",
|
|
1641
|
+
"\nStored credentials may be of kind `pat` or `oauth` and may coexist across orgs (FR-007).\n"
|
|
1642
|
+
);
|
|
1643
|
+
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);
|
|
1644
|
+
logoutCmd.action(async (options) => {
|
|
1645
|
+
const globals = logoutCmd.optsWithGlobals();
|
|
1646
|
+
await handleLogout(options, globals.org);
|
|
1647
|
+
});
|
|
1648
|
+
return command;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// src/commands/config.ts
|
|
1652
|
+
import { Command as Command4 } from "commander";
|
|
869
1653
|
import { createInterface as createInterface2 } from "readline";
|
|
870
1654
|
function formatConfigValue(value, unsetFallback = "") {
|
|
871
1655
|
if (value === void 0) {
|
|
@@ -925,9 +1709,9 @@ async function promptForSetting(cfg, setting, ask) {
|
|
|
925
1709
|
`);
|
|
926
1710
|
}
|
|
927
1711
|
function createConfigCommand() {
|
|
928
|
-
const config = new
|
|
1712
|
+
const config = new Command4("config");
|
|
929
1713
|
config.description("Manage CLI settings");
|
|
930
|
-
const set = new
|
|
1714
|
+
const set = new Command4("set");
|
|
931
1715
|
set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields)").argument("<value>", "setting value").option("--json", "output in JSON format").action((key, value, options) => {
|
|
932
1716
|
try {
|
|
933
1717
|
setConfigValue(key, value);
|
|
@@ -948,7 +1732,7 @@ function createConfigCommand() {
|
|
|
948
1732
|
process.exit(1);
|
|
949
1733
|
}
|
|
950
1734
|
});
|
|
951
|
-
const get = new
|
|
1735
|
+
const get = new Command4("get");
|
|
952
1736
|
get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
953
1737
|
try {
|
|
954
1738
|
const value = getConfigValue(key);
|
|
@@ -971,7 +1755,7 @@ function createConfigCommand() {
|
|
|
971
1755
|
process.exit(1);
|
|
972
1756
|
}
|
|
973
1757
|
});
|
|
974
|
-
const list = new
|
|
1758
|
+
const list = new Command4("list");
|
|
975
1759
|
list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
|
|
976
1760
|
const cfg = loadConfig();
|
|
977
1761
|
if (options.json) {
|
|
@@ -980,7 +1764,7 @@ function createConfigCommand() {
|
|
|
980
1764
|
}
|
|
981
1765
|
writeConfigList(cfg);
|
|
982
1766
|
});
|
|
983
|
-
const unset = new
|
|
1767
|
+
const unset = new Command4("unset");
|
|
984
1768
|
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
985
1769
|
try {
|
|
986
1770
|
unsetConfigValue(key);
|
|
@@ -997,7 +1781,7 @@ function createConfigCommand() {
|
|
|
997
1781
|
process.exit(1);
|
|
998
1782
|
}
|
|
999
1783
|
});
|
|
1000
|
-
const wizard = new
|
|
1784
|
+
const wizard = new Command4("wizard");
|
|
1001
1785
|
wizard.description("Interactive wizard to configure all settings").action(async () => {
|
|
1002
1786
|
if (!process.stdin.isTTY) {
|
|
1003
1787
|
process.stderr.write(
|
|
@@ -1028,9 +1812,9 @@ function createConfigCommand() {
|
|
|
1028
1812
|
}
|
|
1029
1813
|
|
|
1030
1814
|
// src/commands/set-state.ts
|
|
1031
|
-
import { Command as
|
|
1815
|
+
import { Command as Command5 } from "commander";
|
|
1032
1816
|
function createSetStateCommand() {
|
|
1033
|
-
const command = new
|
|
1817
|
+
const command = new Command5("set-state");
|
|
1034
1818
|
command.description("Change the state of a work item").argument("<id>", "work item ID").argument("<state>", 'target state (e.g., "Active", "Closed")').option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
1035
1819
|
async (idStr, state, options) => {
|
|
1036
1820
|
const id = parseWorkItemId(idStr);
|
|
@@ -1038,11 +1822,11 @@ function createSetStateCommand() {
|
|
|
1038
1822
|
let context;
|
|
1039
1823
|
try {
|
|
1040
1824
|
context = resolveContext(options);
|
|
1041
|
-
const credential = await
|
|
1825
|
+
const credential = await requireAuthCredential(context.org);
|
|
1042
1826
|
const operations = [
|
|
1043
1827
|
{ op: "add", path: "/fields/System.State", value: state }
|
|
1044
1828
|
];
|
|
1045
|
-
const result = await updateWorkItem(context, id, credential
|
|
1829
|
+
const result = await updateWorkItem(context, id, credential, "System.State", operations);
|
|
1046
1830
|
if (options.json) {
|
|
1047
1831
|
process.stdout.write(
|
|
1048
1832
|
JSON.stringify({
|
|
@@ -1066,9 +1850,9 @@ function createSetStateCommand() {
|
|
|
1066
1850
|
}
|
|
1067
1851
|
|
|
1068
1852
|
// src/commands/assign.ts
|
|
1069
|
-
import { Command as
|
|
1853
|
+
import { Command as Command6 } from "commander";
|
|
1070
1854
|
function createAssignCommand() {
|
|
1071
|
-
const command = new
|
|
1855
|
+
const command = new Command6("assign");
|
|
1072
1856
|
command.description("Assign a work item to a user, or unassign it").argument("<id>", "work item ID").argument("[name]", "user display name or email").option("--unassign", "clear the Assigned To field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
1073
1857
|
async (idStr, name, options) => {
|
|
1074
1858
|
const id = parseWorkItemId(idStr);
|
|
@@ -1088,12 +1872,12 @@ function createAssignCommand() {
|
|
|
1088
1872
|
let context;
|
|
1089
1873
|
try {
|
|
1090
1874
|
context = resolveContext(options);
|
|
1091
|
-
const credential = await
|
|
1875
|
+
const credential = await requireAuthCredential(context.org);
|
|
1092
1876
|
const value = options.unassign ? "" : name;
|
|
1093
1877
|
const operations = [
|
|
1094
1878
|
{ op: "add", path: "/fields/System.AssignedTo", value }
|
|
1095
1879
|
];
|
|
1096
|
-
const result = await updateWorkItem(context, id, credential
|
|
1880
|
+
const result = await updateWorkItem(context, id, credential, "System.AssignedTo", operations);
|
|
1097
1881
|
if (options.json) {
|
|
1098
1882
|
process.stdout.write(
|
|
1099
1883
|
JSON.stringify({
|
|
@@ -1118,9 +1902,9 @@ function createAssignCommand() {
|
|
|
1118
1902
|
}
|
|
1119
1903
|
|
|
1120
1904
|
// src/commands/set-field.ts
|
|
1121
|
-
import { Command as
|
|
1905
|
+
import { Command as Command7 } from "commander";
|
|
1122
1906
|
function createSetFieldCommand() {
|
|
1123
|
-
const command = new
|
|
1907
|
+
const command = new Command7("set-field");
|
|
1124
1908
|
command.description("Set any work item field by its reference name").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Title)").argument("<value>", "new value for the field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
1125
1909
|
async (idStr, field, value, options) => {
|
|
1126
1910
|
const id = parseWorkItemId(idStr);
|
|
@@ -1128,11 +1912,11 @@ function createSetFieldCommand() {
|
|
|
1128
1912
|
let context;
|
|
1129
1913
|
try {
|
|
1130
1914
|
context = resolveContext(options);
|
|
1131
|
-
const credential = await
|
|
1915
|
+
const credential = await requireAuthCredential(context.org);
|
|
1132
1916
|
const operations = [
|
|
1133
1917
|
{ op: "add", path: `/fields/${field}`, value }
|
|
1134
1918
|
];
|
|
1135
|
-
const result = await updateWorkItem(context, id, credential
|
|
1919
|
+
const result = await updateWorkItem(context, id, credential, field, operations);
|
|
1136
1920
|
if (options.json) {
|
|
1137
1921
|
process.stdout.write(
|
|
1138
1922
|
JSON.stringify({
|
|
@@ -1156,9 +1940,9 @@ function createSetFieldCommand() {
|
|
|
1156
1940
|
}
|
|
1157
1941
|
|
|
1158
1942
|
// src/commands/get-md-field.ts
|
|
1159
|
-
import { Command as
|
|
1943
|
+
import { Command as Command8 } from "commander";
|
|
1160
1944
|
function createGetMdFieldCommand() {
|
|
1161
|
-
const command = new
|
|
1945
|
+
const command = new Command8("get-md-field");
|
|
1162
1946
|
command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
|
|
1163
1947
|
async (idStr, field, options) => {
|
|
1164
1948
|
const id = parseWorkItemId(idStr);
|
|
@@ -1166,8 +1950,8 @@ function createGetMdFieldCommand() {
|
|
|
1166
1950
|
let context;
|
|
1167
1951
|
try {
|
|
1168
1952
|
context = resolveContext(options);
|
|
1169
|
-
const credential = await
|
|
1170
|
-
const value = await getWorkItemFieldValue(context, id, credential
|
|
1953
|
+
const credential = await requireAuthCredential(context.org);
|
|
1954
|
+
const value = await getWorkItemFieldValue(context, id, credential, field);
|
|
1171
1955
|
if (value === null) {
|
|
1172
1956
|
process.stdout.write("\n");
|
|
1173
1957
|
} else {
|
|
@@ -1182,8 +1966,8 @@ function createGetMdFieldCommand() {
|
|
|
1182
1966
|
}
|
|
1183
1967
|
|
|
1184
1968
|
// src/commands/set-md-field.ts
|
|
1185
|
-
import { existsSync, readFileSync as
|
|
1186
|
-
import { Command as
|
|
1969
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
1970
|
+
import { Command as Command9 } from "commander";
|
|
1187
1971
|
function fail(message) {
|
|
1188
1972
|
process.stderr.write(`Error: ${message}
|
|
1189
1973
|
`);
|
|
@@ -1202,11 +1986,11 @@ function resolveContent(inlineContent, options) {
|
|
|
1202
1986
|
return null;
|
|
1203
1987
|
}
|
|
1204
1988
|
function readFileContent(filePath) {
|
|
1205
|
-
if (!
|
|
1989
|
+
if (!existsSync2(filePath)) {
|
|
1206
1990
|
fail(`File not found: ${filePath}`);
|
|
1207
1991
|
}
|
|
1208
1992
|
try {
|
|
1209
|
-
return
|
|
1993
|
+
return readFileSync3(filePath, "utf-8");
|
|
1210
1994
|
} catch {
|
|
1211
1995
|
fail(`Cannot read file: ${filePath}`);
|
|
1212
1996
|
}
|
|
@@ -1245,7 +2029,7 @@ function formatOutput(result, options, field) {
|
|
|
1245
2029
|
}
|
|
1246
2030
|
}
|
|
1247
2031
|
function createSetMdFieldCommand() {
|
|
1248
|
-
const command = new
|
|
2032
|
+
const command = new Command9("set-md-field");
|
|
1249
2033
|
command.description("Set a work item field with markdown content").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").argument("[content]", "markdown content to set").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").option("--file <path>", "read markdown content from file").action(
|
|
1250
2034
|
async (idStr, field, inlineContent, options) => {
|
|
1251
2035
|
const id = parseWorkItemId(idStr);
|
|
@@ -1254,12 +2038,12 @@ function createSetMdFieldCommand() {
|
|
|
1254
2038
|
let context;
|
|
1255
2039
|
try {
|
|
1256
2040
|
context = resolveContext(options);
|
|
1257
|
-
const credential = await
|
|
2041
|
+
const credential = await requireAuthCredential(context.org);
|
|
1258
2042
|
const operations = [
|
|
1259
2043
|
{ op: "add", path: `/fields/${field}`, value: content },
|
|
1260
2044
|
{ op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
|
|
1261
2045
|
];
|
|
1262
|
-
const result = await updateWorkItem(context, id, credential
|
|
2046
|
+
const result = await updateWorkItem(context, id, credential, field, operations);
|
|
1263
2047
|
formatOutput(result, options, field);
|
|
1264
2048
|
} catch (err) {
|
|
1265
2049
|
handleCommandError(err, id, context, "write");
|
|
@@ -1270,8 +2054,8 @@ function createSetMdFieldCommand() {
|
|
|
1270
2054
|
}
|
|
1271
2055
|
|
|
1272
2056
|
// src/commands/upsert.ts
|
|
1273
|
-
import { existsSync as
|
|
1274
|
-
import { Command as
|
|
2057
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
|
|
2058
|
+
import { Command as Command10 } from "commander";
|
|
1275
2059
|
|
|
1276
2060
|
// src/services/task-document.ts
|
|
1277
2061
|
var FIELD_ALIASES = /* @__PURE__ */ new Map([
|
|
@@ -1434,12 +2218,12 @@ function loadSourceContent(options) {
|
|
|
1434
2218
|
return { content: options.content };
|
|
1435
2219
|
}
|
|
1436
2220
|
const filePath = options.file;
|
|
1437
|
-
if (!
|
|
2221
|
+
if (!existsSync3(filePath)) {
|
|
1438
2222
|
fail2(`File not found: ${filePath}`);
|
|
1439
2223
|
}
|
|
1440
2224
|
try {
|
|
1441
2225
|
return {
|
|
1442
|
-
content:
|
|
2226
|
+
content: readFileSync4(filePath, "utf-8"),
|
|
1443
2227
|
sourceFile: filePath
|
|
1444
2228
|
};
|
|
1445
2229
|
} catch {
|
|
@@ -1555,7 +2339,7 @@ function handleUpsertError(err, id, context) {
|
|
|
1555
2339
|
process.exit(1);
|
|
1556
2340
|
}
|
|
1557
2341
|
function createUpsertCommand() {
|
|
1558
|
-
const command = new
|
|
2342
|
+
const command = new Command10("upsert");
|
|
1559
2343
|
command.description("Create or update a work item from a markdown document").argument("[id]", "work item ID to update; omit to create a new work item").option("--content <markdown>", "task document content").option("--file <path>", "read task document from file").option("--type <workItemType>", "create mode work item type (defaults to Task)").option("--json", "output result as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(async (idStr, options) => {
|
|
1560
2344
|
validateOrgProjectPair(options);
|
|
1561
2345
|
const id = idStr === void 0 ? void 0 : parseWorkItemId(idStr);
|
|
@@ -1570,15 +2354,15 @@ function createUpsertCommand() {
|
|
|
1570
2354
|
ensureTitleForCreate(document.fields);
|
|
1571
2355
|
}
|
|
1572
2356
|
const operations = toPatchOperations(document.fields, action);
|
|
1573
|
-
const credential = await
|
|
2357
|
+
const credential = await requireAuthCredential(context.org);
|
|
1574
2358
|
let writeResult;
|
|
1575
2359
|
if (action === "created") {
|
|
1576
|
-
writeResult = await createWorkItem(context, createType, credential
|
|
2360
|
+
writeResult = await createWorkItem(context, createType, credential, operations);
|
|
1577
2361
|
} else {
|
|
1578
2362
|
if (id === void 0) {
|
|
1579
2363
|
fail2("Work item ID is required for updates.");
|
|
1580
2364
|
}
|
|
1581
|
-
writeResult = await applyWorkItemPatch(context, id, credential
|
|
2365
|
+
writeResult = await applyWorkItemPatch(context, id, credential, operations);
|
|
1582
2366
|
}
|
|
1583
2367
|
const result = buildUpsertResult(
|
|
1584
2368
|
action,
|
|
@@ -1596,7 +2380,7 @@ function createUpsertCommand() {
|
|
|
1596
2380
|
}
|
|
1597
2381
|
|
|
1598
2382
|
// src/commands/list-fields.ts
|
|
1599
|
-
import { Command as
|
|
2383
|
+
import { Command as Command11 } from "commander";
|
|
1600
2384
|
function stringifyValue(value) {
|
|
1601
2385
|
if (value === null || value === void 0) return "";
|
|
1602
2386
|
if (typeof value === "object") return JSON.stringify(value);
|
|
@@ -1628,7 +2412,7 @@ function formatFieldList(fields) {
|
|
|
1628
2412
|
}).join("\n");
|
|
1629
2413
|
}
|
|
1630
2414
|
function createListFieldsCommand() {
|
|
1631
|
-
const command = new
|
|
2415
|
+
const command = new Command11("list-fields");
|
|
1632
2416
|
command.description("List all fields of an Azure DevOps work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
1633
2417
|
async (idStr, options) => {
|
|
1634
2418
|
const id = parseWorkItemId(idStr);
|
|
@@ -1636,8 +2420,8 @@ function createListFieldsCommand() {
|
|
|
1636
2420
|
let context;
|
|
1637
2421
|
try {
|
|
1638
2422
|
context = resolveContext(options);
|
|
1639
|
-
const credential = await
|
|
1640
|
-
const fields = await getWorkItemFields(context, id, credential
|
|
2423
|
+
const credential = await requireAuthCredential(context.org);
|
|
2424
|
+
const fields = await getWorkItemFields(context, id, credential);
|
|
1641
2425
|
if (options.json) {
|
|
1642
2426
|
process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
|
|
1643
2427
|
} else {
|
|
@@ -1655,7 +2439,7 @@ function createListFieldsCommand() {
|
|
|
1655
2439
|
}
|
|
1656
2440
|
|
|
1657
2441
|
// src/commands/pr.ts
|
|
1658
|
-
import { Command as
|
|
2442
|
+
import { Command as Command12 } from "commander";
|
|
1659
2443
|
|
|
1660
2444
|
// src/services/pr-client.ts
|
|
1661
2445
|
function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
|
|
@@ -1672,6 +2456,13 @@ function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
|
|
|
1672
2456
|
}
|
|
1673
2457
|
return url;
|
|
1674
2458
|
}
|
|
2459
|
+
function buildPullRequestStatusesUrl(context, repo, prId) {
|
|
2460
|
+
const url = new URL(
|
|
2461
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/statuses`
|
|
2462
|
+
);
|
|
2463
|
+
url.searchParams.set("api-version", "7.1");
|
|
2464
|
+
return url;
|
|
2465
|
+
}
|
|
1675
2466
|
function mapPullRequest(repo, pullRequest) {
|
|
1676
2467
|
return {
|
|
1677
2468
|
id: pullRequest.pullRequestId,
|
|
@@ -1681,7 +2472,36 @@ function mapPullRequest(repo, pullRequest) {
|
|
|
1681
2472
|
targetRefName: pullRequest.targetRefName,
|
|
1682
2473
|
status: pullRequest.status,
|
|
1683
2474
|
createdBy: pullRequest.createdBy?.displayName ?? null,
|
|
1684
|
-
url: pullRequest._links
|
|
2475
|
+
url: pullRequest._links?.web?.href ?? null
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
function mapPullRequestCheckName(status2) {
|
|
2479
|
+
const genre = status2.context?.genre?.trim();
|
|
2480
|
+
const name = status2.context?.name?.trim();
|
|
2481
|
+
if (genre && name) {
|
|
2482
|
+
return `${genre}/${name}`;
|
|
2483
|
+
}
|
|
2484
|
+
if (name) {
|
|
2485
|
+
return name;
|
|
2486
|
+
}
|
|
2487
|
+
if (genre) {
|
|
2488
|
+
return genre;
|
|
2489
|
+
}
|
|
2490
|
+
return `Status #${status2.id}`;
|
|
2491
|
+
}
|
|
2492
|
+
function mapPullRequestCheck(status2) {
|
|
2493
|
+
if (status2.state === "notApplicable" || status2.state === "notSet") {
|
|
2494
|
+
return null;
|
|
2495
|
+
}
|
|
2496
|
+
return {
|
|
2497
|
+
id: status2.id,
|
|
2498
|
+
state: status2.state,
|
|
2499
|
+
name: mapPullRequestCheckName(status2),
|
|
2500
|
+
description: status2.description ?? null,
|
|
2501
|
+
targetUrl: status2.targetUrl ?? null,
|
|
2502
|
+
createdBy: status2.createdBy?.displayName ?? null,
|
|
2503
|
+
createdAt: status2.creationDate ?? null,
|
|
2504
|
+
updatedAt: status2.updatedDate ?? null
|
|
1685
2505
|
};
|
|
1686
2506
|
}
|
|
1687
2507
|
function mapComment(comment) {
|
|
@@ -1697,9 +2517,6 @@ function mapComment(comment) {
|
|
|
1697
2517
|
};
|
|
1698
2518
|
}
|
|
1699
2519
|
function mapThread(thread) {
|
|
1700
|
-
if (thread.status !== "active" && thread.status !== "pending") {
|
|
1701
|
-
return null;
|
|
1702
|
-
}
|
|
1703
2520
|
const comments = thread.comments.map(mapComment).filter((comment) => comment !== null);
|
|
1704
2521
|
if (comments.length === 0) {
|
|
1705
2522
|
return null;
|
|
@@ -1711,22 +2528,67 @@ function mapThread(thread) {
|
|
|
1711
2528
|
comments
|
|
1712
2529
|
};
|
|
1713
2530
|
}
|
|
2531
|
+
function toActiveCommentThread(thread) {
|
|
2532
|
+
return {
|
|
2533
|
+
id: thread.id,
|
|
2534
|
+
status: thread.status,
|
|
2535
|
+
threadContext: thread.threadContext?.filePath ?? null,
|
|
2536
|
+
comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
|
|
2540
|
+
function isThreadResolved(status2) {
|
|
2541
|
+
return RESOLVED_THREAD_STATUSES.has(status2);
|
|
2542
|
+
}
|
|
1714
2543
|
async function readJsonResponse(response) {
|
|
1715
2544
|
if (!response.ok) {
|
|
1716
2545
|
throw new Error(`HTTP_${response.status}`);
|
|
1717
2546
|
}
|
|
1718
2547
|
return response.json();
|
|
1719
2548
|
}
|
|
1720
|
-
async function
|
|
2549
|
+
async function patchThreadStatus(context, repo, cred, prId, threadId, status2) {
|
|
2550
|
+
const url = new URL(
|
|
2551
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
|
|
2552
|
+
);
|
|
2553
|
+
url.searchParams.set("api-version", "7.1");
|
|
2554
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
2555
|
+
method: "PATCH",
|
|
2556
|
+
headers: {
|
|
2557
|
+
...authHeaders(cred),
|
|
2558
|
+
"Content-Type": "application/json"
|
|
2559
|
+
},
|
|
2560
|
+
body: JSON.stringify({ status: status2 })
|
|
2561
|
+
});
|
|
2562
|
+
const data = await readJsonResponse(response);
|
|
2563
|
+
return toActiveCommentThread(data);
|
|
2564
|
+
}
|
|
2565
|
+
async function getPullRequestById(context, repo, cred, prId) {
|
|
2566
|
+
const url = new URL(
|
|
2567
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
|
|
2568
|
+
);
|
|
2569
|
+
url.searchParams.set("api-version", "7.1");
|
|
2570
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
2571
|
+
const data = await readJsonResponse(response);
|
|
2572
|
+
return mapPullRequest(repo, data);
|
|
2573
|
+
}
|
|
2574
|
+
async function listPullRequests(context, repo, cred, sourceBranch, opts) {
|
|
1721
2575
|
const response = await fetchWithErrors(
|
|
1722
2576
|
buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
|
|
1723
|
-
{ headers: authHeaders(
|
|
2577
|
+
{ headers: authHeaders(cred) }
|
|
1724
2578
|
);
|
|
1725
2579
|
const data = await readJsonResponse(response);
|
|
1726
2580
|
return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
|
|
1727
2581
|
}
|
|
1728
|
-
async function
|
|
1729
|
-
const
|
|
2582
|
+
async function getPullRequestChecks(context, repo, cred, prId) {
|
|
2583
|
+
const response = await fetchWithErrors(
|
|
2584
|
+
buildPullRequestStatusesUrl(context, repo, prId).toString(),
|
|
2585
|
+
{ headers: authHeaders(cred) }
|
|
2586
|
+
);
|
|
2587
|
+
const data = await readJsonResponse(response);
|
|
2588
|
+
return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
|
|
2589
|
+
}
|
|
2590
|
+
async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
|
|
2591
|
+
const existing = await listPullRequests(context, repo, cred, sourceBranch, {
|
|
1730
2592
|
status: "active",
|
|
1731
2593
|
targetBranch: "develop"
|
|
1732
2594
|
});
|
|
@@ -1754,7 +2616,7 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
|
|
|
1754
2616
|
const response = await fetchWithErrors(url.toString(), {
|
|
1755
2617
|
method: "POST",
|
|
1756
2618
|
headers: {
|
|
1757
|
-
...authHeaders(
|
|
2619
|
+
...authHeaders(cred),
|
|
1758
2620
|
"Content-Type": "application/json"
|
|
1759
2621
|
},
|
|
1760
2622
|
body: JSON.stringify(payload)
|
|
@@ -1767,96 +2629,147 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
|
|
|
1767
2629
|
pullRequest: mapPullRequest(repo, data)
|
|
1768
2630
|
};
|
|
1769
2631
|
}
|
|
1770
|
-
async function getPullRequestThreads(context, repo,
|
|
2632
|
+
async function getPullRequestThreads(context, repo, cred, prId) {
|
|
1771
2633
|
const url = new URL(
|
|
1772
2634
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
|
|
1773
2635
|
);
|
|
1774
2636
|
url.searchParams.set("api-version", "7.1");
|
|
1775
|
-
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(
|
|
2637
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
|
|
1776
2638
|
const data = await readJsonResponse(response);
|
|
1777
2639
|
return data.value.map(mapThread).filter((thread) => thread !== null);
|
|
1778
2640
|
}
|
|
1779
2641
|
|
|
1780
2642
|
// src/commands/pr.ts
|
|
2643
|
+
function parsePositivePrNumber(raw) {
|
|
2644
|
+
if (!/^\d+$/.test(raw)) {
|
|
2645
|
+
return null;
|
|
2646
|
+
}
|
|
2647
|
+
const n = Number.parseInt(raw, 10);
|
|
2648
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
2649
|
+
}
|
|
2650
|
+
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.";
|
|
2651
|
+
function configureUnwrappedHelp(command) {
|
|
2652
|
+
return command.configureHelp({ helpWidth: 1e3 });
|
|
2653
|
+
}
|
|
2654
|
+
function autoDetectZeroMatch(branch) {
|
|
2655
|
+
return `No open pull request matches branch ${branch}. Pass --pr-number to target a specific PR, or push the branch and open a pull request.`;
|
|
2656
|
+
}
|
|
2657
|
+
function autoDetectMultiMatch(branch, ids) {
|
|
2658
|
+
return `Multiple open pull requests match branch ${branch}: ${ids.map((id) => `#${id}`).join(", ")}. Re-run with --pr-number to choose.`;
|
|
2659
|
+
}
|
|
2660
|
+
function writeContractError(line) {
|
|
2661
|
+
process.stderr.write(`${line}
|
|
2662
|
+
`);
|
|
2663
|
+
process.exitCode = 1;
|
|
2664
|
+
}
|
|
1781
2665
|
function formatBranchName(refName) {
|
|
1782
2666
|
return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
|
|
1783
2667
|
}
|
|
1784
2668
|
function writeError(message) {
|
|
1785
2669
|
process.stderr.write(`Error: ${message}
|
|
1786
2670
|
`);
|
|
1787
|
-
process.
|
|
2671
|
+
process.exitCode = 1;
|
|
1788
2672
|
}
|
|
1789
2673
|
function handlePrCommandError(err, context, mode = "read") {
|
|
1790
2674
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
1791
2675
|
if (error.message === "AUTH_FAILED") {
|
|
1792
2676
|
const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
|
|
1793
2677
|
writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`);
|
|
2678
|
+
return;
|
|
1794
2679
|
}
|
|
1795
2680
|
if (error.message === "PERMISSION_DENIED") {
|
|
1796
2681
|
writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`);
|
|
2682
|
+
return;
|
|
1797
2683
|
}
|
|
1798
2684
|
if (error.message === "NETWORK_ERROR") {
|
|
1799
2685
|
writeError("Could not connect to Azure DevOps. Check your network connection.");
|
|
2686
|
+
return;
|
|
1800
2687
|
}
|
|
1801
2688
|
if (error.message.startsWith("NOT_FOUND")) {
|
|
1802
2689
|
writeError(`Azure DevOps repository not found in ${context?.org}/${context?.project}.`);
|
|
2690
|
+
return;
|
|
1803
2691
|
}
|
|
1804
2692
|
if (error.message.startsWith("HTTP_")) {
|
|
1805
2693
|
writeError(`Azure DevOps request failed with ${error.message}.`);
|
|
2694
|
+
return;
|
|
1806
2695
|
}
|
|
1807
2696
|
writeError(error.message);
|
|
1808
2697
|
}
|
|
2698
|
+
function formatPullRequestChecks(checks) {
|
|
2699
|
+
if (checks.length === 0) {
|
|
2700
|
+
return ["Checks: none reported by Azure DevOps"];
|
|
2701
|
+
}
|
|
2702
|
+
const lines = ["Checks:"];
|
|
2703
|
+
for (const check of checks) {
|
|
2704
|
+
lines.push(`- [${check.state}] ${check.name}`);
|
|
2705
|
+
if ((check.state === "failed" || check.state === "error") && check.description) {
|
|
2706
|
+
lines.push(` Detail: ${check.description}`);
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
return lines;
|
|
2710
|
+
}
|
|
1809
2711
|
function formatPullRequestBlock(pullRequest) {
|
|
1810
2712
|
return [
|
|
1811
2713
|
`#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
|
|
1812
2714
|
`${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
|
|
1813
|
-
pullRequest.url
|
|
2715
|
+
pullRequest.url ?? "\u2014",
|
|
2716
|
+
...formatPullRequestChecks(pullRequest.checks)
|
|
1814
2717
|
].join("\n");
|
|
1815
2718
|
}
|
|
2719
|
+
function threadStatusLabel(status2) {
|
|
2720
|
+
return isThreadResolved(status2) ? "resolved" : status2;
|
|
2721
|
+
}
|
|
1816
2722
|
function formatThreads(prId, title, threads) {
|
|
1817
|
-
const lines = [`
|
|
2723
|
+
const lines = [`Comment threads for pull request #${prId}: ${title}`];
|
|
1818
2724
|
for (const thread of threads) {
|
|
1819
|
-
lines.push("", `Thread #${thread.id} [${thread.status}] ${thread.threadContext ?? "(general)"}`);
|
|
2725
|
+
lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${thread.threadContext ?? "(general)"}`);
|
|
1820
2726
|
for (const comment of thread.comments) {
|
|
1821
2727
|
lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
|
|
1822
2728
|
}
|
|
1823
2729
|
}
|
|
1824
2730
|
return lines.join("\n");
|
|
1825
2731
|
}
|
|
1826
|
-
async function resolvePrCommandContext(options) {
|
|
2732
|
+
async function resolvePrCommandContext(options, resolveOpts = {}) {
|
|
2733
|
+
const requireBranch = resolveOpts.requireBranch ?? true;
|
|
1827
2734
|
const context = resolveContext(options);
|
|
1828
2735
|
const repo = detectRepoName();
|
|
1829
|
-
const branch = getCurrentBranch();
|
|
1830
|
-
const credential = await
|
|
2736
|
+
const branch = requireBranch ? getCurrentBranch() : null;
|
|
2737
|
+
const credential = await requireAuthCredential(context.org);
|
|
1831
2738
|
return {
|
|
1832
2739
|
context,
|
|
1833
2740
|
repo,
|
|
1834
2741
|
branch,
|
|
1835
|
-
pat: credential
|
|
2742
|
+
pat: credential
|
|
1836
2743
|
};
|
|
1837
2744
|
}
|
|
1838
2745
|
function createPrStatusCommand() {
|
|
1839
|
-
const command = new
|
|
2746
|
+
const command = new Command12("status");
|
|
1840
2747
|
command.description("Check pull requests for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
|
|
1841
2748
|
validateOrgProjectPair(options);
|
|
1842
2749
|
let context;
|
|
1843
2750
|
try {
|
|
1844
2751
|
const resolved = await resolvePrCommandContext(options);
|
|
1845
2752
|
context = resolved.context;
|
|
1846
|
-
const
|
|
1847
|
-
const
|
|
1848
|
-
const
|
|
2753
|
+
const branch = resolved.branch;
|
|
2754
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
|
|
2755
|
+
const pullRequestsWithChecks = await Promise.all(
|
|
2756
|
+
pullRequests.map(async (pullRequest) => ({
|
|
2757
|
+
...pullRequest,
|
|
2758
|
+
checks: await getPullRequestChecks(resolved.context, resolved.repo, resolved.pat, pullRequest.id)
|
|
2759
|
+
}))
|
|
2760
|
+
);
|
|
2761
|
+
const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
|
|
1849
2762
|
if (options.json) {
|
|
1850
2763
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1851
2764
|
`);
|
|
1852
2765
|
return;
|
|
1853
2766
|
}
|
|
1854
|
-
if (
|
|
2767
|
+
if (pullRequestsWithChecks.length === 0) {
|
|
1855
2768
|
process.stdout.write(`No pull requests found for branch ${branch}.
|
|
1856
2769
|
`);
|
|
1857
2770
|
return;
|
|
1858
2771
|
}
|
|
1859
|
-
process.stdout.write(`${
|
|
2772
|
+
process.stdout.write(`${pullRequestsWithChecks.map(formatPullRequestBlock).join("\n\n")}
|
|
1860
2773
|
`);
|
|
1861
2774
|
} catch (err) {
|
|
1862
2775
|
handlePrCommandError(err, context, "read");
|
|
@@ -1865,16 +2778,18 @@ function createPrStatusCommand() {
|
|
|
1865
2778
|
return command;
|
|
1866
2779
|
}
|
|
1867
2780
|
function createPrOpenCommand() {
|
|
1868
|
-
const command = new
|
|
2781
|
+
const command = new Command12("open");
|
|
1869
2782
|
command.description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option("--description <description>", "pull request description").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
|
|
1870
2783
|
validateOrgProjectPair(options);
|
|
1871
2784
|
const title = options.title?.trim();
|
|
1872
2785
|
if (!title) {
|
|
1873
2786
|
writeError("--title is required for pull request creation.");
|
|
2787
|
+
return;
|
|
1874
2788
|
}
|
|
1875
2789
|
const description = options.description?.trim();
|
|
1876
2790
|
if (!description) {
|
|
1877
2791
|
writeError("--description is required for pull request creation.");
|
|
2792
|
+
return;
|
|
1878
2793
|
}
|
|
1879
2794
|
let context;
|
|
1880
2795
|
try {
|
|
@@ -1882,12 +2797,14 @@ function createPrOpenCommand() {
|
|
|
1882
2797
|
context = resolved.context;
|
|
1883
2798
|
if (resolved.branch === "develop") {
|
|
1884
2799
|
writeError("Pull request creation requires a source branch other than develop.");
|
|
2800
|
+
return;
|
|
1885
2801
|
}
|
|
2802
|
+
const openBranch = resolved.branch;
|
|
1886
2803
|
const result = await openPullRequest(
|
|
1887
2804
|
resolved.context,
|
|
1888
2805
|
resolved.repo,
|
|
1889
2806
|
resolved.pat,
|
|
1890
|
-
|
|
2807
|
+
openBranch,
|
|
1891
2808
|
title,
|
|
1892
2809
|
description
|
|
1893
2810
|
);
|
|
@@ -1898,19 +2815,20 @@ function createPrOpenCommand() {
|
|
|
1898
2815
|
}
|
|
1899
2816
|
if (result.created) {
|
|
1900
2817
|
process.stdout.write(`Created pull request #${result.pullRequest.id}: ${result.pullRequest.title}
|
|
1901
|
-
${result.pullRequest.url}
|
|
2818
|
+
${result.pullRequest.url ?? "\u2014"}
|
|
1902
2819
|
`);
|
|
1903
2820
|
return;
|
|
1904
2821
|
}
|
|
1905
2822
|
process.stdout.write(
|
|
1906
2823
|
`Active pull request already exists for ${resolved.branch} -> develop: #${result.pullRequest.id}
|
|
1907
|
-
${result.pullRequest.url}
|
|
2824
|
+
${result.pullRequest.url ?? "\u2014"}
|
|
1908
2825
|
`
|
|
1909
2826
|
);
|
|
1910
2827
|
} catch (err) {
|
|
1911
2828
|
if (err instanceof Error && err.message.startsWith("AMBIGUOUS_PRS:")) {
|
|
1912
2829
|
const ids = err.message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
|
|
1913
2830
|
writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
|
|
2831
|
+
return;
|
|
1914
2832
|
}
|
|
1915
2833
|
handlePrCommandError(err, context, "write");
|
|
1916
2834
|
}
|
|
@@ -1918,34 +2836,65 @@ ${result.pullRequest.url}
|
|
|
1918
2836
|
return command;
|
|
1919
2837
|
}
|
|
1920
2838
|
function createPrCommentsCommand() {
|
|
1921
|
-
const command = new
|
|
1922
|
-
command.description("List
|
|
2839
|
+
const command = new Command12("comments");
|
|
2840
|
+
configureUnwrappedHelp(command).description("List pull request comment threads for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--json", "output JSON").action(async (options) => {
|
|
1923
2841
|
validateOrgProjectPair(options);
|
|
1924
2842
|
let context;
|
|
2843
|
+
let explicitPrId = null;
|
|
2844
|
+
if (options.prNumber !== void 0) {
|
|
2845
|
+
explicitPrId = parsePositivePrNumber(options.prNumber);
|
|
2846
|
+
if (explicitPrId === null) {
|
|
2847
|
+
writeError(`Invalid --pr-number "${options.prNumber}"; expected a positive integer.`);
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
1925
2851
|
try {
|
|
1926
|
-
const resolved = await resolvePrCommandContext(options);
|
|
2852
|
+
const resolved = await resolvePrCommandContext(options, { requireBranch: explicitPrId === null });
|
|
1927
2853
|
context = resolved.context;
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
2854
|
+
let pullRequest;
|
|
2855
|
+
let branchLabel;
|
|
2856
|
+
if (explicitPrId !== null) {
|
|
2857
|
+
try {
|
|
2858
|
+
pullRequest = await getPullRequestById(resolved.context, resolved.repo, resolved.pat, explicitPrId);
|
|
2859
|
+
} catch (err) {
|
|
2860
|
+
if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
|
|
2861
|
+
writeError(`Pull request #${explicitPrId} not found in ${resolved.context.org}/${resolved.context.project}/${resolved.repo}.`);
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
throw err;
|
|
2865
|
+
}
|
|
2866
|
+
branchLabel = resolved.branch ?? pullRequest.sourceRefName;
|
|
2867
|
+
} else {
|
|
2868
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
|
|
2869
|
+
status: "active"
|
|
2870
|
+
});
|
|
2871
|
+
if (pullRequests.length === 0) {
|
|
2872
|
+
writeContractError(autoDetectZeroMatch(resolved.branch));
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
if (pullRequests.length > 1) {
|
|
2876
|
+
writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
pullRequest = pullRequests[0];
|
|
2880
|
+
branchLabel = resolved.branch;
|
|
1937
2881
|
}
|
|
1938
|
-
const
|
|
1939
|
-
const threads =
|
|
1940
|
-
const result = { branch:
|
|
2882
|
+
const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
|
|
2883
|
+
const threads = options.hideResolved ? allThreads.filter((thread) => !isThreadResolved(thread.status)) : allThreads;
|
|
2884
|
+
const result = { branch: branchLabel, pullRequest, threads };
|
|
1941
2885
|
if (options.json) {
|
|
1942
2886
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1943
2887
|
`);
|
|
1944
2888
|
return;
|
|
1945
2889
|
}
|
|
1946
2890
|
if (threads.length === 0) {
|
|
1947
|
-
|
|
2891
|
+
if (options.hideResolved && allThreads.length > 0) {
|
|
2892
|
+
process.stdout.write(`Pull request #${pullRequest.id} has no unresolved comment threads (${allThreads.length} resolved thread${allThreads.length === 1 ? "" : "s"} hidden by --hide-resolved).
|
|
1948
2893
|
`);
|
|
2894
|
+
} else {
|
|
2895
|
+
process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
|
|
2896
|
+
`);
|
|
2897
|
+
}
|
|
1949
2898
|
return;
|
|
1950
2899
|
}
|
|
1951
2900
|
process.stdout.write(`${formatThreads(pullRequest.id, pullRequest.title, threads)}
|
|
@@ -1956,17 +2905,135 @@ function createPrCommentsCommand() {
|
|
|
1956
2905
|
});
|
|
1957
2906
|
return command;
|
|
1958
2907
|
}
|
|
2908
|
+
async function resolveThreadTarget(threadIdRaw, options) {
|
|
2909
|
+
validateOrgProjectPair(options);
|
|
2910
|
+
const threadId = parsePositivePrNumber(threadIdRaw);
|
|
2911
|
+
if (threadId === null) {
|
|
2912
|
+
writeError(`Invalid thread id "${threadIdRaw}"; expected a positive integer.`);
|
|
2913
|
+
return null;
|
|
2914
|
+
}
|
|
2915
|
+
let explicitPrId = null;
|
|
2916
|
+
if (options.prNumber !== void 0) {
|
|
2917
|
+
explicitPrId = parsePositivePrNumber(options.prNumber);
|
|
2918
|
+
if (explicitPrId === null) {
|
|
2919
|
+
writeError(`Invalid --pr-number "${options.prNumber}"; expected a positive integer.`);
|
|
2920
|
+
return null;
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
const resolved = await resolvePrCommandContext(options, { requireBranch: explicitPrId === null });
|
|
2924
|
+
let pullRequest;
|
|
2925
|
+
if (explicitPrId !== null) {
|
|
2926
|
+
try {
|
|
2927
|
+
pullRequest = await getPullRequestById(resolved.context, resolved.repo, resolved.pat, explicitPrId);
|
|
2928
|
+
} catch (err) {
|
|
2929
|
+
if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
|
|
2930
|
+
writeError(`Pull request #${explicitPrId} not found in ${resolved.context.org}/${resolved.context.project}/${resolved.repo}.`);
|
|
2931
|
+
return null;
|
|
2932
|
+
}
|
|
2933
|
+
throw err;
|
|
2934
|
+
}
|
|
2935
|
+
} else {
|
|
2936
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
|
|
2937
|
+
status: "active"
|
|
2938
|
+
});
|
|
2939
|
+
if (pullRequests.length === 0) {
|
|
2940
|
+
writeContractError(autoDetectZeroMatch(resolved.branch));
|
|
2941
|
+
return null;
|
|
2942
|
+
}
|
|
2943
|
+
if (pullRequests.length > 1) {
|
|
2944
|
+
writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
|
|
2945
|
+
return null;
|
|
2946
|
+
}
|
|
2947
|
+
pullRequest = pullRequests[0];
|
|
2948
|
+
}
|
|
2949
|
+
return { context: resolved.context, repo: resolved.repo, pat: resolved.pat, pullRequest, threadId };
|
|
2950
|
+
}
|
|
2951
|
+
async function runThreadStateChange(threadIdRaw, options, direction) {
|
|
2952
|
+
let context;
|
|
2953
|
+
try {
|
|
2954
|
+
const target = await resolveThreadTarget(threadIdRaw, options);
|
|
2955
|
+
if (target === null) {
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
context = target.context;
|
|
2959
|
+
const threads = await getPullRequestThreads(target.context, target.repo, target.pat, target.pullRequest.id);
|
|
2960
|
+
const thread = threads.find((t) => t.id === target.threadId);
|
|
2961
|
+
if (!thread) {
|
|
2962
|
+
writeError(`Thread #${target.threadId} not found on pull request #${target.pullRequest.id}.`);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
const alreadyInTargetState = direction === "resolve" ? isThreadResolved(thread.status) : !isThreadResolved(thread.status);
|
|
2966
|
+
const targetStatus = direction === "resolve" ? "fixed" : "active";
|
|
2967
|
+
if (alreadyInTargetState) {
|
|
2968
|
+
const humanLabel = direction === "resolve" ? "resolved" : "active";
|
|
2969
|
+
const noopResult = {
|
|
2970
|
+
pullRequestId: target.pullRequest.id,
|
|
2971
|
+
threadId: target.threadId,
|
|
2972
|
+
status: thread.status,
|
|
2973
|
+
noop: true
|
|
2974
|
+
};
|
|
2975
|
+
if (options.json) {
|
|
2976
|
+
process.stdout.write(`${JSON.stringify(noopResult, null, 2)}
|
|
2977
|
+
`);
|
|
2978
|
+
return;
|
|
2979
|
+
}
|
|
2980
|
+
process.stdout.write(`Thread #${target.threadId} is already ${humanLabel} on pull request #${target.pullRequest.id} (current status: ${thread.status}).
|
|
2981
|
+
`);
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
const updated = await patchThreadStatus(
|
|
2985
|
+
target.context,
|
|
2986
|
+
target.repo,
|
|
2987
|
+
target.pat,
|
|
2988
|
+
target.pullRequest.id,
|
|
2989
|
+
target.threadId,
|
|
2990
|
+
targetStatus
|
|
2991
|
+
);
|
|
2992
|
+
const result = {
|
|
2993
|
+
pullRequestId: target.pullRequest.id,
|
|
2994
|
+
threadId: target.threadId,
|
|
2995
|
+
status: targetStatus,
|
|
2996
|
+
noop: false
|
|
2997
|
+
};
|
|
2998
|
+
if (options.json) {
|
|
2999
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3000
|
+
`);
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
const verb = direction === "resolve" ? "resolved" : "reopened";
|
|
3004
|
+
process.stdout.write(`Thread #${target.threadId} ${verb} on pull request #${target.pullRequest.id} (status: ${updated.status}).
|
|
3005
|
+
`);
|
|
3006
|
+
} catch (err) {
|
|
3007
|
+
handlePrCommandError(err, context, "write");
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
function createPrCommentResolveCommand() {
|
|
3011
|
+
const command = new Command12("comment-resolve");
|
|
3012
|
+
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) => {
|
|
3013
|
+
await runThreadStateChange(threadIdRaw, options, "resolve");
|
|
3014
|
+
});
|
|
3015
|
+
return command;
|
|
3016
|
+
}
|
|
3017
|
+
function createPrCommentReopenCommand() {
|
|
3018
|
+
const command = new Command12("comment-reopen");
|
|
3019
|
+
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) => {
|
|
3020
|
+
await runThreadStateChange(threadIdRaw, options, "reopen");
|
|
3021
|
+
});
|
|
3022
|
+
return command;
|
|
3023
|
+
}
|
|
1959
3024
|
function createPrCommand() {
|
|
1960
|
-
const command = new
|
|
3025
|
+
const command = new Command12("pr");
|
|
1961
3026
|
command.description("Manage Azure DevOps pull requests");
|
|
1962
3027
|
command.addCommand(createPrStatusCommand());
|
|
1963
3028
|
command.addCommand(createPrOpenCommand());
|
|
1964
3029
|
command.addCommand(createPrCommentsCommand());
|
|
3030
|
+
command.addCommand(createPrCommentResolveCommand());
|
|
3031
|
+
command.addCommand(createPrCommentReopenCommand());
|
|
1965
3032
|
return command;
|
|
1966
3033
|
}
|
|
1967
3034
|
|
|
1968
3035
|
// src/commands/comments.ts
|
|
1969
|
-
import { Command as
|
|
3036
|
+
import { Command as Command13 } from "commander";
|
|
1970
3037
|
function writeError2(message) {
|
|
1971
3038
|
process.stderr.write(`Error: ${message}
|
|
1972
3039
|
`);
|
|
@@ -1977,23 +3044,24 @@ function formatCommentHeader(comment) {
|
|
|
1977
3044
|
const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
|
|
1978
3045
|
return `Comment #${comment.id} by ${author} at ${timestamp}`;
|
|
1979
3046
|
}
|
|
1980
|
-
function formatComments(result) {
|
|
3047
|
+
function formatComments(result, convertMarkdown) {
|
|
1981
3048
|
const lines = [`Comments for work item #${result.workItemId}`];
|
|
1982
3049
|
for (const comment of result.comments) {
|
|
1983
|
-
|
|
3050
|
+
const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
|
|
3051
|
+
lines.push("", formatCommentHeader(comment), text);
|
|
1984
3052
|
}
|
|
1985
3053
|
return lines.join("\n");
|
|
1986
3054
|
}
|
|
1987
3055
|
function createCommentsListCommand() {
|
|
1988
|
-
const command = new
|
|
1989
|
-
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").action(async (idStr, options) => {
|
|
3056
|
+
const command = new Command13("list");
|
|
3057
|
+
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) => {
|
|
1990
3058
|
validateOrgProjectPair(options);
|
|
1991
3059
|
const id = parseWorkItemId(idStr);
|
|
1992
3060
|
let context;
|
|
1993
3061
|
try {
|
|
1994
3062
|
context = resolveContext(options);
|
|
1995
|
-
const credential = await
|
|
1996
|
-
const result = await listWorkItemComments(context, id, credential
|
|
3063
|
+
const credential = await requireAuthCredential(context.org);
|
|
3064
|
+
const result = await listWorkItemComments(context, id, credential);
|
|
1997
3065
|
if (options.json) {
|
|
1998
3066
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1999
3067
|
`);
|
|
@@ -2004,7 +3072,7 @@ function createCommentsListCommand() {
|
|
|
2004
3072
|
`);
|
|
2005
3073
|
return;
|
|
2006
3074
|
}
|
|
2007
|
-
process.stdout.write(`${formatComments(result)}
|
|
3075
|
+
process.stdout.write(`${formatComments(result, options.markdown === true)}
|
|
2008
3076
|
`);
|
|
2009
3077
|
} catch (err) {
|
|
2010
3078
|
handleCommandError(err, id, context, "read");
|
|
@@ -2013,8 +3081,8 @@ function createCommentsListCommand() {
|
|
|
2013
3081
|
return command;
|
|
2014
3082
|
}
|
|
2015
3083
|
function createCommentsAddCommand() {
|
|
2016
|
-
const command = new
|
|
2017
|
-
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").action(async (idStr, text, options) => {
|
|
3084
|
+
const command = new Command13("add");
|
|
3085
|
+
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) => {
|
|
2018
3086
|
validateOrgProjectPair(options);
|
|
2019
3087
|
const id = parseWorkItemId(idStr);
|
|
2020
3088
|
if (text.trim() === "") {
|
|
@@ -2023,8 +3091,9 @@ function createCommentsAddCommand() {
|
|
|
2023
3091
|
let context;
|
|
2024
3092
|
try {
|
|
2025
3093
|
context = resolveContext(options);
|
|
2026
|
-
const credential = await
|
|
2027
|
-
const
|
|
3094
|
+
const credential = await requireAuthCredential(context.org);
|
|
3095
|
+
const format = options.markdown === true ? "markdown" : "html";
|
|
3096
|
+
const result = await addWorkItemComment(context, id, credential, text, format);
|
|
2028
3097
|
if (options.json) {
|
|
2029
3098
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2030
3099
|
`);
|
|
@@ -2039,17 +3108,65 @@ function createCommentsAddCommand() {
|
|
|
2039
3108
|
return command;
|
|
2040
3109
|
}
|
|
2041
3110
|
function createCommentsCommand() {
|
|
2042
|
-
const command = new
|
|
3111
|
+
const command = new Command13("comments");
|
|
2043
3112
|
command.description("Manage Azure DevOps work item comments");
|
|
2044
3113
|
command.addCommand(createCommentsListCommand());
|
|
2045
3114
|
command.addCommand(createCommentsAddCommand());
|
|
2046
3115
|
return command;
|
|
2047
3116
|
}
|
|
2048
3117
|
|
|
3118
|
+
// src/commands/download-attachment.ts
|
|
3119
|
+
import { Command as Command14 } from "commander";
|
|
3120
|
+
import { writeFile } from "fs/promises";
|
|
3121
|
+
import { existsSync as existsSync4 } from "fs";
|
|
3122
|
+
import { join as join2 } from "path";
|
|
3123
|
+
function createDownloadAttachmentCommand() {
|
|
3124
|
+
const command = new Command14("download-attachment");
|
|
3125
|
+
command.description("Download an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to download").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--output <dir>", "target directory for the downloaded file").action(
|
|
3126
|
+
async (idStr, filename, options) => {
|
|
3127
|
+
const id = parseWorkItemId(idStr);
|
|
3128
|
+
validateOrgProjectPair(options);
|
|
3129
|
+
let context;
|
|
3130
|
+
try {
|
|
3131
|
+
context = resolveContext(options);
|
|
3132
|
+
const credential = await requireAuthCredential(context.org);
|
|
3133
|
+
const outputDir = options.output ?? ".";
|
|
3134
|
+
if (!existsSync4(outputDir)) {
|
|
3135
|
+
process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
|
|
3136
|
+
`);
|
|
3137
|
+
process.exit(1);
|
|
3138
|
+
}
|
|
3139
|
+
const workItem = await getWorkItem(context, id, credential);
|
|
3140
|
+
const attachment = workItem.attachments?.find(
|
|
3141
|
+
(a) => a.name === filename
|
|
3142
|
+
);
|
|
3143
|
+
if (!attachment) {
|
|
3144
|
+
process.stderr.write(
|
|
3145
|
+
`Error: Attachment "${filename}" not found on work item ${id}.
|
|
3146
|
+
`
|
|
3147
|
+
);
|
|
3148
|
+
process.exit(1);
|
|
3149
|
+
}
|
|
3150
|
+
const data = await downloadAttachment(attachment.url, credential);
|
|
3151
|
+
const outputPath = join2(outputDir, filename);
|
|
3152
|
+
await writeFile(outputPath, Buffer.from(data));
|
|
3153
|
+
process.stdout.write(
|
|
3154
|
+
`Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
|
|
3155
|
+
`
|
|
3156
|
+
);
|
|
3157
|
+
} catch (err) {
|
|
3158
|
+
handleCommandError(err, id, context, "read", false);
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
);
|
|
3162
|
+
return command;
|
|
3163
|
+
}
|
|
3164
|
+
|
|
2049
3165
|
// src/index.ts
|
|
2050
|
-
var program = new
|
|
3166
|
+
var program = new Command15();
|
|
2051
3167
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
2052
3168
|
program.addCommand(createGetItemCommand());
|
|
3169
|
+
program.addCommand(createAuthCommand());
|
|
2053
3170
|
program.addCommand(createClearPatCommand());
|
|
2054
3171
|
program.addCommand(createConfigCommand());
|
|
2055
3172
|
program.addCommand(createSetStateCommand());
|
|
@@ -2061,6 +3178,7 @@ program.addCommand(createUpsertCommand());
|
|
|
2061
3178
|
program.addCommand(createListFieldsCommand());
|
|
2062
3179
|
program.addCommand(createPrCommand());
|
|
2063
3180
|
program.addCommand(createCommentsCommand());
|
|
3181
|
+
program.addCommand(createDownloadAttachmentCommand());
|
|
2064
3182
|
program.showHelpAfterError();
|
|
2065
3183
|
program.parse();
|
|
2066
3184
|
if (process.argv.length <= 2) {
|