azdo-cli 0.19.0-develop.691 → 0.19.0-develop.703

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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +364 -205
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -89,6 +89,8 @@ azdo pr comment-reply 148 "Done." --pr-number 64 # flat
89
89
  # Open a pull request — description from a repo template when you don't pass one
90
90
  azdo pr open --title "Fix the thing" --description "Because X was broken"
91
91
  azdo pr open --title "Fix the thing" # uses docs/pull_request_template[/branches/<branch>].md if present
92
+ # The description plus the template must stay within Azure DevOps' 4000-character cap; over it,
93
+ # the command says by how much and creates nothing. See docs/commands.md.
92
94
 
93
95
  # Link/unlink a work item, add/remove reviewers
94
96
  azdo pr work-items link 1234 --pr-number 64
package/dist/index.js CHANGED
@@ -78,6 +78,21 @@ function redactUrl(url) {
78
78
  return url;
79
79
  }
80
80
  }
81
+ var SECRET_ASSIGNMENT = String.raw`["']?\s*[:=]\s*["']?)[^"'\s,;&}]+`;
82
+ var SECRET_IN_TEXT = [
83
+ [/\b(Bearer|Basic)\s+[\w.~+/=-]{8,}/gi, `$1 ${REDACTED}`],
84
+ [new RegExp(String.raw`(\b(?:access|refresh|api)[_-]?(?:token|key)\b${SECRET_ASSIGNMENT}`, "gi"), `$1${REDACTED}`],
85
+ [new RegExp(String.raw`(\b(?:token|pat|password|secret)\b${SECRET_ASSIGNMENT}`, "gi"), `$1${REDACTED}`],
86
+ [/\beyJ[A-Za-z0-9._~+/=-]{20,}/g, REDACTED],
87
+ [/\b(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{40,}\b/g, REDACTED]
88
+ ];
89
+ function redactText(text) {
90
+ let out = text;
91
+ for (const [pattern, replacement] of SECRET_IN_TEXT) {
92
+ out = out.replace(pattern, replacement);
93
+ }
94
+ return out;
95
+ }
81
96
  function redactBody(body) {
82
97
  if (body === null) return null;
83
98
  try {
@@ -123,6 +138,131 @@ function getActiveTraceWriter() {
123
138
  return activeWriter;
124
139
  }
125
140
 
141
+ // src/services/command-helpers.ts
142
+ function isSentinel(message, sentinel) {
143
+ return message === sentinel || message.startsWith(`${sentinel}: `);
144
+ }
145
+ function withDetail(sentinel, detail) {
146
+ return detail === null || detail === "" ? sentinel : `${sentinel}: ${detail}`;
147
+ }
148
+ function sentinelDetail(message, sentinel) {
149
+ return message.startsWith(`${sentinel}: `) ? message.slice(sentinel.length + 2) : null;
150
+ }
151
+ function splitSentinel(message) {
152
+ const separator = message.indexOf(": ");
153
+ return separator === -1 ? { sentinel: message, detail: null } : { sentinel: message.slice(0, separator), detail: message.slice(separator + 2) };
154
+ }
155
+ function writeErrorDetail(message, sentinel) {
156
+ const detail = sentinelDetail(message, sentinel);
157
+ if (detail !== null) {
158
+ process.stderr.write(` ${detail}
159
+ `);
160
+ }
161
+ }
162
+ async function promptYesNo(prompt) {
163
+ if (!process.stdin.isTTY) return true;
164
+ process.stderr.write(prompt);
165
+ return await new Promise((resolve2) => {
166
+ process.stdin.setEncoding("utf8");
167
+ let answered = false;
168
+ const handler = (data) => {
169
+ if (answered) return;
170
+ answered = true;
171
+ process.stdin.removeListener("data", handler);
172
+ process.stdin.pause();
173
+ const trimmed = data.trim().toLowerCase();
174
+ resolve2(trimmed === "y" || trimmed === "yes");
175
+ };
176
+ process.stdin.resume();
177
+ process.stdin.on("data", handler);
178
+ });
179
+ }
180
+ function parseWorkItemId(idStr) {
181
+ const id = Number.parseInt(idStr, 10);
182
+ if (!Number.isInteger(id) || id <= 0) {
183
+ process.stderr.write(
184
+ `Error: Work item ID must be a positive integer. Got: "${idStr}"
185
+ `
186
+ );
187
+ process.exit(1);
188
+ }
189
+ return id;
190
+ }
191
+ function validateOrgProjectPair(options) {
192
+ const hasOrg = options.org !== void 0;
193
+ const hasProject = options.project !== void 0;
194
+ if (hasOrg !== hasProject) {
195
+ process.stderr.write(
196
+ "Error: --org and --project must both be provided, or both omitted.\n"
197
+ );
198
+ process.exit(1);
199
+ }
200
+ }
201
+ function validateSource(options) {
202
+ const hasContent = options.content !== void 0;
203
+ const hasFile = options.file !== void 0;
204
+ if (hasContent === hasFile) {
205
+ process.stderr.write("Error: provide exactly one of --content or --file\n");
206
+ process.exit(1);
207
+ }
208
+ }
209
+ function formatCreateError(err) {
210
+ const error = err instanceof Error ? err : new Error(String(err));
211
+ const message = error.message.startsWith("CREATE_REJECTED:") ? error.message.replace("CREATE_REJECTED:", "").trim() : error.message;
212
+ const requiredMatches = [...message.matchAll(/field ['"]([^'"]+)['"]/gi)];
213
+ if (requiredMatches.length > 0) {
214
+ const fields = Array.from(new Set(requiredMatches.map((match) => match[1])));
215
+ return `Create rejected: ${message} (fields: ${fields.join(", ")})`;
216
+ }
217
+ return `Create rejected: ${message}`;
218
+ }
219
+ function handleCommandError(err, id, context, scope = "write", exit = true) {
220
+ const error = err instanceof Error ? err : new Error(String(err));
221
+ const msg = error.message;
222
+ const scopeLabel = scope === "read" ? "Work Items (read)" : "Work Items (Read & Write)";
223
+ if (isSentinel(msg, "AUTH_FAILED")) {
224
+ process.stderr.write(
225
+ `Error: Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.
226
+ `
227
+ );
228
+ writeErrorDetail(msg, "AUTH_FAILED");
229
+ } else if (isSentinel(msg, "PERMISSION_DENIED")) {
230
+ process.stderr.write(
231
+ `Error: Access denied. Your PAT may lack ${scope} permissions for project "${context?.project}".
232
+ `
233
+ );
234
+ writeErrorDetail(msg, "PERMISSION_DENIED");
235
+ } else if (msg.startsWith("NOT_FOUND")) {
236
+ process.stderr.write(
237
+ `Error: Work item ${id} not found in ${context?.org}/${context?.project}.
238
+ `
239
+ );
240
+ } else if (msg === "NETWORK_ERROR") {
241
+ process.stderr.write(
242
+ "Error: Could not connect to Azure DevOps. Check your network connection.\n"
243
+ );
244
+ } else if (msg.startsWith("BAD_REQUEST:")) {
245
+ const serverMsg = msg.replace("BAD_REQUEST: ", "");
246
+ process.stderr.write(`Error: Request rejected: ${serverMsg}
247
+ `);
248
+ } else if (msg.startsWith("CREATE_REJECTED:")) {
249
+ process.stderr.write(`Error: ${formatCreateError(error)}
250
+ `);
251
+ } else if (msg.startsWith("UPDATE_REJECTED:")) {
252
+ const serverMsg = msg.replace("UPDATE_REJECTED: ", "");
253
+ process.stderr.write(`Error: Update rejected: ${serverMsg}
254
+ `);
255
+ } else {
256
+ process.stderr.write(`Error: ${msg}
257
+ `);
258
+ }
259
+ if (exit) {
260
+ process.exit(1);
261
+ } else {
262
+ process.exitCode = 1;
263
+ }
264
+ }
265
+
126
266
  // src/services/image-download.ts
127
267
  import { Jimp } from "jimp";
128
268
  import { writeFile } from "fs/promises";
@@ -326,65 +466,136 @@ async function fetchRaw(url, init) {
326
466
  const body = await response.text();
327
467
  return { status: response.status, body };
328
468
  }
329
- async function fetchWithErrors(url, init) {
469
+ var MAX_DETAIL_CHARS = 500;
470
+ var MAX_RAW_BODY_CHARS = 200;
471
+ var failureDetails = /* @__PURE__ */ new WeakMap();
472
+ function renderErrorFields(body, requireMessage = false) {
473
+ let parsed;
474
+ try {
475
+ parsed = JSON.parse(body);
476
+ } catch {
477
+ return null;
478
+ }
479
+ if (typeof parsed !== "object" || parsed === null) return null;
480
+ return renderErrorRecord(parsed, requireMessage);
481
+ }
482
+ function renderErrorRecord(record, requireMessage) {
483
+ const parts = [];
484
+ const message = typeof record.message === "string" ? record.message.trim() : "";
485
+ if (message !== "") {
486
+ parts.push(message);
487
+ } else if (requireMessage) {
488
+ return null;
489
+ }
490
+ if (typeof record.typeKey === "string" && record.typeKey.trim() !== "") {
491
+ parts.push(`[${record.typeKey.trim()}]`);
492
+ } else if (typeof record.errorCode === "number" || typeof record.errorCode === "string") {
493
+ parts.push(`[errorCode ${record.errorCode}]`);
494
+ }
495
+ return parts.length === 0 ? null : parts.join(" ");
496
+ }
497
+ function describeFailureBody(body, contentType) {
498
+ if (body === null) return null;
499
+ const trimmed = body.trim();
500
+ if (trimmed === "") return null;
501
+ if (contentType.toLowerCase().startsWith("text/html") || trimmed.startsWith("<")) {
502
+ return null;
503
+ }
504
+ const redacted = redactBody(trimmed) ?? trimmed;
505
+ const fields = renderErrorFields(redacted);
506
+ if (fields !== null) return truncateDetail(fields);
507
+ return truncateDetail(redactText(redacted).slice(0, MAX_RAW_BODY_CHARS));
508
+ }
509
+ function truncateDetail(detail) {
510
+ const collapsed = redactText(detail).replace(/\s+/g, " ").trim();
511
+ if (collapsed === "") return null;
512
+ return collapsed.length > MAX_DETAIL_CHARS ? `${collapsed.slice(0, MAX_DETAIL_CHARS)}\u2026(truncated)` : collapsed;
513
+ }
514
+ function httpError(response) {
515
+ return new Error(withDetail(`HTTP_${response.status}`, failureDetails.get(response) ?? null));
516
+ }
517
+ var failureBodies = /* @__PURE__ */ new WeakMap();
518
+ async function readFailureBody(response, tracedBody) {
519
+ if (tracedBody !== null) return tracedBody;
520
+ try {
521
+ return await response.clone().text();
522
+ } catch {
523
+ try {
524
+ return await response.text();
525
+ } catch {
526
+ return null;
527
+ }
528
+ }
529
+ }
530
+ function writeTraceEntry(writer, url, init, response, responseBody) {
531
+ const respHeaders = {};
532
+ response.headers.forEach((v, k) => {
533
+ respHeaders[k] = v;
534
+ });
535
+ const entry = {
536
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
537
+ method: (init.method ?? "GET").toUpperCase(),
538
+ url: redactUrl(url),
539
+ requestHeaders: redactHeaders(init.headers ?? {}),
540
+ requestBody: typeof init.body === "string" ? redactBody(init.body) : null,
541
+ responseStatus: response.status,
542
+ responseHeaders: respHeaders,
543
+ responseBody
544
+ };
545
+ writer.append(entry);
546
+ }
547
+ async function traceResponse(url, init, response) {
330
548
  const writer = getActiveTraceWriter();
549
+ if (!writer) return null;
550
+ let responseBody = null;
551
+ try {
552
+ responseBody = await response.clone().text();
553
+ } catch {
554
+ }
555
+ writeTraceEntry(writer, url, init, response, responseBody ?? "");
556
+ return responseBody;
557
+ }
558
+ async function fetchWithErrors(url, init) {
331
559
  let response;
332
560
  try {
333
561
  response = await fetch(url, init);
334
562
  } catch (err) {
335
563
  throw new Error("NETWORK_ERROR", { cause: err });
336
564
  }
337
- if (writer) {
338
- const reqHeaders = redactHeaders(init.headers ?? {});
339
- const reqBody = typeof init.body === "string" ? redactBody(init.body) : null;
340
- let responseBody = "";
341
- const clone = response.clone();
342
- try {
343
- responseBody = await clone.text();
344
- } catch {
345
- }
346
- const respHeaders = {};
347
- response.headers.forEach((v, k) => {
348
- respHeaders[k] = v;
349
- });
350
- const entry = {
351
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
352
- method: (init.method ?? "GET").toUpperCase(),
353
- url: redactUrl(url),
354
- requestHeaders: reqHeaders,
355
- requestBody: reqBody ?? null,
356
- responseStatus: response.status,
357
- responseHeaders: respHeaders,
358
- responseBody
359
- };
360
- writer.append(entry);
565
+ const tracedBody = await traceResponse(url, init, response);
566
+ const contentType = response.headers?.get("content-type") ?? "";
567
+ let failureDetail = null;
568
+ if (!response.ok) {
569
+ const body = await readFailureBody(response, tracedBody);
570
+ if (body !== null) failureBodies.set(response, body);
571
+ failureDetail = describeFailureBody(body, contentType);
572
+ if (failureDetail !== null) failureDetails.set(response, failureDetail);
361
573
  }
362
- if (response.status === 401) throw new Error("AUTH_FAILED");
363
- if (response.status === 403) throw new Error("PERMISSION_DENIED");
574
+ if (response.status === 401) throw new Error(withDetail("AUTH_FAILED", failureDetail));
575
+ if (response.status === 403) throw new Error(withDetail("PERMISSION_DENIED", failureDetail));
364
576
  if (response.status === 404) {
365
- let detail = "";
366
- try {
367
- const body = await response.text();
368
- detail = ` | url=${url} | body=${body}`;
369
- } catch {
370
- }
371
- throw new Error(`NOT_FOUND${detail}`);
577
+ throw new Error(
578
+ failureDetail === null ? "NOT_FOUND" : `NOT_FOUND | url=${redactUrl(url)} | body=${failureDetail}`
579
+ );
372
580
  }
373
- const contentType = response.headers?.get("content-type") ?? "";
374
581
  if (contentType.toLowerCase().startsWith("text/html")) {
375
582
  throw new Error("AUTH_FAILED");
376
583
  }
377
584
  return response;
378
585
  }
379
586
  async function readResponseMessage(response) {
587
+ const captured = failureBodies.get(response);
588
+ const rendered = captured === void 0 ? await renderErrorStream(response) : renderErrorFields(redactBody(captured.trim()) ?? captured.trim(), true);
589
+ return rendered === null ? null : truncateDetail(rendered);
590
+ }
591
+ async function renderErrorStream(response) {
380
592
  try {
381
593
  const body = await response.json();
382
- if (typeof body.message === "string" && body.message.trim() !== "") {
383
- return body.message.trim();
384
- }
594
+ if (typeof body !== "object" || body === null) return null;
595
+ return renderErrorRecord(body, true);
385
596
  } catch {
597
+ return null;
386
598
  }
387
- return null;
388
599
  }
389
600
  function normalizeFieldList(fields) {
390
601
  return Array.from(new Set(fields.map((f) => f.trim()).filter((f) => f.length > 0)));
@@ -464,7 +675,7 @@ async function readWriteResponse(response, errorCode) {
464
675
  throw new Error(`${errorCode}: ${serverMessage}`);
465
676
  }
466
677
  if (!response.ok) {
467
- throw new Error(`HTTP_${response.status}`);
678
+ throw httpError(response);
468
679
  }
469
680
  const data = await response.json();
470
681
  return {
@@ -487,7 +698,7 @@ async function getWorkItemFields(context, id, cred) {
487
698
  }
488
699
  }
489
700
  if (!response.ok) {
490
- throw new Error(`HTTP_${response.status}`);
701
+ throw httpError(response);
491
702
  }
492
703
  const data = await response.json();
493
704
  return data.fields;
@@ -539,7 +750,7 @@ async function fetchWorkItemResponse(context, id, cred, options = {}) {
539
750
  }
540
751
  }
541
752
  if (!response.ok) {
542
- throw new Error(`HTTP_${response.status}`);
753
+ throw httpError(response);
543
754
  }
544
755
  return await response.json();
545
756
  }
@@ -550,7 +761,7 @@ async function getOrgFieldNames(context, cred) {
550
761
  url.searchParams.set("api-version", "7.1");
551
762
  const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
552
763
  if (!response.ok) {
553
- throw new Error(`HTTP_${response.status}`);
764
+ throw httpError(response);
554
765
  }
555
766
  const data = await response.json();
556
767
  return (data.value ?? []).map((f) => f.referenceName);
@@ -632,7 +843,7 @@ async function getWorkItemFieldValue(context, id, cred, fieldName) {
632
843
  }
633
844
  }
634
845
  if (!response.ok) {
635
- throw new Error(`HTTP_${response.status}`);
846
+ throw httpError(response);
636
847
  }
637
848
  const data = await response.json();
638
849
  const value = data.fields[fieldName];
@@ -650,7 +861,7 @@ async function listWorkItemComments(context, id, cred) {
650
861
  { headers: authHeaders(cred) }
651
862
  );
652
863
  if (!response.ok) {
653
- throw new Error(`HTTP_${response.status}`);
864
+ throw httpError(response);
654
865
  }
655
866
  const data = await response.json();
656
867
  comments.push(
@@ -680,7 +891,7 @@ async function addWorkItemComment(context, id, cred, text, format = "html") {
680
891
  throw new Error(`BAD_REQUEST: ${serverMessage}`);
681
892
  }
682
893
  if (!response.ok) {
683
- throw new Error(`HTTP_${response.status}`);
894
+ throw httpError(response);
684
895
  }
685
896
  const data = await response.json();
686
897
  return {
@@ -732,7 +943,7 @@ async function applyWorkItemPatch(context, id, cred, operations) {
732
943
  async function downloadAttachment(url, cred) {
733
944
  const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
734
945
  if (!response.ok) {
735
- throw new Error(`HTTP_${response.status}`);
946
+ throw httpError(response);
736
947
  }
737
948
  return response.arrayBuffer();
738
949
  }
@@ -757,7 +968,7 @@ async function createAttachment(context, fileName, content, cred) {
757
968
  }
758
969
  }
759
970
  if (!response.ok) {
760
- throw new Error(`HTTP_${response.status}`);
971
+ throw httpError(response);
761
972
  }
762
973
  return await response.json();
763
974
  }
@@ -1546,109 +1757,6 @@ function toMarkdown(content) {
1546
1757
  return decodeEntitiesInMarkdownCodeSpans(decodeAdoEntitiesInMarkdown(content));
1547
1758
  }
1548
1759
 
1549
- // src/services/command-helpers.ts
1550
- async function promptYesNo(prompt) {
1551
- if (!process.stdin.isTTY) return true;
1552
- process.stderr.write(prompt);
1553
- return await new Promise((resolve2) => {
1554
- process.stdin.setEncoding("utf8");
1555
- let answered = false;
1556
- const handler = (data) => {
1557
- if (answered) return;
1558
- answered = true;
1559
- process.stdin.removeListener("data", handler);
1560
- process.stdin.pause();
1561
- const trimmed = data.trim().toLowerCase();
1562
- resolve2(trimmed === "y" || trimmed === "yes");
1563
- };
1564
- process.stdin.resume();
1565
- process.stdin.on("data", handler);
1566
- });
1567
- }
1568
- function parseWorkItemId(idStr) {
1569
- const id = Number.parseInt(idStr, 10);
1570
- if (!Number.isInteger(id) || id <= 0) {
1571
- process.stderr.write(
1572
- `Error: Work item ID must be a positive integer. Got: "${idStr}"
1573
- `
1574
- );
1575
- process.exit(1);
1576
- }
1577
- return id;
1578
- }
1579
- function validateOrgProjectPair(options) {
1580
- const hasOrg = options.org !== void 0;
1581
- const hasProject = options.project !== void 0;
1582
- if (hasOrg !== hasProject) {
1583
- process.stderr.write(
1584
- "Error: --org and --project must both be provided, or both omitted.\n"
1585
- );
1586
- process.exit(1);
1587
- }
1588
- }
1589
- function validateSource(options) {
1590
- const hasContent = options.content !== void 0;
1591
- const hasFile = options.file !== void 0;
1592
- if (hasContent === hasFile) {
1593
- process.stderr.write("Error: provide exactly one of --content or --file\n");
1594
- process.exit(1);
1595
- }
1596
- }
1597
- function formatCreateError(err) {
1598
- const error = err instanceof Error ? err : new Error(String(err));
1599
- const message = error.message.startsWith("CREATE_REJECTED:") ? error.message.replace("CREATE_REJECTED:", "").trim() : error.message;
1600
- const requiredMatches = [...message.matchAll(/field ['"]([^'"]+)['"]/gi)];
1601
- if (requiredMatches.length > 0) {
1602
- const fields = Array.from(new Set(requiredMatches.map((match) => match[1])));
1603
- return `Create rejected: ${message} (fields: ${fields.join(", ")})`;
1604
- }
1605
- return `Create rejected: ${message}`;
1606
- }
1607
- function handleCommandError(err, id, context, scope = "write", exit = true) {
1608
- const error = err instanceof Error ? err : new Error(String(err));
1609
- const msg = error.message;
1610
- const scopeLabel = scope === "read" ? "Work Items (read)" : "Work Items (Read & Write)";
1611
- if (msg === "AUTH_FAILED") {
1612
- process.stderr.write(
1613
- `Error: Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.
1614
- `
1615
- );
1616
- } else if (msg === "PERMISSION_DENIED") {
1617
- process.stderr.write(
1618
- `Error: Access denied. Your PAT may lack ${scope} permissions for project "${context?.project}".
1619
- `
1620
- );
1621
- } else if (msg.startsWith("NOT_FOUND")) {
1622
- process.stderr.write(
1623
- `Error: Work item ${id} not found in ${context?.org}/${context?.project}.
1624
- `
1625
- );
1626
- } else if (msg === "NETWORK_ERROR") {
1627
- process.stderr.write(
1628
- "Error: Could not connect to Azure DevOps. Check your network connection.\n"
1629
- );
1630
- } else if (msg.startsWith("BAD_REQUEST:")) {
1631
- const serverMsg = msg.replace("BAD_REQUEST: ", "");
1632
- process.stderr.write(`Error: Request rejected: ${serverMsg}
1633
- `);
1634
- } else if (msg.startsWith("CREATE_REJECTED:")) {
1635
- process.stderr.write(`Error: ${formatCreateError(error)}
1636
- `);
1637
- } else if (msg.startsWith("UPDATE_REJECTED:")) {
1638
- const serverMsg = msg.replace("UPDATE_REJECTED: ", "");
1639
- process.stderr.write(`Error: Update rejected: ${serverMsg}
1640
- `);
1641
- } else {
1642
- process.stderr.write(`Error: ${msg}
1643
- `);
1644
- }
1645
- if (exit) {
1646
- process.exit(1);
1647
- } else {
1648
- process.exitCode = 1;
1649
- }
1650
- }
1651
-
1652
1760
  // src/commands/get-item.ts
1653
1761
  function parseRequestedFields(raw) {
1654
1762
  if (raw === void 0) return void 0;
@@ -3132,10 +3240,10 @@ function buildUpsertResult(action, writeResult, fields, fallbackWorkItemType) {
3132
3240
  };
3133
3241
  }
3134
3242
  function isUpdateWriteError(err) {
3135
- return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message.startsWith("NOT_FOUND") || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("UPDATE_REJECTED:");
3243
+ return isSentinel(err.message, "AUTH_FAILED") || isSentinel(err.message, "PERMISSION_DENIED") || err.message.startsWith("NOT_FOUND") || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("UPDATE_REJECTED:");
3136
3244
  }
3137
3245
  function isCreateWriteError(err) {
3138
- return err.message === "AUTH_FAILED" || err.message === "PERMISSION_DENIED" || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("HTTP_");
3246
+ return isSentinel(err.message, "AUTH_FAILED") || isSentinel(err.message, "PERMISSION_DENIED") || err.message === "NETWORK_ERROR" || err.message.startsWith("BAD_REQUEST:") || err.message.startsWith("HTTP_");
3139
3247
  }
3140
3248
  function handleUpsertError(err, id, context) {
3141
3249
  if (!(err instanceof Error)) {
@@ -3465,7 +3573,7 @@ function isThreadResolved(status2) {
3465
3573
  }
3466
3574
  async function readJsonResponse(response) {
3467
3575
  if (!response.ok) {
3468
- throw new Error(`HTTP_${response.status}`);
3576
+ throw httpError(response);
3469
3577
  }
3470
3578
  return response.json();
3471
3579
  }
@@ -3552,19 +3660,35 @@ async function getPullRequestBuilds(context, cred, prId) {
3552
3660
  isBlocking: null
3553
3661
  }));
3554
3662
  }
3663
+ var MAX_PR_DESCRIPTION_CHARS = 4e3;
3664
+ var DESCRIPTION_SEPARATOR = "\n\n";
3555
3665
  function composeDescription(description, template) {
3556
- if (description !== void 0 && template !== null) {
3557
- return `${description}
3558
-
3559
- ${template.content}`;
3560
- }
3561
- if (description !== void 0) {
3562
- return description;
3563
- }
3564
- if (template !== null) {
3565
- return template.content;
3666
+ if (description === void 0 && template === null) {
3667
+ return null;
3566
3668
  }
3567
- return null;
3669
+ const provided = description ?? "";
3670
+ const templateContent = template?.content ?? "";
3671
+ const separator = description !== void 0 && template !== null ? DESCRIPTION_SEPARATOR : "";
3672
+ const text = `${provided}${separator}${templateContent}`;
3673
+ return {
3674
+ text,
3675
+ providedChars: provided.length,
3676
+ separatorChars: separator.length,
3677
+ templateChars: templateContent.length,
3678
+ templatePath: template?.path ?? null,
3679
+ totalChars: text.length
3680
+ };
3681
+ }
3682
+ function formatDescriptionOverflow(composed) {
3683
+ const overflow = composed.totalChars - MAX_PR_DESCRIPTION_CHARS;
3684
+ return `description is ${composed.totalChars} characters${formatContributions(composed)}, exceeding the Azure DevOps limit of ${MAX_PR_DESCRIPTION_CHARS} characters. Shorten the description by at least ${overflow} characters.`;
3685
+ }
3686
+ function formatContributions(composed) {
3687
+ if (composed.templatePath === null) return "";
3688
+ return ` (${composed.providedChars} provided + ${composed.separatorChars} separator + ${composed.templateChars} from the repository pull request template ${composed.templatePath})`;
3689
+ }
3690
+ function describeDescriptionBudget(composed) {
3691
+ return `description: ${composed.providedChars} provided + ${composed.separatorChars} separator + ${composed.templateChars} template = ${composed.totalChars} characters (client limit ${MAX_PR_DESCRIPTION_CHARS})`;
3568
3692
  }
3569
3693
  async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
3570
3694
  const existing = await listPullRequests(context, repo, cred, sourceBranch, {
@@ -3585,29 +3709,40 @@ async function openPullRequest(context, repo, cred, sourceBranch, title, descrip
3585
3709
  const repository = await getRepository(context, repo, cred);
3586
3710
  const defaultBranch = repository.defaultBranch ? repository.defaultBranch.replace(/^refs\/heads\//, "") : "develop";
3587
3711
  const template = await resolvePullRequestTemplate(context, repo, cred, defaultBranch, "develop");
3588
- const finalDescription = composeDescription(description, template);
3589
- if (finalDescription === null) {
3712
+ const composed = composeDescription(description, template);
3713
+ if (composed === null) {
3590
3714
  throw new Error("DESCRIPTION_REQUIRED");
3591
3715
  }
3716
+ if (composed.totalChars > MAX_PR_DESCRIPTION_CHARS) {
3717
+ throw new Error(`DESCRIPTION_TOO_LONG: ${formatDescriptionOverflow(composed)}`);
3718
+ }
3592
3719
  const payload = {
3593
3720
  sourceRefName: `refs/heads/${sourceBranch}`,
3594
3721
  targetRefName: "refs/heads/develop",
3595
3722
  title,
3596
- description: finalDescription
3723
+ description: composed.text
3597
3724
  };
3598
3725
  const url = new URL(
3599
3726
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
3600
3727
  );
3601
3728
  url.searchParams.set("api-version", "7.1");
3602
- const response = await fetchWithErrors(url.toString(), {
3603
- method: "POST",
3604
- headers: {
3605
- ...authHeaders(cred),
3606
- "Content-Type": "application/json"
3607
- },
3608
- body: JSON.stringify(payload)
3609
- });
3610
- const data = await readJsonResponse(response);
3729
+ let data;
3730
+ try {
3731
+ const response = await fetchWithErrors(url.toString(), {
3732
+ method: "POST",
3733
+ headers: {
3734
+ ...authHeaders(cred),
3735
+ "Content-Type": "application/json"
3736
+ },
3737
+ body: JSON.stringify(payload)
3738
+ });
3739
+ data = await readJsonResponse(response);
3740
+ } catch (err) {
3741
+ if (err instanceof Error && err.message.startsWith("HTTP_400")) {
3742
+ throw new Error(`${err.message} | ${describeDescriptionBudget(composed)}`, { cause: err });
3743
+ }
3744
+ throw err;
3745
+ }
3611
3746
  return {
3612
3747
  branch: sourceBranch,
3613
3748
  targetBranch: "develop",
@@ -3745,7 +3880,7 @@ async function patchWorkItemRelations(context, cred, workItemId, operation) {
3745
3880
  body: JSON.stringify([operation])
3746
3881
  });
3747
3882
  if (!response.ok) {
3748
- throw new Error(`HTTP_${response.status}`);
3883
+ throw httpError(response);
3749
3884
  }
3750
3885
  }
3751
3886
  async function linkWorkItemToPullRequest(context, repo, cred, prId, workItemId) {
@@ -3794,8 +3929,8 @@ async function resolveReviewerIdentity(org, cred, input) {
3794
3929
  headers: authHeaders(cred)
3795
3930
  });
3796
3931
  } catch (err) {
3797
- if (err instanceof Error && err.message === "AUTH_FAILED") {
3798
- throw new Error("IDENTITY_SCOPE_MISSING", { cause: err });
3932
+ if (err instanceof Error && isSentinel(err.message, "AUTH_FAILED")) {
3933
+ throw new Error(withDetail("IDENTITY_SCOPE_MISSING", sentinelDetail(err.message, "AUTH_FAILED")), { cause: err });
3799
3934
  }
3800
3935
  throw err;
3801
3936
  }
@@ -3853,7 +3988,7 @@ async function removePullRequestReviewer(context, repo, cred, prId, reviewerId)
3853
3988
  headers: authHeaders(cred)
3854
3989
  });
3855
3990
  if (!response.ok) {
3856
- throw new Error(`HTTP_${response.status}`);
3991
+ throw httpError(response);
3857
3992
  }
3858
3993
  return { reviewer: existing, noop: false };
3859
3994
  }
@@ -3892,7 +4027,7 @@ async function fetchRepositoryItemContent(context, repo, cred, path3, branch) {
3892
4027
  throw err;
3893
4028
  }
3894
4029
  if (!response.ok) {
3895
- throw new Error(`HTTP_${response.status}`);
4030
+ throw httpError(response);
3896
4031
  }
3897
4032
  return response.text();
3898
4033
  }
@@ -4027,7 +4162,7 @@ function writeError(message, exitCode = 1) {
4027
4162
  }
4028
4163
  function handlePrCommandError(err, context, mode = "read") {
4029
4164
  const error = err instanceof Error ? err : new Error(String(err));
4030
- if (error.message === "AUTH_FAILED") {
4165
+ if (isSentinel(error.message, "AUTH_FAILED")) {
4031
4166
  const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
4032
4167
  writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`, EXIT_NOT_PERMITTED);
4033
4168
  const credentialHint = describeResolvedCredential();
@@ -4035,17 +4170,20 @@ function handlePrCommandError(err, context, mode = "read") {
4035
4170
  process.stderr.write(` ${credentialHint}
4036
4171
  `);
4037
4172
  }
4173
+ writeErrorDetail(error.message, "AUTH_FAILED");
4038
4174
  return;
4039
4175
  }
4040
- if (error.message === "IDENTITY_SCOPE_MISSING") {
4176
+ if (isSentinel(error.message, "IDENTITY_SCOPE_MISSING")) {
4041
4177
  writeError(
4042
4178
  'Could not resolve reviewer identity: your PAT is missing the "Identity (Read)" scope required by the Azure DevOps identities API (separate from Code scope).',
4043
4179
  EXIT_NOT_PERMITTED
4044
4180
  );
4181
+ writeErrorDetail(error.message, "IDENTITY_SCOPE_MISSING");
4045
4182
  return;
4046
4183
  }
4047
- if (error.message === "PERMISSION_DENIED") {
4184
+ if (isSentinel(error.message, "PERMISSION_DENIED")) {
4048
4185
  writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`, EXIT_NOT_PERMITTED);
4186
+ writeErrorDetail(error.message, "PERMISSION_DENIED");
4049
4187
  return;
4050
4188
  }
4051
4189
  if (error.message === "NETWORK_ERROR") {
@@ -4057,7 +4195,12 @@ function handlePrCommandError(err, context, mode = "read") {
4057
4195
  return;
4058
4196
  }
4059
4197
  if (error.message.startsWith("HTTP_")) {
4060
- writeError(`Azure DevOps request failed with ${error.message}.`);
4198
+ const { sentinel, detail } = splitSentinel(error.message);
4199
+ writeError(`Azure DevOps request failed with ${sentinel}.`);
4200
+ if (detail !== null) {
4201
+ process.stderr.write(` ${detail}
4202
+ `);
4203
+ }
4061
4204
  return;
4062
4205
  }
4063
4206
  writeError(error.message);
@@ -4232,6 +4375,23 @@ function createPrStatusCommand() {
4232
4375
  });
4233
4376
  return command;
4234
4377
  }
4378
+ function handlePrOpenError(err, context) {
4379
+ const message = err instanceof Error ? err.message : "";
4380
+ if (message.startsWith("AMBIGUOUS_PRS:")) {
4381
+ const ids = message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
4382
+ writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
4383
+ return;
4384
+ }
4385
+ if (message === "DESCRIPTION_REQUIRED") {
4386
+ writeError("--description is required for pull request creation.");
4387
+ return;
4388
+ }
4389
+ if (message.startsWith("DESCRIPTION_TOO_LONG: ")) {
4390
+ writeError(message.slice("DESCRIPTION_TOO_LONG: ".length));
4391
+ return;
4392
+ }
4393
+ handlePrCommandError(err, context, "write");
4394
+ }
4235
4395
  function createPrOpenCommand() {
4236
4396
  const command = new Command12("open");
4237
4397
  withCommonPrOptions(command).description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option(
@@ -4280,16 +4440,7 @@ ${result.pullRequest.url ?? "\u2014"}
4280
4440
  `
4281
4441
  );
4282
4442
  } catch (err) {
4283
- if (err instanceof Error && err.message.startsWith("AMBIGUOUS_PRS:")) {
4284
- const ids = err.message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
4285
- writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
4286
- return;
4287
- }
4288
- if (err instanceof Error && err.message === "DESCRIPTION_REQUIRED") {
4289
- writeError("--description is required for pull request creation.");
4290
- return;
4291
- }
4292
- handlePrCommandError(err, context, "write");
4443
+ handlePrOpenError(err, context);
4293
4444
  }
4294
4445
  });
4295
4446
  return command;
@@ -5098,7 +5249,7 @@ function withApiVersion(url) {
5098
5249
  }
5099
5250
  async function readJsonResponse2(response) {
5100
5251
  if (!response.ok) {
5101
- throw new Error(`HTTP_${response.status}`);
5252
+ throw httpError(response);
5102
5253
  }
5103
5254
  return await response.json();
5104
5255
  }
@@ -5374,7 +5525,7 @@ async function getRunLog(context, cred, buildId, logId) {
5374
5525
  );
5375
5526
  const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
5376
5527
  if (!response.ok) {
5377
- throw new Error(`HTTP_${response.status}`);
5528
+ throw httpError(response);
5378
5529
  }
5379
5530
  return response.text();
5380
5531
  }
@@ -5390,12 +5541,14 @@ function writeError2(message) {
5390
5541
  }
5391
5542
  function handlePipelineError(err, context) {
5392
5543
  const error = err instanceof Error ? err : new Error(String(err));
5393
- if (error.message === "AUTH_FAILED") {
5544
+ if (isSentinel(error.message, "AUTH_FAILED")) {
5394
5545
  writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
5546
+ writeErrorDetail(error.message, "AUTH_FAILED");
5395
5547
  return;
5396
5548
  }
5397
- if (error.message === "PERMISSION_DENIED") {
5549
+ if (isSentinel(error.message, "PERMISSION_DENIED")) {
5398
5550
  writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
5551
+ writeErrorDetail(error.message, "PERMISSION_DENIED");
5399
5552
  return;
5400
5553
  }
5401
5554
  if (error.message === "NETWORK_ERROR") {
@@ -5407,7 +5560,12 @@ function handlePipelineError(err, context) {
5407
5560
  return;
5408
5561
  }
5409
5562
  if (error.message.startsWith("HTTP_")) {
5410
- writeError2(`Azure DevOps request failed with ${error.message}.`);
5563
+ const { sentinel, detail } = splitSentinel(error.message);
5564
+ writeError2(`Azure DevOps request failed with ${sentinel}.`);
5565
+ if (detail !== null) {
5566
+ process.stderr.write(` ${detail}
5567
+ `);
5568
+ }
5411
5569
  return;
5412
5570
  }
5413
5571
  writeError2(error.message);
@@ -6299,7 +6457,7 @@ function mapRelationType(raw) {
6299
6457
  };
6300
6458
  }
6301
6459
  async function readJsonResponse3(response) {
6302
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6460
+ if (!response.ok) throw httpError(response);
6303
6461
  return await response.json();
6304
6462
  }
6305
6463
  function parseTargetId(url) {
@@ -6347,7 +6505,7 @@ async function addWorkItemRelation(context, cred, type, id1, id2) {
6347
6505
  { op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
6348
6506
  ])
6349
6507
  });
6350
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6508
+ if (!response.ok) throw httpError(response);
6351
6509
  return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
6352
6510
  }
6353
6511
  async function removeWorkItemRelation(context, cred, type, id1, id2) {
@@ -6367,7 +6525,7 @@ async function removeWorkItemRelation(context, cred, type, id1, id2) {
6367
6525
  headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
6368
6526
  body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
6369
6527
  });
6370
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6528
+ if (!response.ok) throw httpError(response);
6371
6529
  return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
6372
6530
  }
6373
6531
  async function listWorkItemRelations(context, cred, id) {
@@ -6455,11 +6613,12 @@ function handleRelationError(err, id1) {
6455
6613
  const target = id1 !== void 0 ? id1 : "unknown";
6456
6614
  process.stderr.write(`Error: work item #${target} not found.
6457
6615
  `);
6458
- } else if (msg === "AUTH_FAILED") {
6616
+ } else if (isSentinel(msg, "AUTH_FAILED")) {
6459
6617
  process.stderr.write(
6460
6618
  `Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
6461
6619
  `
6462
6620
  );
6621
+ writeErrorDetail(msg, "AUTH_FAILED");
6463
6622
  } else {
6464
6623
  process.stderr.write(`Error: ${msg}
6465
6624
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.19.0-develop.691",
3
+ "version": "0.19.0-develop.703",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {