azdo-cli 0.5.0-develop.156 → 0.5.0-hotfix-0-10-1.266
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 +22 -340
- package/dist/index.js +1238 -265
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
4
|
+
import { Command as Command15 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
7
|
import { readFileSync } from "fs";
|
|
@@ -166,17 +166,33 @@ async function getWorkItemFields(context, id, pat) {
|
|
|
166
166
|
const data = await response.json();
|
|
167
167
|
return data.fields;
|
|
168
168
|
}
|
|
169
|
-
|
|
169
|
+
function extractAttachments(relations) {
|
|
170
|
+
if (!relations) return null;
|
|
171
|
+
const attachments = relations.filter((r) => r.rel === "AttachedFile").map((r) => ({
|
|
172
|
+
name: r.attributes.name ?? "unknown",
|
|
173
|
+
size: r.attributes.resourceSize ?? 0,
|
|
174
|
+
url: r.url
|
|
175
|
+
}));
|
|
176
|
+
return attachments.length > 0 ? attachments : null;
|
|
177
|
+
}
|
|
178
|
+
function buildWorkItemUrl(context, id, options = {}) {
|
|
170
179
|
const url = new URL(
|
|
171
180
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
172
181
|
);
|
|
173
182
|
url.searchParams.set("api-version", "7.1");
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const allFields = normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields]);
|
|
177
|
-
url.searchParams.set("fields", allFields.join(","));
|
|
183
|
+
if (options.includeRelations) {
|
|
184
|
+
url.searchParams.set("$expand", "relations");
|
|
178
185
|
}
|
|
179
|
-
|
|
186
|
+
if (options.fields && options.fields.length > 0) {
|
|
187
|
+
url.searchParams.set("fields", options.fields.join(","));
|
|
188
|
+
}
|
|
189
|
+
return url;
|
|
190
|
+
}
|
|
191
|
+
async function fetchWorkItemResponse(context, id, pat, options = {}) {
|
|
192
|
+
const response = await fetchWithErrors(
|
|
193
|
+
buildWorkItemUrl(context, id, options).toString(),
|
|
194
|
+
{ headers: authHeaders(pat) }
|
|
195
|
+
);
|
|
180
196
|
if (response.status === 400) {
|
|
181
197
|
const serverMessage = await readResponseMessage(response);
|
|
182
198
|
if (serverMessage) {
|
|
@@ -186,7 +202,14 @@ async function getWorkItem(context, id, pat, extraFields) {
|
|
|
186
202
|
if (!response.ok) {
|
|
187
203
|
throw new Error(`HTTP_${response.status}`);
|
|
188
204
|
}
|
|
189
|
-
|
|
205
|
+
return await response.json();
|
|
206
|
+
}
|
|
207
|
+
async function getWorkItem(context, id, pat, extraFields) {
|
|
208
|
+
const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
|
|
209
|
+
const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, {
|
|
210
|
+
fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
|
|
211
|
+
}) : await fetchWorkItemResponse(context, id, pat, { includeRelations: true });
|
|
212
|
+
const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, { includeRelations: true }) : data;
|
|
190
213
|
const descriptionParts = [];
|
|
191
214
|
if (data.fields["System.Description"]) {
|
|
192
215
|
descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
|
|
@@ -214,7 +237,8 @@ async function getWorkItem(context, id, pat, extraFields) {
|
|
|
214
237
|
areaPath: data.fields["System.AreaPath"],
|
|
215
238
|
iterationPath: data.fields["System.IterationPath"],
|
|
216
239
|
url: data._links.html.href,
|
|
217
|
-
extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null
|
|
240
|
+
extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null,
|
|
241
|
+
attachments: extractAttachments(relationsData.relations)
|
|
218
242
|
};
|
|
219
243
|
}
|
|
220
244
|
async function getWorkItemFieldValue(context, id, pat, fieldName) {
|
|
@@ -263,8 +287,10 @@ async function listWorkItemComments(context, id, pat) {
|
|
|
263
287
|
comments
|
|
264
288
|
};
|
|
265
289
|
}
|
|
266
|
-
async function addWorkItemComment(context, id, pat, text) {
|
|
267
|
-
const
|
|
290
|
+
async function addWorkItemComment(context, id, pat, text, format = "html") {
|
|
291
|
+
const url = buildWorkItemCommentsUrl(context, id);
|
|
292
|
+
url.searchParams.set("format", format);
|
|
293
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
268
294
|
method: "POST",
|
|
269
295
|
headers: {
|
|
270
296
|
...authHeaders(pat),
|
|
@@ -326,44 +352,340 @@ async function applyWorkItemPatch(context, id, pat, operations) {
|
|
|
326
352
|
});
|
|
327
353
|
return readWriteResponse(response, "UPDATE_REJECTED");
|
|
328
354
|
}
|
|
355
|
+
async function downloadAttachment(url, pat) {
|
|
356
|
+
const response = await fetchWithErrors(url, { headers: authHeaders(pat) });
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
throw new Error(`HTTP_${response.status}`);
|
|
359
|
+
}
|
|
360
|
+
return response.arrayBuffer();
|
|
361
|
+
}
|
|
329
362
|
|
|
330
363
|
// src/services/auth.ts
|
|
331
364
|
import { createInterface } from "readline";
|
|
365
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
366
|
+
import { dirname as dirname2, join } from "path";
|
|
332
367
|
|
|
333
368
|
// src/services/credential-store.ts
|
|
334
369
|
import { Entry } from "@napi-rs/keyring";
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
370
|
+
|
|
371
|
+
// src/types/credential.ts
|
|
372
|
+
var CredentialStoreUnavailableError = class extends Error {
|
|
373
|
+
backend;
|
|
374
|
+
constructor(backend, cause) {
|
|
375
|
+
super(`OS secret backend unavailable (${backend}). Install the platform's credential service and try again.`);
|
|
376
|
+
this.name = "CredentialStoreUnavailableError";
|
|
377
|
+
this.backend = backend;
|
|
378
|
+
if (cause instanceof Error) {
|
|
379
|
+
this.cause = cause;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// src/services/audit-log.ts
|
|
385
|
+
import fs from "fs";
|
|
386
|
+
import os from "os";
|
|
387
|
+
import path from "path";
|
|
388
|
+
function getAuditLogPath() {
|
|
389
|
+
return path.join(os.homedir(), ".azdo", "audit.log");
|
|
390
|
+
}
|
|
391
|
+
function ensureDirWithPerms(dir) {
|
|
392
|
+
if (!fs.existsSync(dir)) {
|
|
393
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
338
396
|
try {
|
|
339
|
-
|
|
340
|
-
return entry.getPassword();
|
|
397
|
+
fs.chmodSync(dir, 448);
|
|
341
398
|
} catch {
|
|
342
|
-
return null;
|
|
343
399
|
}
|
|
344
400
|
}
|
|
345
|
-
|
|
401
|
+
function ensureFileWithPerms(file) {
|
|
402
|
+
if (!fs.existsSync(file)) {
|
|
403
|
+
fs.writeFileSync(file, "", { mode: 384 });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
346
406
|
try {
|
|
347
|
-
|
|
348
|
-
entry.setPassword(pat);
|
|
407
|
+
fs.chmodSync(file, 384);
|
|
349
408
|
} catch {
|
|
350
409
|
}
|
|
351
410
|
}
|
|
352
|
-
|
|
411
|
+
function appendAuthAuditEvent(input) {
|
|
412
|
+
const auditLog = getAuditLogPath();
|
|
413
|
+
const dir = path.dirname(auditLog);
|
|
414
|
+
ensureDirWithPerms(dir);
|
|
415
|
+
ensureFileWithPerms(auditLog);
|
|
416
|
+
const record = {
|
|
417
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
418
|
+
event: input.event,
|
|
419
|
+
org: input.org,
|
|
420
|
+
backend: input.backend,
|
|
421
|
+
...input.masked_pat !== void 0 ? { masked_pat: input.masked_pat } : {}
|
|
422
|
+
};
|
|
423
|
+
fs.appendFileSync(auditLog, `${JSON.stringify(record)}
|
|
424
|
+
`);
|
|
425
|
+
}
|
|
426
|
+
function readAuditEvents() {
|
|
427
|
+
const auditLog = getAuditLogPath();
|
|
428
|
+
if (!fs.existsSync(auditLog)) {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
const contents = fs.readFileSync(auditLog, "utf8");
|
|
432
|
+
const out = [];
|
|
433
|
+
for (const line of contents.split("\n")) {
|
|
434
|
+
const trimmed = line.trim();
|
|
435
|
+
if (!trimmed) continue;
|
|
436
|
+
try {
|
|
437
|
+
const parsed = JSON.parse(trimmed);
|
|
438
|
+
if (parsed && typeof parsed === "object" && typeof parsed.event === "string") {
|
|
439
|
+
out.push(parsed);
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/services/config-store.ts
|
|
448
|
+
import fs2 from "fs";
|
|
449
|
+
import path2 from "path";
|
|
450
|
+
import os2 from "os";
|
|
451
|
+
var SETTINGS = [
|
|
452
|
+
{
|
|
453
|
+
key: "org",
|
|
454
|
+
description: "Azure DevOps organization name",
|
|
455
|
+
type: "string",
|
|
456
|
+
example: "mycompany",
|
|
457
|
+
required: true
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
key: "project",
|
|
461
|
+
description: "Azure DevOps project name",
|
|
462
|
+
type: "string",
|
|
463
|
+
example: "MyProject",
|
|
464
|
+
required: true
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
key: "fields",
|
|
468
|
+
description: "Extra work item fields to include (comma-separated reference names)",
|
|
469
|
+
type: "string[]",
|
|
470
|
+
example: "System.Tags,Custom.Priority",
|
|
471
|
+
required: false
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
key: "markdown",
|
|
475
|
+
description: "Convert rich text fields to markdown on display",
|
|
476
|
+
type: "boolean",
|
|
477
|
+
example: "true",
|
|
478
|
+
required: false
|
|
479
|
+
}
|
|
480
|
+
];
|
|
481
|
+
var VALID_KEYS = SETTINGS.map((s) => s.key);
|
|
482
|
+
function getConfigPath() {
|
|
483
|
+
return path2.join(os2.homedir(), ".azdo", "config.json");
|
|
484
|
+
}
|
|
485
|
+
function loadConfig() {
|
|
486
|
+
const configPath = getConfigPath();
|
|
487
|
+
let raw;
|
|
353
488
|
try {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
489
|
+
raw = fs2.readFileSync(configPath, "utf-8");
|
|
490
|
+
} catch (err) {
|
|
491
|
+
if (err.code === "ENOENT") {
|
|
492
|
+
return {};
|
|
493
|
+
}
|
|
494
|
+
throw err;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
return JSON.parse(raw);
|
|
357
498
|
} catch {
|
|
358
|
-
|
|
499
|
+
process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
|
|
500
|
+
`);
|
|
501
|
+
return {};
|
|
359
502
|
}
|
|
360
503
|
}
|
|
504
|
+
function saveConfig(config) {
|
|
505
|
+
const configPath = getConfigPath();
|
|
506
|
+
const dir = path2.dirname(configPath);
|
|
507
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
508
|
+
fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
509
|
+
}
|
|
510
|
+
function validateKey(key) {
|
|
511
|
+
if (!VALID_KEYS.includes(key)) {
|
|
512
|
+
throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function getConfigValue(key) {
|
|
516
|
+
validateKey(key);
|
|
517
|
+
const config = loadConfig();
|
|
518
|
+
return config[key];
|
|
519
|
+
}
|
|
520
|
+
function setConfigValue(key, value) {
|
|
521
|
+
validateKey(key);
|
|
522
|
+
const config = loadConfig();
|
|
523
|
+
if (value === "") {
|
|
524
|
+
delete config[key];
|
|
525
|
+
} else if (key === "markdown") {
|
|
526
|
+
if (value !== "true" && value !== "false") {
|
|
527
|
+
throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
|
|
528
|
+
}
|
|
529
|
+
config.markdown = value === "true";
|
|
530
|
+
} else if (key === "fields") {
|
|
531
|
+
config.fields = value.split(",").map((s) => s.trim());
|
|
532
|
+
} else {
|
|
533
|
+
config[key] = value;
|
|
534
|
+
}
|
|
535
|
+
saveConfig(config);
|
|
536
|
+
}
|
|
537
|
+
function unsetConfigValue(key) {
|
|
538
|
+
validateKey(key);
|
|
539
|
+
const config = loadConfig();
|
|
540
|
+
delete config[key];
|
|
541
|
+
saveConfig(config);
|
|
542
|
+
}
|
|
361
543
|
|
|
362
|
-
// src/services/auth.ts
|
|
544
|
+
// src/services/auth-masking.ts
|
|
545
|
+
var VISIBLE_CHARS = 5;
|
|
546
|
+
function maskedDisplay(pat) {
|
|
547
|
+
if (pat.length <= VISIBLE_CHARS * 2) {
|
|
548
|
+
return pat;
|
|
549
|
+
}
|
|
550
|
+
const hiddenCount = pat.length - VISIBLE_CHARS * 2;
|
|
551
|
+
return pat.slice(0, VISIBLE_CHARS) + "*".repeat(hiddenCount) + pat.slice(-VISIBLE_CHARS);
|
|
552
|
+
}
|
|
363
553
|
function normalizePat(rawPat) {
|
|
364
554
|
const trimmedPat = rawPat.trim();
|
|
365
555
|
return trimmedPat.length > 0 ? trimmedPat : null;
|
|
366
556
|
}
|
|
557
|
+
|
|
558
|
+
// src/services/credential-store.ts
|
|
559
|
+
var SERVICE = "azdo-cli";
|
|
560
|
+
var LEGACY_ACCOUNT = "pat";
|
|
561
|
+
function accountFor(org) {
|
|
562
|
+
return `pat:${org}`;
|
|
563
|
+
}
|
|
564
|
+
function probeBackend() {
|
|
565
|
+
switch (process.platform) {
|
|
566
|
+
case "win32":
|
|
567
|
+
return "windows-credential-manager";
|
|
568
|
+
case "darwin":
|
|
569
|
+
return "macos-keychain";
|
|
570
|
+
case "linux":
|
|
571
|
+
return "linux-libsecret";
|
|
572
|
+
default:
|
|
573
|
+
return "unknown";
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function wrapUnavailable(fn) {
|
|
577
|
+
try {
|
|
578
|
+
return fn();
|
|
579
|
+
} catch (err) {
|
|
580
|
+
throw new CredentialStoreUnavailableError(probeBackend(), err);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
var legacyUnsetNoticeEmitted = false;
|
|
584
|
+
function emitLegacyUnsetNoticeOnce() {
|
|
585
|
+
if (legacyUnsetNoticeEmitted) return;
|
|
586
|
+
legacyUnsetNoticeEmitted = true;
|
|
587
|
+
process.stderr.write(
|
|
588
|
+
'A legacy PAT exists in the OS vault from a previous azdo-cli version, but no "org" is set in config. Run `azdo auth --org <name>` to re-store it under the per-org key, then `azdo clear-pat` to remove the legacy slot.\n'
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
async function maybeMigrateLegacy(targetOrg) {
|
|
592
|
+
const config = loadConfig();
|
|
593
|
+
if (!config.org || config.org !== targetOrg) {
|
|
594
|
+
if (!config.org) {
|
|
595
|
+
let legacyExists;
|
|
596
|
+
try {
|
|
597
|
+
const legacyEntry2 = new Entry(SERVICE, LEGACY_ACCOUNT);
|
|
598
|
+
legacyExists = legacyEntry2.getPassword() !== null;
|
|
599
|
+
} catch {
|
|
600
|
+
legacyExists = false;
|
|
601
|
+
}
|
|
602
|
+
if (legacyExists) {
|
|
603
|
+
emitLegacyUnsetNoticeOnce();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
const newEntry = new Entry(SERVICE, accountFor(targetOrg));
|
|
609
|
+
const existingNew = wrapUnavailable(() => newEntry.getPassword());
|
|
610
|
+
if (existingNew !== null) {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
const legacyEntry = new Entry(SERVICE, LEGACY_ACCOUNT);
|
|
614
|
+
const legacy = wrapUnavailable(() => legacyEntry.getPassword());
|
|
615
|
+
if (legacy === null) {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
wrapUnavailable(() => {
|
|
619
|
+
newEntry.setPassword(legacy);
|
|
620
|
+
legacyEntry.deletePassword();
|
|
621
|
+
});
|
|
622
|
+
appendAuthAuditEvent({
|
|
623
|
+
event: "auth.store",
|
|
624
|
+
org: targetOrg,
|
|
625
|
+
backend: probeBackend(),
|
|
626
|
+
masked_pat: maskedDisplay(legacy)
|
|
627
|
+
});
|
|
628
|
+
process.stderr.write(`Migrated legacy PAT to org ${targetOrg}.
|
|
629
|
+
`);
|
|
630
|
+
return legacy;
|
|
631
|
+
}
|
|
632
|
+
async function getPat(org) {
|
|
633
|
+
const entry = new Entry(SERVICE, accountFor(org));
|
|
634
|
+
const value = wrapUnavailable(() => entry.getPassword());
|
|
635
|
+
if (value !== null) {
|
|
636
|
+
return value;
|
|
637
|
+
}
|
|
638
|
+
const migrated = await maybeMigrateLegacy(org);
|
|
639
|
+
return migrated;
|
|
640
|
+
}
|
|
641
|
+
async function storePat(org, pat) {
|
|
642
|
+
const entry = new Entry(SERVICE, accountFor(org));
|
|
643
|
+
wrapUnavailable(() => entry.setPassword(pat));
|
|
644
|
+
appendAuthAuditEvent({
|
|
645
|
+
event: "auth.store",
|
|
646
|
+
org,
|
|
647
|
+
backend: probeBackend(),
|
|
648
|
+
masked_pat: maskedDisplay(pat)
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
async function deletePat(org) {
|
|
652
|
+
const entry = new Entry(SERVICE, accountFor(org));
|
|
653
|
+
const existing = wrapUnavailable(() => entry.getPassword());
|
|
654
|
+
if (existing === null) {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
wrapUnavailable(() => entry.deletePassword());
|
|
658
|
+
appendAuthAuditEvent({
|
|
659
|
+
event: "auth.delete",
|
|
660
|
+
org,
|
|
661
|
+
backend: probeBackend(),
|
|
662
|
+
masked_pat: maskedDisplay(existing)
|
|
663
|
+
});
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
async function listOrgsWithStoredPat() {
|
|
667
|
+
const seen = /* @__PURE__ */ new Set();
|
|
668
|
+
for (const ev of readAuditEvents()) {
|
|
669
|
+
if (ev.event === "auth.store") {
|
|
670
|
+
seen.add(ev.org);
|
|
671
|
+
} else if (ev.event === "auth.delete") {
|
|
672
|
+
seen.delete(ev.org);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const present = [];
|
|
676
|
+
for (const org of seen) {
|
|
677
|
+
const entry = new Entry(SERVICE, accountFor(org));
|
|
678
|
+
const value = wrapUnavailable(() => entry.getPassword());
|
|
679
|
+
if (value !== null) {
|
|
680
|
+
present.push(org);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
present.sort((a, b) => a.localeCompare(b));
|
|
684
|
+
return present;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/services/auth.ts
|
|
688
|
+
var PAT_PROMPT = "Enter your Azure DevOps PAT: ";
|
|
367
689
|
async function promptForPat() {
|
|
368
690
|
if (!process.stdin.isTTY) {
|
|
369
691
|
return null;
|
|
@@ -371,12 +693,16 @@ async function promptForPat() {
|
|
|
371
693
|
return new Promise((resolve2) => {
|
|
372
694
|
const rl = createInterface({
|
|
373
695
|
input: process.stdin,
|
|
374
|
-
|
|
696
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
697
|
+
output: null
|
|
375
698
|
});
|
|
376
|
-
process.stderr.write(
|
|
699
|
+
process.stderr.write(PAT_PROMPT);
|
|
377
700
|
process.stdin.setRawMode(true);
|
|
378
701
|
process.stdin.resume();
|
|
379
702
|
let pat = "";
|
|
703
|
+
const redraw = () => {
|
|
704
|
+
process.stderr.write(`\r${PAT_PROMPT}${maskedDisplay(pat)}\x1B[K`);
|
|
705
|
+
};
|
|
380
706
|
const onData = (key) => {
|
|
381
707
|
const ch = key.toString("utf8");
|
|
382
708
|
if (ch === "") {
|
|
@@ -394,37 +720,77 @@ async function promptForPat() {
|
|
|
394
720
|
} else if (ch === "\x7F" || ch === "\b") {
|
|
395
721
|
if (pat.length > 0) {
|
|
396
722
|
pat = pat.slice(0, -1);
|
|
397
|
-
|
|
723
|
+
redraw();
|
|
398
724
|
}
|
|
399
725
|
} else {
|
|
400
726
|
pat += ch;
|
|
401
|
-
|
|
727
|
+
redraw();
|
|
402
728
|
}
|
|
403
729
|
};
|
|
404
730
|
process.stdin.on("data", onData);
|
|
405
731
|
});
|
|
406
732
|
}
|
|
407
|
-
|
|
733
|
+
function findDotEnvPat(startDir = process.cwd()) {
|
|
734
|
+
let current = startDir;
|
|
735
|
+
while (true) {
|
|
736
|
+
const envFile = join(current, ".env");
|
|
737
|
+
if (existsSync(envFile)) {
|
|
738
|
+
const contents = readFileSync2(envFile, "utf8");
|
|
739
|
+
for (const line of contents.split("\n")) {
|
|
740
|
+
const match = line.match(/^AZDO_PAT\s*=\s*(.+)$/);
|
|
741
|
+
if (match) {
|
|
742
|
+
const value = match[1].trim().replace(/^["']|["']$/g, "");
|
|
743
|
+
if (value.length > 0) return value;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const parent = dirname2(current);
|
|
748
|
+
if (parent === current) break;
|
|
749
|
+
current = parent;
|
|
750
|
+
}
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
async function resolvePat(org) {
|
|
408
754
|
const envPat = process.env.AZDO_PAT;
|
|
409
|
-
if (envPat) {
|
|
755
|
+
if (envPat && envPat.length > 0) {
|
|
410
756
|
return { pat: envPat, source: "env" };
|
|
411
757
|
}
|
|
412
|
-
const storedPat = await getPat();
|
|
758
|
+
const storedPat = await getPat(org);
|
|
413
759
|
if (storedPat !== null) {
|
|
414
760
|
return { pat: storedPat, source: "credential-store" };
|
|
415
761
|
}
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
762
|
+
const dotEnvPat = findDotEnvPat();
|
|
763
|
+
if (dotEnvPat !== null) {
|
|
764
|
+
return { pat: dotEnvPat, source: "env" };
|
|
765
|
+
}
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
async function requirePat(org) {
|
|
769
|
+
const cred = await resolvePat(org);
|
|
770
|
+
if (cred !== null) {
|
|
771
|
+
return cred;
|
|
423
772
|
}
|
|
424
773
|
throw new Error(
|
|
425
|
-
"
|
|
774
|
+
`No PAT available for org "${org}". Set AZDO_PAT environment variable or run \`azdo auth --org ${org}\`.`
|
|
426
775
|
);
|
|
427
776
|
}
|
|
777
|
+
async function validatePatAgainstAzdo(pat, org) {
|
|
778
|
+
const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?$top=1&api-version=7.1`;
|
|
779
|
+
const auth = Buffer.from(`:${pat}`).toString("base64");
|
|
780
|
+
const response = await fetch(url, {
|
|
781
|
+
headers: {
|
|
782
|
+
Authorization: `Basic ${auth}`,
|
|
783
|
+
Accept: "application/json"
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
if (response.status === 200) {
|
|
787
|
+
return { ok: true, status: 200 };
|
|
788
|
+
}
|
|
789
|
+
if (response.status === 401 || response.status === 403) {
|
|
790
|
+
return { ok: false, status: response.status };
|
|
791
|
+
}
|
|
792
|
+
throw new Error(`Azure DevOps returned HTTP ${response.status} while validating PAT for org "${org}".`);
|
|
793
|
+
}
|
|
428
794
|
|
|
429
795
|
// src/services/git-remote.ts
|
|
430
796
|
import { execSync } from "child_process";
|
|
@@ -496,122 +862,57 @@ function getCurrentBranch() {
|
|
|
496
862
|
return branch;
|
|
497
863
|
}
|
|
498
864
|
|
|
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;
|
|
865
|
+
// src/services/org-resolver.ts
|
|
866
|
+
function defaultDetectFromGit() {
|
|
540
867
|
try {
|
|
541
|
-
|
|
542
|
-
} catch (err) {
|
|
543
|
-
if (err.code === "ENOENT") {
|
|
544
|
-
return {};
|
|
545
|
-
}
|
|
546
|
-
throw err;
|
|
547
|
-
}
|
|
548
|
-
try {
|
|
549
|
-
return JSON.parse(raw);
|
|
868
|
+
return detectAzdoContext().org ?? null;
|
|
550
869
|
} catch {
|
|
551
|
-
|
|
552
|
-
`);
|
|
553
|
-
return {};
|
|
870
|
+
return null;
|
|
554
871
|
}
|
|
555
872
|
}
|
|
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");
|
|
873
|
+
function defaultReadConfig() {
|
|
874
|
+
return loadConfig();
|
|
561
875
|
}
|
|
562
|
-
function
|
|
563
|
-
if (
|
|
564
|
-
|
|
876
|
+
function resolveOrg(options) {
|
|
877
|
+
if (options.org && options.org.length > 0) {
|
|
878
|
+
return { org: options.org, source: "flag" };
|
|
565
879
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const config = loadConfig();
|
|
570
|
-
return config[key];
|
|
571
|
-
}
|
|
572
|
-
function setConfigValue(key, value) {
|
|
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;
|
|
880
|
+
const gitOrg = (options.detectFromGit ?? defaultDetectFromGit)();
|
|
881
|
+
if (gitOrg && gitOrg.length > 0) {
|
|
882
|
+
return { org: gitOrg, source: "git" };
|
|
586
883
|
}
|
|
587
|
-
|
|
884
|
+
const configOrg = (options.readConfig ?? defaultReadConfig)().org;
|
|
885
|
+
if (configOrg && configOrg.length > 0) {
|
|
886
|
+
return { org: configOrg, source: "config" };
|
|
887
|
+
}
|
|
888
|
+
return null;
|
|
588
889
|
}
|
|
589
|
-
function
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
890
|
+
function formatResolutionError() {
|
|
891
|
+
return [
|
|
892
|
+
"Could not resolve an Azure DevOps organization. Options (in priority order):",
|
|
893
|
+
" 1. Pass --org <name> on the command line.",
|
|
894
|
+
" 2. Run this command from a git repo whose origin remote is an Azure DevOps URL.",
|
|
895
|
+
" 3. Run `azdo config set org <name>` once to set a persistent default."
|
|
896
|
+
].join("\n");
|
|
594
897
|
}
|
|
595
898
|
|
|
596
899
|
// src/services/context.ts
|
|
597
900
|
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
|
-
}
|
|
901
|
+
const resolvedOrg = resolveOrg({ org: options.org });
|
|
605
902
|
let gitContext = null;
|
|
606
903
|
try {
|
|
607
904
|
gitContext = detectAzdoContext();
|
|
608
905
|
} catch {
|
|
609
906
|
}
|
|
610
|
-
const
|
|
611
|
-
const
|
|
907
|
+
const config = loadConfig();
|
|
908
|
+
const org = resolvedOrg?.org;
|
|
909
|
+
const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || config.project;
|
|
612
910
|
if (org && project) {
|
|
613
911
|
return { org, project };
|
|
614
912
|
}
|
|
913
|
+
if (!org) {
|
|
914
|
+
throw new Error(formatResolutionError());
|
|
915
|
+
}
|
|
615
916
|
throw new Error(
|
|
616
917
|
'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
918
|
);
|
|
@@ -771,7 +1072,7 @@ function stripHtml(html) {
|
|
|
771
1072
|
text = text.replaceAll(/<br\s*\/?>/gi, "\n");
|
|
772
1073
|
text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
|
|
773
1074
|
text = text.replaceAll(/<li>/gi, "\n");
|
|
774
|
-
text = text
|
|
1075
|
+
text = removeHtmlTags(text);
|
|
775
1076
|
text = text.replaceAll("&", "&");
|
|
776
1077
|
text = text.replaceAll("<", "<");
|
|
777
1078
|
text = text.replaceAll(">", ">");
|
|
@@ -781,22 +1082,71 @@ function stripHtml(html) {
|
|
|
781
1082
|
text = text.replaceAll(/\n{3,}/g, "\n\n");
|
|
782
1083
|
return text.trim();
|
|
783
1084
|
}
|
|
1085
|
+
function removeHtmlTags(value) {
|
|
1086
|
+
let result = "";
|
|
1087
|
+
let insideTag = false;
|
|
1088
|
+
for (const char of value) {
|
|
1089
|
+
if (char === "<") {
|
|
1090
|
+
insideTag = true;
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (char === ">" && insideTag) {
|
|
1094
|
+
insideTag = false;
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
if (!insideTag) {
|
|
1098
|
+
result += char;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return result;
|
|
1102
|
+
}
|
|
784
1103
|
function convertRichText(html, markdown) {
|
|
785
1104
|
if (!html) return "";
|
|
786
1105
|
return markdown ? toMarkdown(html) : stripHtml(html);
|
|
787
1106
|
}
|
|
1107
|
+
function formatMarkdownField(fieldLabel, value) {
|
|
1108
|
+
if (value.includes("\n")) {
|
|
1109
|
+
return `${fieldLabel}:
|
|
1110
|
+
${value}`;
|
|
1111
|
+
}
|
|
1112
|
+
return `${fieldLabel}: ${value}`;
|
|
1113
|
+
}
|
|
788
1114
|
function formatExtraFields(extraFields, markdown) {
|
|
789
1115
|
return Object.entries(extraFields).map(([refName, value]) => {
|
|
790
1116
|
const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
|
|
791
|
-
|
|
792
|
-
|
|
1117
|
+
if (markdown) {
|
|
1118
|
+
const displayValue = toMarkdown(value);
|
|
1119
|
+
return formatMarkdownField(fieldLabel, displayValue);
|
|
1120
|
+
}
|
|
1121
|
+
return formatMarkdownField(fieldLabel, value);
|
|
793
1122
|
});
|
|
794
1123
|
}
|
|
795
|
-
function summarizeDescription(text, label) {
|
|
1124
|
+
function summarizeDescription(text, label, markdown) {
|
|
796
1125
|
const descLines = text.split("\n").filter((l) => l.trim() !== "");
|
|
797
1126
|
const firstThree = descLines.slice(0, 3);
|
|
798
1127
|
const suffix = descLines.length > 3 ? "\n..." : "";
|
|
799
|
-
|
|
1128
|
+
const content = `${firstThree.join("\n")}${suffix}`;
|
|
1129
|
+
if (markdown) {
|
|
1130
|
+
return [formatMarkdownField("Description", content)];
|
|
1131
|
+
}
|
|
1132
|
+
return [`${label("Description:")}${content}`];
|
|
1133
|
+
}
|
|
1134
|
+
function formatFileSize(bytes) {
|
|
1135
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1136
|
+
const kb = bytes / 1024;
|
|
1137
|
+
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
|
1138
|
+
const mb = kb / 1024;
|
|
1139
|
+
return `${mb.toFixed(1)} MB`;
|
|
1140
|
+
}
|
|
1141
|
+
function formatAttachments(attachments, short) {
|
|
1142
|
+
if (short) {
|
|
1143
|
+
return [`Attachments: ${attachments.length}`];
|
|
1144
|
+
}
|
|
1145
|
+
const lines = ["", "Attachments:"];
|
|
1146
|
+
for (const att of attachments) {
|
|
1147
|
+
lines.push(` ${att.name} (${formatFileSize(att.size)})`);
|
|
1148
|
+
}
|
|
1149
|
+
return lines;
|
|
800
1150
|
}
|
|
801
1151
|
function formatWorkItem(workItem, short, markdown = false) {
|
|
802
1152
|
const label = (name) => name.padEnd(13);
|
|
@@ -813,59 +1163,361 @@ function formatWorkItem(workItem, short, markdown = false) {
|
|
|
813
1163
|
`${label("Iteration:")}${workItem.iterationPath}`
|
|
814
1164
|
);
|
|
815
1165
|
}
|
|
816
|
-
lines.push(`${label("URL:")}${workItem.url}`);
|
|
817
|
-
if (workItem.extraFields) {
|
|
818
|
-
lines.push(...formatExtraFields(workItem.extraFields, markdown));
|
|
1166
|
+
lines.push(`${label("URL:")}${workItem.url}`);
|
|
1167
|
+
if (workItem.extraFields) {
|
|
1168
|
+
lines.push(...formatExtraFields(workItem.extraFields, markdown));
|
|
1169
|
+
}
|
|
1170
|
+
lines.push("");
|
|
1171
|
+
const descriptionText = convertRichText(workItem.description, markdown);
|
|
1172
|
+
if (short) {
|
|
1173
|
+
lines.push(...summarizeDescription(descriptionText, label, markdown));
|
|
1174
|
+
} else {
|
|
1175
|
+
lines.push("Description:", descriptionText);
|
|
1176
|
+
}
|
|
1177
|
+
if (workItem.attachments) {
|
|
1178
|
+
lines.push(...formatAttachments(workItem.attachments, short));
|
|
1179
|
+
}
|
|
1180
|
+
return lines.join("\n");
|
|
1181
|
+
}
|
|
1182
|
+
function createGetItemCommand() {
|
|
1183
|
+
const command = new Command("get-item");
|
|
1184
|
+
command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown").action(
|
|
1185
|
+
async (idStr, options) => {
|
|
1186
|
+
const id = parseWorkItemId(idStr);
|
|
1187
|
+
validateOrgProjectPair(options);
|
|
1188
|
+
let context;
|
|
1189
|
+
try {
|
|
1190
|
+
context = resolveContext(options);
|
|
1191
|
+
const credential = await requirePat(context.org);
|
|
1192
|
+
const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
|
|
1193
|
+
const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
|
|
1194
|
+
const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
|
|
1195
|
+
const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
|
|
1196
|
+
process.stdout.write(output + "\n");
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
handleCommandError(err, id, context, "read", false);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
);
|
|
1202
|
+
return command;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// src/commands/clear-pat.ts
|
|
1206
|
+
import { Command as Command2 } from "commander";
|
|
1207
|
+
function createClearPatCommand() {
|
|
1208
|
+
const command = new Command2("clear-pat");
|
|
1209
|
+
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) => {
|
|
1210
|
+
process.stderr.write("`azdo clear-pat` is deprecated; use `azdo auth logout [--org <name>]` instead.\n");
|
|
1211
|
+
const resolved = resolveOrg({ org: options.org });
|
|
1212
|
+
if (!resolved) {
|
|
1213
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1214
|
+
`);
|
|
1215
|
+
process.exitCode = 3;
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
const deleted = await deletePat(resolved.org);
|
|
1219
|
+
if (deleted) {
|
|
1220
|
+
process.stdout.write(`PAT removed for org ${resolved.org}.
|
|
1221
|
+
`);
|
|
1222
|
+
} else {
|
|
1223
|
+
process.stdout.write(`No stored PAT found for org ${resolved.org}.
|
|
1224
|
+
`);
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
return command;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// src/commands/auth.ts
|
|
1231
|
+
import { Command as Command3 } from "commander";
|
|
1232
|
+
|
|
1233
|
+
// src/services/browser-open.ts
|
|
1234
|
+
import { execFile } from "child_process";
|
|
1235
|
+
function isHeadless(platform, hasDisplay) {
|
|
1236
|
+
if (platform === "linux") {
|
|
1237
|
+
return !hasDisplay;
|
|
1238
|
+
}
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
function commandForPlatform(platform) {
|
|
1242
|
+
switch (platform) {
|
|
1243
|
+
case "darwin":
|
|
1244
|
+
return { cmd: "open", args: (url) => [url] };
|
|
1245
|
+
case "win32":
|
|
1246
|
+
return { cmd: "cmd", args: (url) => ["/c", "start", '""', url] };
|
|
1247
|
+
case "linux":
|
|
1248
|
+
return { cmd: "xdg-open", args: (url) => [url] };
|
|
1249
|
+
default:
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
async function openUrl(url, opts = {}) {
|
|
1254
|
+
const platform = opts.platform ?? process.platform;
|
|
1255
|
+
const hasDisplay = opts.hasDisplay ?? (process.env.DISPLAY !== void 0 && process.env.DISPLAY !== "");
|
|
1256
|
+
const forcePrint = opts.forcePrint ?? false;
|
|
1257
|
+
if (forcePrint || isHeadless(platform, hasDisplay)) {
|
|
1258
|
+
process.stderr.write(`Open this URL in your browser: ${url}
|
|
1259
|
+
`);
|
|
1260
|
+
return "printed";
|
|
1261
|
+
}
|
|
1262
|
+
const spec = commandForPlatform(platform);
|
|
1263
|
+
if (!spec) {
|
|
1264
|
+
process.stderr.write(`Open this URL in your browser: ${url}
|
|
1265
|
+
`);
|
|
1266
|
+
return "printed";
|
|
1267
|
+
}
|
|
1268
|
+
const runner = opts.execFileFn ?? ((cmd, args, cb) => execFile(cmd, args, { timeout: 5e3 }, (err) => cb(err)));
|
|
1269
|
+
return await new Promise((resolve2) => {
|
|
1270
|
+
try {
|
|
1271
|
+
runner(spec.cmd, spec.args(url), (err) => {
|
|
1272
|
+
if (err) {
|
|
1273
|
+
process.stderr.write(`Open this URL in your browser: ${url}
|
|
1274
|
+
`);
|
|
1275
|
+
resolve2("printed");
|
|
1276
|
+
} else {
|
|
1277
|
+
resolve2("opened");
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
} catch {
|
|
1281
|
+
process.stderr.write(`Open this URL in your browser: ${url}
|
|
1282
|
+
`);
|
|
1283
|
+
resolve2("printed");
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// src/commands/auth.ts
|
|
1289
|
+
async function readStdinToString() {
|
|
1290
|
+
const chunks = [];
|
|
1291
|
+
for await (const chunk of process.stdin) {
|
|
1292
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1293
|
+
}
|
|
1294
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1295
|
+
}
|
|
1296
|
+
async function confirmOverwrite(org) {
|
|
1297
|
+
if (!process.stdin.isTTY) return true;
|
|
1298
|
+
process.stderr.write(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
|
|
1299
|
+
return await new Promise((resolve2) => {
|
|
1300
|
+
process.stdin.setEncoding("utf8");
|
|
1301
|
+
let answered = false;
|
|
1302
|
+
const handler = (data) => {
|
|
1303
|
+
if (answered) return;
|
|
1304
|
+
answered = true;
|
|
1305
|
+
process.stdin.removeListener("data", handler);
|
|
1306
|
+
process.stdin.pause();
|
|
1307
|
+
const trimmed = data.trim().toLowerCase();
|
|
1308
|
+
resolve2(trimmed === "y" || trimmed === "yes");
|
|
1309
|
+
};
|
|
1310
|
+
process.stdin.resume();
|
|
1311
|
+
process.stdin.on("data", handler);
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
async function handleAuthRoot(options) {
|
|
1315
|
+
const resolved = resolveOrg({ org: options.org });
|
|
1316
|
+
if (!resolved) {
|
|
1317
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1318
|
+
`);
|
|
1319
|
+
process.exitCode = 3;
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const org = resolved.org;
|
|
1323
|
+
const wantBrowser = options.browser !== false && !options.fromStdin;
|
|
1324
|
+
if (wantBrowser) {
|
|
1325
|
+
const url = `https://dev.azure.com/${encodeURIComponent(org)}/_usersSettings/tokens`;
|
|
1326
|
+
await openUrl(url);
|
|
1327
|
+
}
|
|
1328
|
+
const raw = options.fromStdin ? await readStdinToString() : await promptForPat();
|
|
1329
|
+
const pat = raw ? normalizePat(raw) : null;
|
|
1330
|
+
if (!pat) {
|
|
1331
|
+
process.stderr.write("No PAT provided. Aborting.\n");
|
|
1332
|
+
process.exitCode = 1;
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
let validation;
|
|
1336
|
+
try {
|
|
1337
|
+
validation = await validatePatAgainstAzdo(pat, org);
|
|
1338
|
+
} catch (err) {
|
|
1339
|
+
process.stderr.write(`Could not reach Azure DevOps to validate PAT: ${err.message}
|
|
1340
|
+
`);
|
|
1341
|
+
process.exitCode = 1;
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (!validation.ok) {
|
|
1345
|
+
appendAuthAuditEvent({ event: "auth.validate.fail", org, backend: probeBackend() });
|
|
1346
|
+
process.stderr.write(`PAT validation failed (HTTP ${validation.status}). Token NOT stored.
|
|
1347
|
+
`);
|
|
1348
|
+
process.exitCode = 2;
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
appendAuthAuditEvent({
|
|
1352
|
+
event: "auth.validate.ok",
|
|
1353
|
+
org,
|
|
1354
|
+
backend: probeBackend(),
|
|
1355
|
+
masked_pat: maskedDisplay(pat)
|
|
1356
|
+
});
|
|
1357
|
+
try {
|
|
1358
|
+
const existing = await getPat(org);
|
|
1359
|
+
if (existing !== null) {
|
|
1360
|
+
const overwrite = await confirmOverwrite(org);
|
|
1361
|
+
if (!overwrite) {
|
|
1362
|
+
process.stderr.write("Aborted. Existing PAT preserved.\n");
|
|
1363
|
+
process.exitCode = 1;
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
await storePat(org, pat);
|
|
1368
|
+
} catch (err) {
|
|
1369
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1370
|
+
process.stderr.write(`${err.message}
|
|
1371
|
+
`);
|
|
1372
|
+
process.exitCode = 4;
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
throw err;
|
|
1376
|
+
}
|
|
1377
|
+
process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
|
|
1378
|
+
`);
|
|
1379
|
+
}
|
|
1380
|
+
async function handleStatus(options, org) {
|
|
1381
|
+
let backend;
|
|
1382
|
+
let value;
|
|
1383
|
+
try {
|
|
1384
|
+
backend = probeBackend();
|
|
1385
|
+
value = await getPat(org);
|
|
1386
|
+
} catch (err) {
|
|
1387
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1388
|
+
process.stderr.write(`${err.message}
|
|
1389
|
+
`);
|
|
1390
|
+
process.exitCode = 4;
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
throw err;
|
|
819
1394
|
}
|
|
820
|
-
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
1395
|
+
const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
|
|
1396
|
+
const last = storedEvents[storedEvents.length - 1];
|
|
1397
|
+
const updatedAt = last?.ts ?? null;
|
|
1398
|
+
if (!value) {
|
|
1399
|
+
if (options.json) {
|
|
1400
|
+
process.stdout.write(
|
|
1401
|
+
`${JSON.stringify({ org, backend, stored: false, masked: null, updated_at: updatedAt })}
|
|
1402
|
+
`
|
|
1403
|
+
);
|
|
1404
|
+
} else {
|
|
1405
|
+
process.stdout.write(`Organization: ${org}
|
|
1406
|
+
Backend: ${backend}
|
|
1407
|
+
Stored: no
|
|
1408
|
+
`);
|
|
1409
|
+
}
|
|
1410
|
+
process.exitCode = 1;
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const masked = maskedDisplay(value);
|
|
1414
|
+
if (options.json) {
|
|
1415
|
+
process.stdout.write(
|
|
1416
|
+
`${JSON.stringify({ org, backend, stored: true, masked, updated_at: updatedAt })}
|
|
1417
|
+
`
|
|
1418
|
+
);
|
|
824
1419
|
} else {
|
|
825
|
-
|
|
1420
|
+
process.stdout.write(
|
|
1421
|
+
`Organization: ${org}
|
|
1422
|
+
Backend: ${backend}
|
|
1423
|
+
Stored: yes
|
|
1424
|
+
Identifier: ${masked}
|
|
1425
|
+
` + (updatedAt ? `Last updated: ${updatedAt}
|
|
1426
|
+
` : "")
|
|
1427
|
+
);
|
|
826
1428
|
}
|
|
827
|
-
return lines.join("\n");
|
|
828
1429
|
}
|
|
829
|
-
function
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1430
|
+
async function handleLogout(options, orgFromGlobal) {
|
|
1431
|
+
if (options.all && orgFromGlobal) {
|
|
1432
|
+
process.stderr.write("--org and --all are mutually exclusive.\n");
|
|
1433
|
+
process.exitCode = 1;
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
if (options.all) {
|
|
1437
|
+
let orgs;
|
|
1438
|
+
try {
|
|
1439
|
+
orgs = await listOrgsWithStoredPat();
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1442
|
+
process.stderr.write(`${err.message}
|
|
1443
|
+
`);
|
|
1444
|
+
process.exitCode = 4;
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
throw err;
|
|
1448
|
+
}
|
|
1449
|
+
if (orgs.length === 0) {
|
|
1450
|
+
process.stdout.write("No stored PATs to remove.\n");
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
for (const org of orgs) {
|
|
836
1454
|
try {
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
|
|
841
|
-
const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
|
|
842
|
-
const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
|
|
843
|
-
process.stdout.write(output + "\n");
|
|
1455
|
+
await deletePat(org);
|
|
1456
|
+
process.stdout.write(`PAT removed for org ${org}.
|
|
1457
|
+
`);
|
|
844
1458
|
} catch (err) {
|
|
845
|
-
|
|
1459
|
+
process.stderr.write(`Failed to remove PAT for org ${org}: ${err.message}
|
|
1460
|
+
`);
|
|
1461
|
+
process.exitCode = 1;
|
|
846
1462
|
}
|
|
847
1463
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
const resolved = resolveOrg({ org: orgFromGlobal });
|
|
1467
|
+
if (!resolved) {
|
|
1468
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1469
|
+
`);
|
|
1470
|
+
process.exitCode = 3;
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
try {
|
|
1474
|
+
const removed = await deletePat(resolved.org);
|
|
1475
|
+
if (removed) {
|
|
1476
|
+
process.stdout.write(`PAT removed for org ${resolved.org}.
|
|
1477
|
+
`);
|
|
860
1478
|
} else {
|
|
861
|
-
process.stdout.write(
|
|
1479
|
+
process.stdout.write(`No stored PAT found for org ${resolved.org}.
|
|
1480
|
+
`);
|
|
1481
|
+
}
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
if (err instanceof CredentialStoreUnavailableError) {
|
|
1484
|
+
process.stderr.write(`${err.message}
|
|
1485
|
+
`);
|
|
1486
|
+
process.exitCode = 4;
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
throw err;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
function createAuthCommand() {
|
|
1493
|
+
const command = new Command3("auth");
|
|
1494
|
+
command.description("Manage Azure DevOps Personal Access Tokens (PAT) in the OS secret vault");
|
|
1495
|
+
command.option("--org <name>", "Azure DevOps organization (flag wins over auto-detect / config)").option("--from-stdin", "read PAT from stdin instead of prompting", false).option("--no-browser", "do not open the Azure DevOps PAT page in a browser");
|
|
1496
|
+
command.action(async (options) => {
|
|
1497
|
+
await handleAuthRoot(options);
|
|
1498
|
+
});
|
|
1499
|
+
const statusCmd = command.command("status").description("Report whether a PAT is stored for the resolved org (masked, never the full value)").option("--json", "emit a JSON object", false);
|
|
1500
|
+
statusCmd.action(async (options) => {
|
|
1501
|
+
const globals = statusCmd.optsWithGlobals();
|
|
1502
|
+
const resolved = resolveOrg({ org: globals.org });
|
|
1503
|
+
if (!resolved) {
|
|
1504
|
+
process.stderr.write(`${formatResolutionError()}
|
|
1505
|
+
`);
|
|
1506
|
+
process.exitCode = 3;
|
|
1507
|
+
return;
|
|
862
1508
|
}
|
|
1509
|
+
await handleStatus(options, resolved.org);
|
|
1510
|
+
});
|
|
1511
|
+
const logoutCmd = command.command("logout").description("Remove the stored PAT for an org (or all orgs with --all)").option("--all", "remove the stored PAT for every org", false);
|
|
1512
|
+
logoutCmd.action(async (options) => {
|
|
1513
|
+
const globals = logoutCmd.optsWithGlobals();
|
|
1514
|
+
await handleLogout(options, globals.org);
|
|
863
1515
|
});
|
|
864
1516
|
return command;
|
|
865
1517
|
}
|
|
866
1518
|
|
|
867
1519
|
// src/commands/config.ts
|
|
868
|
-
import { Command as
|
|
1520
|
+
import { Command as Command4 } from "commander";
|
|
869
1521
|
import { createInterface as createInterface2 } from "readline";
|
|
870
1522
|
function formatConfigValue(value, unsetFallback = "") {
|
|
871
1523
|
if (value === void 0) {
|
|
@@ -925,9 +1577,9 @@ async function promptForSetting(cfg, setting, ask) {
|
|
|
925
1577
|
`);
|
|
926
1578
|
}
|
|
927
1579
|
function createConfigCommand() {
|
|
928
|
-
const config = new
|
|
1580
|
+
const config = new Command4("config");
|
|
929
1581
|
config.description("Manage CLI settings");
|
|
930
|
-
const set = new
|
|
1582
|
+
const set = new Command4("set");
|
|
931
1583
|
set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields)").argument("<value>", "setting value").option("--json", "output in JSON format").action((key, value, options) => {
|
|
932
1584
|
try {
|
|
933
1585
|
setConfigValue(key, value);
|
|
@@ -948,7 +1600,7 @@ function createConfigCommand() {
|
|
|
948
1600
|
process.exit(1);
|
|
949
1601
|
}
|
|
950
1602
|
});
|
|
951
|
-
const get = new
|
|
1603
|
+
const get = new Command4("get");
|
|
952
1604
|
get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
953
1605
|
try {
|
|
954
1606
|
const value = getConfigValue(key);
|
|
@@ -971,7 +1623,7 @@ function createConfigCommand() {
|
|
|
971
1623
|
process.exit(1);
|
|
972
1624
|
}
|
|
973
1625
|
});
|
|
974
|
-
const list = new
|
|
1626
|
+
const list = new Command4("list");
|
|
975
1627
|
list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
|
|
976
1628
|
const cfg = loadConfig();
|
|
977
1629
|
if (options.json) {
|
|
@@ -980,7 +1632,7 @@ function createConfigCommand() {
|
|
|
980
1632
|
}
|
|
981
1633
|
writeConfigList(cfg);
|
|
982
1634
|
});
|
|
983
|
-
const unset = new
|
|
1635
|
+
const unset = new Command4("unset");
|
|
984
1636
|
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
985
1637
|
try {
|
|
986
1638
|
unsetConfigValue(key);
|
|
@@ -997,7 +1649,7 @@ function createConfigCommand() {
|
|
|
997
1649
|
process.exit(1);
|
|
998
1650
|
}
|
|
999
1651
|
});
|
|
1000
|
-
const wizard = new
|
|
1652
|
+
const wizard = new Command4("wizard");
|
|
1001
1653
|
wizard.description("Interactive wizard to configure all settings").action(async () => {
|
|
1002
1654
|
if (!process.stdin.isTTY) {
|
|
1003
1655
|
process.stderr.write(
|
|
@@ -1028,9 +1680,9 @@ function createConfigCommand() {
|
|
|
1028
1680
|
}
|
|
1029
1681
|
|
|
1030
1682
|
// src/commands/set-state.ts
|
|
1031
|
-
import { Command as
|
|
1683
|
+
import { Command as Command5 } from "commander";
|
|
1032
1684
|
function createSetStateCommand() {
|
|
1033
|
-
const command = new
|
|
1685
|
+
const command = new Command5("set-state");
|
|
1034
1686
|
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
1687
|
async (idStr, state, options) => {
|
|
1036
1688
|
const id = parseWorkItemId(idStr);
|
|
@@ -1038,7 +1690,7 @@ function createSetStateCommand() {
|
|
|
1038
1690
|
let context;
|
|
1039
1691
|
try {
|
|
1040
1692
|
context = resolveContext(options);
|
|
1041
|
-
const credential = await
|
|
1693
|
+
const credential = await requirePat(context.org);
|
|
1042
1694
|
const operations = [
|
|
1043
1695
|
{ op: "add", path: "/fields/System.State", value: state }
|
|
1044
1696
|
];
|
|
@@ -1066,9 +1718,9 @@ function createSetStateCommand() {
|
|
|
1066
1718
|
}
|
|
1067
1719
|
|
|
1068
1720
|
// src/commands/assign.ts
|
|
1069
|
-
import { Command as
|
|
1721
|
+
import { Command as Command6 } from "commander";
|
|
1070
1722
|
function createAssignCommand() {
|
|
1071
|
-
const command = new
|
|
1723
|
+
const command = new Command6("assign");
|
|
1072
1724
|
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
1725
|
async (idStr, name, options) => {
|
|
1074
1726
|
const id = parseWorkItemId(idStr);
|
|
@@ -1088,7 +1740,7 @@ function createAssignCommand() {
|
|
|
1088
1740
|
let context;
|
|
1089
1741
|
try {
|
|
1090
1742
|
context = resolveContext(options);
|
|
1091
|
-
const credential = await
|
|
1743
|
+
const credential = await requirePat(context.org);
|
|
1092
1744
|
const value = options.unassign ? "" : name;
|
|
1093
1745
|
const operations = [
|
|
1094
1746
|
{ op: "add", path: "/fields/System.AssignedTo", value }
|
|
@@ -1118,9 +1770,9 @@ function createAssignCommand() {
|
|
|
1118
1770
|
}
|
|
1119
1771
|
|
|
1120
1772
|
// src/commands/set-field.ts
|
|
1121
|
-
import { Command as
|
|
1773
|
+
import { Command as Command7 } from "commander";
|
|
1122
1774
|
function createSetFieldCommand() {
|
|
1123
|
-
const command = new
|
|
1775
|
+
const command = new Command7("set-field");
|
|
1124
1776
|
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
1777
|
async (idStr, field, value, options) => {
|
|
1126
1778
|
const id = parseWorkItemId(idStr);
|
|
@@ -1128,7 +1780,7 @@ function createSetFieldCommand() {
|
|
|
1128
1780
|
let context;
|
|
1129
1781
|
try {
|
|
1130
1782
|
context = resolveContext(options);
|
|
1131
|
-
const credential = await
|
|
1783
|
+
const credential = await requirePat(context.org);
|
|
1132
1784
|
const operations = [
|
|
1133
1785
|
{ op: "add", path: `/fields/${field}`, value }
|
|
1134
1786
|
];
|
|
@@ -1156,9 +1808,9 @@ function createSetFieldCommand() {
|
|
|
1156
1808
|
}
|
|
1157
1809
|
|
|
1158
1810
|
// src/commands/get-md-field.ts
|
|
1159
|
-
import { Command as
|
|
1811
|
+
import { Command as Command8 } from "commander";
|
|
1160
1812
|
function createGetMdFieldCommand() {
|
|
1161
|
-
const command = new
|
|
1813
|
+
const command = new Command8("get-md-field");
|
|
1162
1814
|
command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
|
|
1163
1815
|
async (idStr, field, options) => {
|
|
1164
1816
|
const id = parseWorkItemId(idStr);
|
|
@@ -1166,7 +1818,7 @@ function createGetMdFieldCommand() {
|
|
|
1166
1818
|
let context;
|
|
1167
1819
|
try {
|
|
1168
1820
|
context = resolveContext(options);
|
|
1169
|
-
const credential = await
|
|
1821
|
+
const credential = await requirePat(context.org);
|
|
1170
1822
|
const value = await getWorkItemFieldValue(context, id, credential.pat, field);
|
|
1171
1823
|
if (value === null) {
|
|
1172
1824
|
process.stdout.write("\n");
|
|
@@ -1182,8 +1834,8 @@ function createGetMdFieldCommand() {
|
|
|
1182
1834
|
}
|
|
1183
1835
|
|
|
1184
1836
|
// src/commands/set-md-field.ts
|
|
1185
|
-
import { existsSync, readFileSync as
|
|
1186
|
-
import { Command as
|
|
1837
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
1838
|
+
import { Command as Command9 } from "commander";
|
|
1187
1839
|
function fail(message) {
|
|
1188
1840
|
process.stderr.write(`Error: ${message}
|
|
1189
1841
|
`);
|
|
@@ -1202,11 +1854,11 @@ function resolveContent(inlineContent, options) {
|
|
|
1202
1854
|
return null;
|
|
1203
1855
|
}
|
|
1204
1856
|
function readFileContent(filePath) {
|
|
1205
|
-
if (!
|
|
1857
|
+
if (!existsSync2(filePath)) {
|
|
1206
1858
|
fail(`File not found: ${filePath}`);
|
|
1207
1859
|
}
|
|
1208
1860
|
try {
|
|
1209
|
-
return
|
|
1861
|
+
return readFileSync3(filePath, "utf-8");
|
|
1210
1862
|
} catch {
|
|
1211
1863
|
fail(`Cannot read file: ${filePath}`);
|
|
1212
1864
|
}
|
|
@@ -1245,7 +1897,7 @@ function formatOutput(result, options, field) {
|
|
|
1245
1897
|
}
|
|
1246
1898
|
}
|
|
1247
1899
|
function createSetMdFieldCommand() {
|
|
1248
|
-
const command = new
|
|
1900
|
+
const command = new Command9("set-md-field");
|
|
1249
1901
|
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
1902
|
async (idStr, field, inlineContent, options) => {
|
|
1251
1903
|
const id = parseWorkItemId(idStr);
|
|
@@ -1254,7 +1906,7 @@ function createSetMdFieldCommand() {
|
|
|
1254
1906
|
let context;
|
|
1255
1907
|
try {
|
|
1256
1908
|
context = resolveContext(options);
|
|
1257
|
-
const credential = await
|
|
1909
|
+
const credential = await requirePat(context.org);
|
|
1258
1910
|
const operations = [
|
|
1259
1911
|
{ op: "add", path: `/fields/${field}`, value: content },
|
|
1260
1912
|
{ op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
|
|
@@ -1270,8 +1922,8 @@ function createSetMdFieldCommand() {
|
|
|
1270
1922
|
}
|
|
1271
1923
|
|
|
1272
1924
|
// src/commands/upsert.ts
|
|
1273
|
-
import { existsSync as
|
|
1274
|
-
import { Command as
|
|
1925
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
|
|
1926
|
+
import { Command as Command10 } from "commander";
|
|
1275
1927
|
|
|
1276
1928
|
// src/services/task-document.ts
|
|
1277
1929
|
var FIELD_ALIASES = /* @__PURE__ */ new Map([
|
|
@@ -1434,12 +2086,12 @@ function loadSourceContent(options) {
|
|
|
1434
2086
|
return { content: options.content };
|
|
1435
2087
|
}
|
|
1436
2088
|
const filePath = options.file;
|
|
1437
|
-
if (!
|
|
2089
|
+
if (!existsSync3(filePath)) {
|
|
1438
2090
|
fail2(`File not found: ${filePath}`);
|
|
1439
2091
|
}
|
|
1440
2092
|
try {
|
|
1441
2093
|
return {
|
|
1442
|
-
content:
|
|
2094
|
+
content: readFileSync4(filePath, "utf-8"),
|
|
1443
2095
|
sourceFile: filePath
|
|
1444
2096
|
};
|
|
1445
2097
|
} catch {
|
|
@@ -1555,7 +2207,7 @@ function handleUpsertError(err, id, context) {
|
|
|
1555
2207
|
process.exit(1);
|
|
1556
2208
|
}
|
|
1557
2209
|
function createUpsertCommand() {
|
|
1558
|
-
const command = new
|
|
2210
|
+
const command = new Command10("upsert");
|
|
1559
2211
|
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
2212
|
validateOrgProjectPair(options);
|
|
1561
2213
|
const id = idStr === void 0 ? void 0 : parseWorkItemId(idStr);
|
|
@@ -1570,7 +2222,7 @@ function createUpsertCommand() {
|
|
|
1570
2222
|
ensureTitleForCreate(document.fields);
|
|
1571
2223
|
}
|
|
1572
2224
|
const operations = toPatchOperations(document.fields, action);
|
|
1573
|
-
const credential = await
|
|
2225
|
+
const credential = await requirePat(context.org);
|
|
1574
2226
|
let writeResult;
|
|
1575
2227
|
if (action === "created") {
|
|
1576
2228
|
writeResult = await createWorkItem(context, createType, credential.pat, operations);
|
|
@@ -1596,7 +2248,7 @@ function createUpsertCommand() {
|
|
|
1596
2248
|
}
|
|
1597
2249
|
|
|
1598
2250
|
// src/commands/list-fields.ts
|
|
1599
|
-
import { Command as
|
|
2251
|
+
import { Command as Command11 } from "commander";
|
|
1600
2252
|
function stringifyValue(value) {
|
|
1601
2253
|
if (value === null || value === void 0) return "";
|
|
1602
2254
|
if (typeof value === "object") return JSON.stringify(value);
|
|
@@ -1628,7 +2280,7 @@ function formatFieldList(fields) {
|
|
|
1628
2280
|
}).join("\n");
|
|
1629
2281
|
}
|
|
1630
2282
|
function createListFieldsCommand() {
|
|
1631
|
-
const command = new
|
|
2283
|
+
const command = new Command11("list-fields");
|
|
1632
2284
|
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
2285
|
async (idStr, options) => {
|
|
1634
2286
|
const id = parseWorkItemId(idStr);
|
|
@@ -1636,7 +2288,7 @@ function createListFieldsCommand() {
|
|
|
1636
2288
|
let context;
|
|
1637
2289
|
try {
|
|
1638
2290
|
context = resolveContext(options);
|
|
1639
|
-
const credential = await
|
|
2291
|
+
const credential = await requirePat(context.org);
|
|
1640
2292
|
const fields = await getWorkItemFields(context, id, credential.pat);
|
|
1641
2293
|
if (options.json) {
|
|
1642
2294
|
process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
|
|
@@ -1655,7 +2307,7 @@ function createListFieldsCommand() {
|
|
|
1655
2307
|
}
|
|
1656
2308
|
|
|
1657
2309
|
// src/commands/pr.ts
|
|
1658
|
-
import { Command as
|
|
2310
|
+
import { Command as Command12 } from "commander";
|
|
1659
2311
|
|
|
1660
2312
|
// src/services/pr-client.ts
|
|
1661
2313
|
function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
|
|
@@ -1672,6 +2324,13 @@ function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
|
|
|
1672
2324
|
}
|
|
1673
2325
|
return url;
|
|
1674
2326
|
}
|
|
2327
|
+
function buildPullRequestStatusesUrl(context, repo, prId) {
|
|
2328
|
+
const url = new URL(
|
|
2329
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/statuses`
|
|
2330
|
+
);
|
|
2331
|
+
url.searchParams.set("api-version", "7.1");
|
|
2332
|
+
return url;
|
|
2333
|
+
}
|
|
1675
2334
|
function mapPullRequest(repo, pullRequest) {
|
|
1676
2335
|
return {
|
|
1677
2336
|
id: pullRequest.pullRequestId,
|
|
@@ -1681,7 +2340,36 @@ function mapPullRequest(repo, pullRequest) {
|
|
|
1681
2340
|
targetRefName: pullRequest.targetRefName,
|
|
1682
2341
|
status: pullRequest.status,
|
|
1683
2342
|
createdBy: pullRequest.createdBy?.displayName ?? null,
|
|
1684
|
-
url: pullRequest._links
|
|
2343
|
+
url: pullRequest._links?.web?.href ?? null
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
function mapPullRequestCheckName(status) {
|
|
2347
|
+
const genre = status.context?.genre?.trim();
|
|
2348
|
+
const name = status.context?.name?.trim();
|
|
2349
|
+
if (genre && name) {
|
|
2350
|
+
return `${genre}/${name}`;
|
|
2351
|
+
}
|
|
2352
|
+
if (name) {
|
|
2353
|
+
return name;
|
|
2354
|
+
}
|
|
2355
|
+
if (genre) {
|
|
2356
|
+
return genre;
|
|
2357
|
+
}
|
|
2358
|
+
return `Status #${status.id}`;
|
|
2359
|
+
}
|
|
2360
|
+
function mapPullRequestCheck(status) {
|
|
2361
|
+
if (status.state === "notApplicable" || status.state === "notSet") {
|
|
2362
|
+
return null;
|
|
2363
|
+
}
|
|
2364
|
+
return {
|
|
2365
|
+
id: status.id,
|
|
2366
|
+
state: status.state,
|
|
2367
|
+
name: mapPullRequestCheckName(status),
|
|
2368
|
+
description: status.description ?? null,
|
|
2369
|
+
targetUrl: status.targetUrl ?? null,
|
|
2370
|
+
createdBy: status.createdBy?.displayName ?? null,
|
|
2371
|
+
createdAt: status.creationDate ?? null,
|
|
2372
|
+
updatedAt: status.updatedDate ?? null
|
|
1685
2373
|
};
|
|
1686
2374
|
}
|
|
1687
2375
|
function mapComment(comment) {
|
|
@@ -1697,9 +2385,6 @@ function mapComment(comment) {
|
|
|
1697
2385
|
};
|
|
1698
2386
|
}
|
|
1699
2387
|
function mapThread(thread) {
|
|
1700
|
-
if (thread.status !== "active" && thread.status !== "pending") {
|
|
1701
|
-
return null;
|
|
1702
|
-
}
|
|
1703
2388
|
const comments = thread.comments.map(mapComment).filter((comment) => comment !== null);
|
|
1704
2389
|
if (comments.length === 0) {
|
|
1705
2390
|
return null;
|
|
@@ -1711,12 +2396,49 @@ function mapThread(thread) {
|
|
|
1711
2396
|
comments
|
|
1712
2397
|
};
|
|
1713
2398
|
}
|
|
2399
|
+
function toActiveCommentThread(thread) {
|
|
2400
|
+
return {
|
|
2401
|
+
id: thread.id,
|
|
2402
|
+
status: thread.status,
|
|
2403
|
+
threadContext: thread.threadContext?.filePath ?? null,
|
|
2404
|
+
comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
|
|
2408
|
+
function isThreadResolved(status) {
|
|
2409
|
+
return RESOLVED_THREAD_STATUSES.has(status);
|
|
2410
|
+
}
|
|
1714
2411
|
async function readJsonResponse(response) {
|
|
1715
2412
|
if (!response.ok) {
|
|
1716
2413
|
throw new Error(`HTTP_${response.status}`);
|
|
1717
2414
|
}
|
|
1718
2415
|
return response.json();
|
|
1719
2416
|
}
|
|
2417
|
+
async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
|
|
2418
|
+
const url = new URL(
|
|
2419
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
|
|
2420
|
+
);
|
|
2421
|
+
url.searchParams.set("api-version", "7.1");
|
|
2422
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
2423
|
+
method: "PATCH",
|
|
2424
|
+
headers: {
|
|
2425
|
+
...authHeaders(pat),
|
|
2426
|
+
"Content-Type": "application/json"
|
|
2427
|
+
},
|
|
2428
|
+
body: JSON.stringify({ status })
|
|
2429
|
+
});
|
|
2430
|
+
const data = await readJsonResponse(response);
|
|
2431
|
+
return toActiveCommentThread(data);
|
|
2432
|
+
}
|
|
2433
|
+
async function getPullRequestById(context, repo, pat, prId) {
|
|
2434
|
+
const url = new URL(
|
|
2435
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
|
|
2436
|
+
);
|
|
2437
|
+
url.searchParams.set("api-version", "7.1");
|
|
2438
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
|
|
2439
|
+
const data = await readJsonResponse(response);
|
|
2440
|
+
return mapPullRequest(repo, data);
|
|
2441
|
+
}
|
|
1720
2442
|
async function listPullRequests(context, repo, pat, sourceBranch, opts) {
|
|
1721
2443
|
const response = await fetchWithErrors(
|
|
1722
2444
|
buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
|
|
@@ -1725,6 +2447,14 @@ async function listPullRequests(context, repo, pat, sourceBranch, opts) {
|
|
|
1725
2447
|
const data = await readJsonResponse(response);
|
|
1726
2448
|
return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
|
|
1727
2449
|
}
|
|
2450
|
+
async function getPullRequestChecks(context, repo, pat, prId) {
|
|
2451
|
+
const response = await fetchWithErrors(
|
|
2452
|
+
buildPullRequestStatusesUrl(context, repo, prId).toString(),
|
|
2453
|
+
{ headers: authHeaders(pat) }
|
|
2454
|
+
);
|
|
2455
|
+
const data = await readJsonResponse(response);
|
|
2456
|
+
return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
|
|
2457
|
+
}
|
|
1728
2458
|
async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
|
|
1729
2459
|
const existing = await listPullRequests(context, repo, pat, sourceBranch, {
|
|
1730
2460
|
status: "active",
|
|
@@ -1778,56 +2508,86 @@ async function getPullRequestThreads(context, repo, pat, prId) {
|
|
|
1778
2508
|
}
|
|
1779
2509
|
|
|
1780
2510
|
// src/commands/pr.ts
|
|
2511
|
+
function parsePositivePrNumber(raw) {
|
|
2512
|
+
if (!/^\d+$/.test(raw)) {
|
|
2513
|
+
return null;
|
|
2514
|
+
}
|
|
2515
|
+
const n = Number.parseInt(raw, 10);
|
|
2516
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
2517
|
+
}
|
|
1781
2518
|
function formatBranchName(refName) {
|
|
1782
2519
|
return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
|
|
1783
2520
|
}
|
|
1784
2521
|
function writeError(message) {
|
|
1785
2522
|
process.stderr.write(`Error: ${message}
|
|
1786
2523
|
`);
|
|
1787
|
-
process.
|
|
2524
|
+
process.exitCode = 1;
|
|
1788
2525
|
}
|
|
1789
2526
|
function handlePrCommandError(err, context, mode = "read") {
|
|
1790
2527
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
1791
2528
|
if (error.message === "AUTH_FAILED") {
|
|
1792
2529
|
const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
|
|
1793
2530
|
writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`);
|
|
2531
|
+
return;
|
|
1794
2532
|
}
|
|
1795
2533
|
if (error.message === "PERMISSION_DENIED") {
|
|
1796
2534
|
writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`);
|
|
2535
|
+
return;
|
|
1797
2536
|
}
|
|
1798
2537
|
if (error.message === "NETWORK_ERROR") {
|
|
1799
2538
|
writeError("Could not connect to Azure DevOps. Check your network connection.");
|
|
2539
|
+
return;
|
|
1800
2540
|
}
|
|
1801
2541
|
if (error.message.startsWith("NOT_FOUND")) {
|
|
1802
2542
|
writeError(`Azure DevOps repository not found in ${context?.org}/${context?.project}.`);
|
|
2543
|
+
return;
|
|
1803
2544
|
}
|
|
1804
2545
|
if (error.message.startsWith("HTTP_")) {
|
|
1805
2546
|
writeError(`Azure DevOps request failed with ${error.message}.`);
|
|
2547
|
+
return;
|
|
1806
2548
|
}
|
|
1807
2549
|
writeError(error.message);
|
|
1808
2550
|
}
|
|
2551
|
+
function formatPullRequestChecks(checks) {
|
|
2552
|
+
if (checks.length === 0) {
|
|
2553
|
+
return ["Checks: none reported by Azure DevOps"];
|
|
2554
|
+
}
|
|
2555
|
+
const lines = ["Checks:"];
|
|
2556
|
+
for (const check of checks) {
|
|
2557
|
+
lines.push(`- [${check.state}] ${check.name}`);
|
|
2558
|
+
if ((check.state === "failed" || check.state === "error") && check.description) {
|
|
2559
|
+
lines.push(` Detail: ${check.description}`);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
return lines;
|
|
2563
|
+
}
|
|
1809
2564
|
function formatPullRequestBlock(pullRequest) {
|
|
1810
2565
|
return [
|
|
1811
2566
|
`#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
|
|
1812
2567
|
`${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
|
|
1813
|
-
pullRequest.url
|
|
2568
|
+
pullRequest.url ?? "\u2014",
|
|
2569
|
+
...formatPullRequestChecks(pullRequest.checks)
|
|
1814
2570
|
].join("\n");
|
|
1815
2571
|
}
|
|
2572
|
+
function threadStatusLabel(status) {
|
|
2573
|
+
return isThreadResolved(status) ? "resolved" : status;
|
|
2574
|
+
}
|
|
1816
2575
|
function formatThreads(prId, title, threads) {
|
|
1817
|
-
const lines = [`
|
|
2576
|
+
const lines = [`Comment threads for pull request #${prId}: ${title}`];
|
|
1818
2577
|
for (const thread of threads) {
|
|
1819
|
-
lines.push("", `Thread #${thread.id} [${thread.status}] ${thread.threadContext ?? "(general)"}`);
|
|
2578
|
+
lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${thread.threadContext ?? "(general)"}`);
|
|
1820
2579
|
for (const comment of thread.comments) {
|
|
1821
2580
|
lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
|
|
1822
2581
|
}
|
|
1823
2582
|
}
|
|
1824
2583
|
return lines.join("\n");
|
|
1825
2584
|
}
|
|
1826
|
-
async function resolvePrCommandContext(options) {
|
|
2585
|
+
async function resolvePrCommandContext(options, resolveOpts = {}) {
|
|
2586
|
+
const requireBranch = resolveOpts.requireBranch ?? true;
|
|
1827
2587
|
const context = resolveContext(options);
|
|
1828
2588
|
const repo = detectRepoName();
|
|
1829
|
-
const branch = getCurrentBranch();
|
|
1830
|
-
const credential = await
|
|
2589
|
+
const branch = requireBranch ? getCurrentBranch() : null;
|
|
2590
|
+
const credential = await requirePat(context.org);
|
|
1831
2591
|
return {
|
|
1832
2592
|
context,
|
|
1833
2593
|
repo,
|
|
@@ -1836,27 +2596,33 @@ async function resolvePrCommandContext(options) {
|
|
|
1836
2596
|
};
|
|
1837
2597
|
}
|
|
1838
2598
|
function createPrStatusCommand() {
|
|
1839
|
-
const command = new
|
|
2599
|
+
const command = new Command12("status");
|
|
1840
2600
|
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
2601
|
validateOrgProjectPair(options);
|
|
1842
2602
|
let context;
|
|
1843
2603
|
try {
|
|
1844
2604
|
const resolved = await resolvePrCommandContext(options);
|
|
1845
2605
|
context = resolved.context;
|
|
1846
|
-
const
|
|
1847
|
-
const
|
|
1848
|
-
const
|
|
2606
|
+
const branch = resolved.branch;
|
|
2607
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
|
|
2608
|
+
const pullRequestsWithChecks = await Promise.all(
|
|
2609
|
+
pullRequests.map(async (pullRequest) => ({
|
|
2610
|
+
...pullRequest,
|
|
2611
|
+
checks: await getPullRequestChecks(resolved.context, resolved.repo, resolved.pat, pullRequest.id)
|
|
2612
|
+
}))
|
|
2613
|
+
);
|
|
2614
|
+
const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
|
|
1849
2615
|
if (options.json) {
|
|
1850
2616
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1851
2617
|
`);
|
|
1852
2618
|
return;
|
|
1853
2619
|
}
|
|
1854
|
-
if (
|
|
2620
|
+
if (pullRequestsWithChecks.length === 0) {
|
|
1855
2621
|
process.stdout.write(`No pull requests found for branch ${branch}.
|
|
1856
2622
|
`);
|
|
1857
2623
|
return;
|
|
1858
2624
|
}
|
|
1859
|
-
process.stdout.write(`${
|
|
2625
|
+
process.stdout.write(`${pullRequestsWithChecks.map(formatPullRequestBlock).join("\n\n")}
|
|
1860
2626
|
`);
|
|
1861
2627
|
} catch (err) {
|
|
1862
2628
|
handlePrCommandError(err, context, "read");
|
|
@@ -1865,16 +2631,18 @@ function createPrStatusCommand() {
|
|
|
1865
2631
|
return command;
|
|
1866
2632
|
}
|
|
1867
2633
|
function createPrOpenCommand() {
|
|
1868
|
-
const command = new
|
|
2634
|
+
const command = new Command12("open");
|
|
1869
2635
|
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
2636
|
validateOrgProjectPair(options);
|
|
1871
2637
|
const title = options.title?.trim();
|
|
1872
2638
|
if (!title) {
|
|
1873
2639
|
writeError("--title is required for pull request creation.");
|
|
2640
|
+
return;
|
|
1874
2641
|
}
|
|
1875
2642
|
const description = options.description?.trim();
|
|
1876
2643
|
if (!description) {
|
|
1877
2644
|
writeError("--description is required for pull request creation.");
|
|
2645
|
+
return;
|
|
1878
2646
|
}
|
|
1879
2647
|
let context;
|
|
1880
2648
|
try {
|
|
@@ -1882,12 +2650,14 @@ function createPrOpenCommand() {
|
|
|
1882
2650
|
context = resolved.context;
|
|
1883
2651
|
if (resolved.branch === "develop") {
|
|
1884
2652
|
writeError("Pull request creation requires a source branch other than develop.");
|
|
2653
|
+
return;
|
|
1885
2654
|
}
|
|
2655
|
+
const openBranch = resolved.branch;
|
|
1886
2656
|
const result = await openPullRequest(
|
|
1887
2657
|
resolved.context,
|
|
1888
2658
|
resolved.repo,
|
|
1889
2659
|
resolved.pat,
|
|
1890
|
-
|
|
2660
|
+
openBranch,
|
|
1891
2661
|
title,
|
|
1892
2662
|
description
|
|
1893
2663
|
);
|
|
@@ -1898,19 +2668,20 @@ function createPrOpenCommand() {
|
|
|
1898
2668
|
}
|
|
1899
2669
|
if (result.created) {
|
|
1900
2670
|
process.stdout.write(`Created pull request #${result.pullRequest.id}: ${result.pullRequest.title}
|
|
1901
|
-
${result.pullRequest.url}
|
|
2671
|
+
${result.pullRequest.url ?? "\u2014"}
|
|
1902
2672
|
`);
|
|
1903
2673
|
return;
|
|
1904
2674
|
}
|
|
1905
2675
|
process.stdout.write(
|
|
1906
2676
|
`Active pull request already exists for ${resolved.branch} -> develop: #${result.pullRequest.id}
|
|
1907
|
-
${result.pullRequest.url}
|
|
2677
|
+
${result.pullRequest.url ?? "\u2014"}
|
|
1908
2678
|
`
|
|
1909
2679
|
);
|
|
1910
2680
|
} catch (err) {
|
|
1911
2681
|
if (err instanceof Error && err.message.startsWith("AMBIGUOUS_PRS:")) {
|
|
1912
2682
|
const ids = err.message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
|
|
1913
2683
|
writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
|
|
2684
|
+
return;
|
|
1914
2685
|
}
|
|
1915
2686
|
handlePrCommandError(err, context, "write");
|
|
1916
2687
|
}
|
|
@@ -1918,34 +2689,66 @@ ${result.pullRequest.url}
|
|
|
1918
2689
|
return command;
|
|
1919
2690
|
}
|
|
1920
2691
|
function createPrCommentsCommand() {
|
|
1921
|
-
const command = new
|
|
1922
|
-
command.description("List
|
|
2692
|
+
const command = new Command12("comments");
|
|
2693
|
+
command.description("List pull request comment threads for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--json", "output JSON").action(async (options) => {
|
|
1923
2694
|
validateOrgProjectPair(options);
|
|
1924
2695
|
let context;
|
|
2696
|
+
let explicitPrId = null;
|
|
2697
|
+
if (options.prNumber !== void 0) {
|
|
2698
|
+
explicitPrId = parsePositivePrNumber(options.prNumber);
|
|
2699
|
+
if (explicitPrId === null) {
|
|
2700
|
+
writeError(`Invalid --pr-number "${options.prNumber}"; expected a positive integer.`);
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
1925
2704
|
try {
|
|
1926
|
-
const resolved = await resolvePrCommandContext(options);
|
|
2705
|
+
const resolved = await resolvePrCommandContext(options, { requireBranch: explicitPrId === null });
|
|
1927
2706
|
context = resolved.context;
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
2707
|
+
let pullRequest;
|
|
2708
|
+
let branchLabel;
|
|
2709
|
+
if (explicitPrId !== null) {
|
|
2710
|
+
try {
|
|
2711
|
+
pullRequest = await getPullRequestById(resolved.context, resolved.repo, resolved.pat, explicitPrId);
|
|
2712
|
+
} catch (err) {
|
|
2713
|
+
if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
|
|
2714
|
+
writeError(`Pull request #${explicitPrId} not found in ${resolved.context.org}/${resolved.context.project}/${resolved.repo}.`);
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
throw err;
|
|
2718
|
+
}
|
|
2719
|
+
branchLabel = resolved.branch ?? pullRequest.sourceRefName;
|
|
2720
|
+
} else {
|
|
2721
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
|
|
2722
|
+
status: "active"
|
|
2723
|
+
});
|
|
2724
|
+
if (pullRequests.length === 0) {
|
|
2725
|
+
writeError(`No active pull request found for branch ${resolved.branch}.`);
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
if (pullRequests.length > 1) {
|
|
2729
|
+
const ids = pullRequests.map((pr) => `#${pr.id}`).join(", ");
|
|
2730
|
+
writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
pullRequest = pullRequests[0];
|
|
2734
|
+
branchLabel = resolved.branch;
|
|
1937
2735
|
}
|
|
1938
|
-
const
|
|
1939
|
-
const threads =
|
|
1940
|
-
const result = { branch:
|
|
2736
|
+
const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
|
|
2737
|
+
const threads = options.hideResolved ? allThreads.filter((thread) => !isThreadResolved(thread.status)) : allThreads;
|
|
2738
|
+
const result = { branch: branchLabel, pullRequest, threads };
|
|
1941
2739
|
if (options.json) {
|
|
1942
2740
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1943
2741
|
`);
|
|
1944
2742
|
return;
|
|
1945
2743
|
}
|
|
1946
2744
|
if (threads.length === 0) {
|
|
1947
|
-
|
|
2745
|
+
if (options.hideResolved && allThreads.length > 0) {
|
|
2746
|
+
process.stdout.write(`Pull request #${pullRequest.id} has no unresolved comment threads (${allThreads.length} resolved thread${allThreads.length === 1 ? "" : "s"} hidden by --hide-resolved).
|
|
2747
|
+
`);
|
|
2748
|
+
} else {
|
|
2749
|
+
process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
|
|
1948
2750
|
`);
|
|
2751
|
+
}
|
|
1949
2752
|
return;
|
|
1950
2753
|
}
|
|
1951
2754
|
process.stdout.write(`${formatThreads(pullRequest.id, pullRequest.title, threads)}
|
|
@@ -1956,17 +2759,136 @@ function createPrCommentsCommand() {
|
|
|
1956
2759
|
});
|
|
1957
2760
|
return command;
|
|
1958
2761
|
}
|
|
2762
|
+
async function resolveThreadTarget(threadIdRaw, options) {
|
|
2763
|
+
validateOrgProjectPair(options);
|
|
2764
|
+
const threadId = parsePositivePrNumber(threadIdRaw);
|
|
2765
|
+
if (threadId === null) {
|
|
2766
|
+
writeError(`Invalid thread id "${threadIdRaw}"; expected a positive integer.`);
|
|
2767
|
+
return null;
|
|
2768
|
+
}
|
|
2769
|
+
let explicitPrId = null;
|
|
2770
|
+
if (options.prNumber !== void 0) {
|
|
2771
|
+
explicitPrId = parsePositivePrNumber(options.prNumber);
|
|
2772
|
+
if (explicitPrId === null) {
|
|
2773
|
+
writeError(`Invalid --pr-number "${options.prNumber}"; expected a positive integer.`);
|
|
2774
|
+
return null;
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
const resolved = await resolvePrCommandContext(options, { requireBranch: explicitPrId === null });
|
|
2778
|
+
let pullRequest;
|
|
2779
|
+
if (explicitPrId !== null) {
|
|
2780
|
+
try {
|
|
2781
|
+
pullRequest = await getPullRequestById(resolved.context, resolved.repo, resolved.pat, explicitPrId);
|
|
2782
|
+
} catch (err) {
|
|
2783
|
+
if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
|
|
2784
|
+
writeError(`Pull request #${explicitPrId} not found in ${resolved.context.org}/${resolved.context.project}/${resolved.repo}.`);
|
|
2785
|
+
return null;
|
|
2786
|
+
}
|
|
2787
|
+
throw err;
|
|
2788
|
+
}
|
|
2789
|
+
} else {
|
|
2790
|
+
const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
|
|
2791
|
+
status: "active"
|
|
2792
|
+
});
|
|
2793
|
+
if (pullRequests.length === 0) {
|
|
2794
|
+
writeError(`No active pull request found for branch ${resolved.branch}.`);
|
|
2795
|
+
return null;
|
|
2796
|
+
}
|
|
2797
|
+
if (pullRequests.length > 1) {
|
|
2798
|
+
const ids = pullRequests.map((pr) => `#${pr.id}`).join(", ");
|
|
2799
|
+
writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
|
|
2800
|
+
return null;
|
|
2801
|
+
}
|
|
2802
|
+
pullRequest = pullRequests[0];
|
|
2803
|
+
}
|
|
2804
|
+
return { context: resolved.context, repo: resolved.repo, pat: resolved.pat, pullRequest, threadId };
|
|
2805
|
+
}
|
|
2806
|
+
async function runThreadStateChange(threadIdRaw, options, direction) {
|
|
2807
|
+
let context;
|
|
2808
|
+
try {
|
|
2809
|
+
const target = await resolveThreadTarget(threadIdRaw, options);
|
|
2810
|
+
if (target === null) {
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
context = target.context;
|
|
2814
|
+
const threads = await getPullRequestThreads(target.context, target.repo, target.pat, target.pullRequest.id);
|
|
2815
|
+
const thread = threads.find((t) => t.id === target.threadId);
|
|
2816
|
+
if (!thread) {
|
|
2817
|
+
writeError(`Thread #${target.threadId} not found on pull request #${target.pullRequest.id}.`);
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
const alreadyInTargetState = direction === "resolve" ? isThreadResolved(thread.status) : !isThreadResolved(thread.status);
|
|
2821
|
+
const targetStatus = direction === "resolve" ? "fixed" : "active";
|
|
2822
|
+
if (alreadyInTargetState) {
|
|
2823
|
+
const humanLabel = direction === "resolve" ? "resolved" : "active";
|
|
2824
|
+
const noopResult = {
|
|
2825
|
+
pullRequestId: target.pullRequest.id,
|
|
2826
|
+
threadId: target.threadId,
|
|
2827
|
+
status: thread.status,
|
|
2828
|
+
noop: true
|
|
2829
|
+
};
|
|
2830
|
+
if (options.json) {
|
|
2831
|
+
process.stdout.write(`${JSON.stringify(noopResult, null, 2)}
|
|
2832
|
+
`);
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
process.stdout.write(`Thread #${target.threadId} is already ${humanLabel} on pull request #${target.pullRequest.id} (current status: ${thread.status}).
|
|
2836
|
+
`);
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
const updated = await patchThreadStatus(
|
|
2840
|
+
target.context,
|
|
2841
|
+
target.repo,
|
|
2842
|
+
target.pat,
|
|
2843
|
+
target.pullRequest.id,
|
|
2844
|
+
target.threadId,
|
|
2845
|
+
targetStatus
|
|
2846
|
+
);
|
|
2847
|
+
const result = {
|
|
2848
|
+
pullRequestId: target.pullRequest.id,
|
|
2849
|
+
threadId: target.threadId,
|
|
2850
|
+
status: targetStatus,
|
|
2851
|
+
noop: false
|
|
2852
|
+
};
|
|
2853
|
+
if (options.json) {
|
|
2854
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2855
|
+
`);
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
const verb = direction === "resolve" ? "resolved" : "reopened";
|
|
2859
|
+
process.stdout.write(`Thread #${target.threadId} ${verb} on pull request #${target.pullRequest.id} (status: ${updated.status}).
|
|
2860
|
+
`);
|
|
2861
|
+
} catch (err) {
|
|
2862
|
+
handlePrCommandError(err, context, "write");
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
function createPrCommentResolveCommand() {
|
|
2866
|
+
const command = new Command12("comment-resolve");
|
|
2867
|
+
command.description("Mark a pull request comment thread as resolved").argument("<threadId>", "numeric id of the thread to resolve").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--json", "output JSON").action(async (threadIdRaw, options) => {
|
|
2868
|
+
await runThreadStateChange(threadIdRaw, options, "resolve");
|
|
2869
|
+
});
|
|
2870
|
+
return command;
|
|
2871
|
+
}
|
|
2872
|
+
function createPrCommentReopenCommand() {
|
|
2873
|
+
const command = new Command12("comment-reopen");
|
|
2874
|
+
command.description("Reopen (set to active) a previously resolved pull request comment thread").argument("<threadId>", "numeric id of the thread to reopen").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--json", "output JSON").action(async (threadIdRaw, options) => {
|
|
2875
|
+
await runThreadStateChange(threadIdRaw, options, "reopen");
|
|
2876
|
+
});
|
|
2877
|
+
return command;
|
|
2878
|
+
}
|
|
1959
2879
|
function createPrCommand() {
|
|
1960
|
-
const command = new
|
|
2880
|
+
const command = new Command12("pr");
|
|
1961
2881
|
command.description("Manage Azure DevOps pull requests");
|
|
1962
2882
|
command.addCommand(createPrStatusCommand());
|
|
1963
2883
|
command.addCommand(createPrOpenCommand());
|
|
1964
2884
|
command.addCommand(createPrCommentsCommand());
|
|
2885
|
+
command.addCommand(createPrCommentResolveCommand());
|
|
2886
|
+
command.addCommand(createPrCommentReopenCommand());
|
|
1965
2887
|
return command;
|
|
1966
2888
|
}
|
|
1967
2889
|
|
|
1968
2890
|
// src/commands/comments.ts
|
|
1969
|
-
import { Command as
|
|
2891
|
+
import { Command as Command13 } from "commander";
|
|
1970
2892
|
function writeError2(message) {
|
|
1971
2893
|
process.stderr.write(`Error: ${message}
|
|
1972
2894
|
`);
|
|
@@ -1977,22 +2899,23 @@ function formatCommentHeader(comment) {
|
|
|
1977
2899
|
const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
|
|
1978
2900
|
return `Comment #${comment.id} by ${author} at ${timestamp}`;
|
|
1979
2901
|
}
|
|
1980
|
-
function formatComments(result) {
|
|
2902
|
+
function formatComments(result, convertMarkdown) {
|
|
1981
2903
|
const lines = [`Comments for work item #${result.workItemId}`];
|
|
1982
2904
|
for (const comment of result.comments) {
|
|
1983
|
-
|
|
2905
|
+
const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
|
|
2906
|
+
lines.push("", formatCommentHeader(comment), text);
|
|
1984
2907
|
}
|
|
1985
2908
|
return lines.join("\n");
|
|
1986
2909
|
}
|
|
1987
2910
|
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) => {
|
|
2911
|
+
const command = new Command13("list");
|
|
2912
|
+
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
2913
|
validateOrgProjectPair(options);
|
|
1991
2914
|
const id = parseWorkItemId(idStr);
|
|
1992
2915
|
let context;
|
|
1993
2916
|
try {
|
|
1994
2917
|
context = resolveContext(options);
|
|
1995
|
-
const credential = await
|
|
2918
|
+
const credential = await requirePat(context.org);
|
|
1996
2919
|
const result = await listWorkItemComments(context, id, credential.pat);
|
|
1997
2920
|
if (options.json) {
|
|
1998
2921
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
@@ -2004,7 +2927,7 @@ function createCommentsListCommand() {
|
|
|
2004
2927
|
`);
|
|
2005
2928
|
return;
|
|
2006
2929
|
}
|
|
2007
|
-
process.stdout.write(`${formatComments(result)}
|
|
2930
|
+
process.stdout.write(`${formatComments(result, options.markdown === true)}
|
|
2008
2931
|
`);
|
|
2009
2932
|
} catch (err) {
|
|
2010
2933
|
handleCommandError(err, id, context, "read");
|
|
@@ -2013,8 +2936,8 @@ function createCommentsListCommand() {
|
|
|
2013
2936
|
return command;
|
|
2014
2937
|
}
|
|
2015
2938
|
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) => {
|
|
2939
|
+
const command = new Command13("add");
|
|
2940
|
+
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
2941
|
validateOrgProjectPair(options);
|
|
2019
2942
|
const id = parseWorkItemId(idStr);
|
|
2020
2943
|
if (text.trim() === "") {
|
|
@@ -2023,8 +2946,9 @@ function createCommentsAddCommand() {
|
|
|
2023
2946
|
let context;
|
|
2024
2947
|
try {
|
|
2025
2948
|
context = resolveContext(options);
|
|
2026
|
-
const credential = await
|
|
2027
|
-
const
|
|
2949
|
+
const credential = await requirePat(context.org);
|
|
2950
|
+
const format = options.markdown === true ? "markdown" : "html";
|
|
2951
|
+
const result = await addWorkItemComment(context, id, credential.pat, text, format);
|
|
2028
2952
|
if (options.json) {
|
|
2029
2953
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2030
2954
|
`);
|
|
@@ -2039,17 +2963,65 @@ function createCommentsAddCommand() {
|
|
|
2039
2963
|
return command;
|
|
2040
2964
|
}
|
|
2041
2965
|
function createCommentsCommand() {
|
|
2042
|
-
const command = new
|
|
2966
|
+
const command = new Command13("comments");
|
|
2043
2967
|
command.description("Manage Azure DevOps work item comments");
|
|
2044
2968
|
command.addCommand(createCommentsListCommand());
|
|
2045
2969
|
command.addCommand(createCommentsAddCommand());
|
|
2046
2970
|
return command;
|
|
2047
2971
|
}
|
|
2048
2972
|
|
|
2973
|
+
// src/commands/download-attachment.ts
|
|
2974
|
+
import { Command as Command14 } from "commander";
|
|
2975
|
+
import { writeFile } from "fs/promises";
|
|
2976
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2977
|
+
import { join as join2 } from "path";
|
|
2978
|
+
function createDownloadAttachmentCommand() {
|
|
2979
|
+
const command = new Command14("download-attachment");
|
|
2980
|
+
command.description("Download an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to download").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--output <dir>", "target directory for the downloaded file").action(
|
|
2981
|
+
async (idStr, filename, options) => {
|
|
2982
|
+
const id = parseWorkItemId(idStr);
|
|
2983
|
+
validateOrgProjectPair(options);
|
|
2984
|
+
let context;
|
|
2985
|
+
try {
|
|
2986
|
+
context = resolveContext(options);
|
|
2987
|
+
const credential = await requirePat(context.org);
|
|
2988
|
+
const outputDir = options.output ?? ".";
|
|
2989
|
+
if (!existsSync4(outputDir)) {
|
|
2990
|
+
process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
|
|
2991
|
+
`);
|
|
2992
|
+
process.exit(1);
|
|
2993
|
+
}
|
|
2994
|
+
const workItem = await getWorkItem(context, id, credential.pat);
|
|
2995
|
+
const attachment = workItem.attachments?.find(
|
|
2996
|
+
(a) => a.name === filename
|
|
2997
|
+
);
|
|
2998
|
+
if (!attachment) {
|
|
2999
|
+
process.stderr.write(
|
|
3000
|
+
`Error: Attachment "${filename}" not found on work item ${id}.
|
|
3001
|
+
`
|
|
3002
|
+
);
|
|
3003
|
+
process.exit(1);
|
|
3004
|
+
}
|
|
3005
|
+
const data = await downloadAttachment(attachment.url, credential.pat);
|
|
3006
|
+
const outputPath = join2(outputDir, filename);
|
|
3007
|
+
await writeFile(outputPath, Buffer.from(data));
|
|
3008
|
+
process.stdout.write(
|
|
3009
|
+
`Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
|
|
3010
|
+
`
|
|
3011
|
+
);
|
|
3012
|
+
} catch (err) {
|
|
3013
|
+
handleCommandError(err, id, context, "read", false);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
);
|
|
3017
|
+
return command;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
2049
3020
|
// src/index.ts
|
|
2050
|
-
var program = new
|
|
3021
|
+
var program = new Command15();
|
|
2051
3022
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
2052
3023
|
program.addCommand(createGetItemCommand());
|
|
3024
|
+
program.addCommand(createAuthCommand());
|
|
2053
3025
|
program.addCommand(createClearPatCommand());
|
|
2054
3026
|
program.addCommand(createConfigCommand());
|
|
2055
3027
|
program.addCommand(createSetStateCommand());
|
|
@@ -2061,6 +3033,7 @@ program.addCommand(createUpsertCommand());
|
|
|
2061
3033
|
program.addCommand(createListFieldsCommand());
|
|
2062
3034
|
program.addCommand(createPrCommand());
|
|
2063
3035
|
program.addCommand(createCommentsCommand());
|
|
3036
|
+
program.addCommand(createDownloadAttachmentCommand());
|
|
2064
3037
|
program.showHelpAfterError();
|
|
2065
3038
|
program.parse();
|
|
2066
3039
|
if (process.argv.length <= 2) {
|