azdo-cli 0.5.0-develop.156 → 0.5.0-fix-sonarcloud-major-issues.505

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