azdo-cli 0.19.0-feature-037-api-error-surfacing.692 → 0.19.0-feature-037-api-error-surfacing.695

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 +362 -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,22 @@ function redactUrl(url) {
78
78
  return url;
79
79
  }
80
80
  }
81
+ var SECRET_IN_TEXT = [
82
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${REDACTED}`],
83
+ [
84
+ /(\b(?:access[_-]?token|refresh[_-]?token|token|pat|password|secret|api[_-]?key)\b["']?\s*[:=]\s*["']?)[^"'\s,;&}]+/gi,
85
+ `$1${REDACTED}`
86
+ ],
87
+ [/\beyJ[A-Za-z0-9._~+/=-]{20,}/g, REDACTED],
88
+ [/\b(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{40,}\b/g, REDACTED]
89
+ ];
90
+ function redactText(text) {
91
+ let out = text;
92
+ for (const [pattern, replacement] of SECRET_IN_TEXT) {
93
+ out = out.replace(pattern, replacement);
94
+ }
95
+ return out;
96
+ }
81
97
  function redactBody(body) {
82
98
  if (body === null) return null;
83
99
  try {
@@ -123,6 +139,131 @@ function getActiveTraceWriter() {
123
139
  return activeWriter;
124
140
  }
125
141
 
142
+ // src/services/command-helpers.ts
143
+ function isSentinel(message, sentinel) {
144
+ return message === sentinel || message.startsWith(`${sentinel}: `);
145
+ }
146
+ function withDetail(sentinel, detail) {
147
+ return detail === null || detail === "" ? sentinel : `${sentinel}: ${detail}`;
148
+ }
149
+ function sentinelDetail(message, sentinel) {
150
+ return message.startsWith(`${sentinel}: `) ? message.slice(sentinel.length + 2) : null;
151
+ }
152
+ function splitSentinel(message) {
153
+ const separator = message.indexOf(": ");
154
+ return separator === -1 ? { sentinel: message, detail: null } : { sentinel: message.slice(0, separator), detail: message.slice(separator + 2) };
155
+ }
156
+ function writeErrorDetail(message, sentinel) {
157
+ const detail = sentinelDetail(message, sentinel);
158
+ if (detail !== null) {
159
+ process.stderr.write(` ${detail}
160
+ `);
161
+ }
162
+ }
163
+ async function promptYesNo(prompt) {
164
+ if (!process.stdin.isTTY) return true;
165
+ process.stderr.write(prompt);
166
+ return await new Promise((resolve2) => {
167
+ process.stdin.setEncoding("utf8");
168
+ let answered = false;
169
+ const handler = (data) => {
170
+ if (answered) return;
171
+ answered = true;
172
+ process.stdin.removeListener("data", handler);
173
+ process.stdin.pause();
174
+ const trimmed = data.trim().toLowerCase();
175
+ resolve2(trimmed === "y" || trimmed === "yes");
176
+ };
177
+ process.stdin.resume();
178
+ process.stdin.on("data", handler);
179
+ });
180
+ }
181
+ function parseWorkItemId(idStr) {
182
+ const id = Number.parseInt(idStr, 10);
183
+ if (!Number.isInteger(id) || id <= 0) {
184
+ process.stderr.write(
185
+ `Error: Work item ID must be a positive integer. Got: "${idStr}"
186
+ `
187
+ );
188
+ process.exit(1);
189
+ }
190
+ return id;
191
+ }
192
+ function validateOrgProjectPair(options) {
193
+ const hasOrg = options.org !== void 0;
194
+ const hasProject = options.project !== void 0;
195
+ if (hasOrg !== hasProject) {
196
+ process.stderr.write(
197
+ "Error: --org and --project must both be provided, or both omitted.\n"
198
+ );
199
+ process.exit(1);
200
+ }
201
+ }
202
+ function validateSource(options) {
203
+ const hasContent = options.content !== void 0;
204
+ const hasFile = options.file !== void 0;
205
+ if (hasContent === hasFile) {
206
+ process.stderr.write("Error: provide exactly one of --content or --file\n");
207
+ process.exit(1);
208
+ }
209
+ }
210
+ function formatCreateError(err) {
211
+ const error = err instanceof Error ? err : new Error(String(err));
212
+ const message = error.message.startsWith("CREATE_REJECTED:") ? error.message.replace("CREATE_REJECTED:", "").trim() : error.message;
213
+ const requiredMatches = [...message.matchAll(/field ['"]([^'"]+)['"]/gi)];
214
+ if (requiredMatches.length > 0) {
215
+ const fields = Array.from(new Set(requiredMatches.map((match) => match[1])));
216
+ return `Create rejected: ${message} (fields: ${fields.join(", ")})`;
217
+ }
218
+ return `Create rejected: ${message}`;
219
+ }
220
+ function handleCommandError(err, id, context, scope = "write", exit = true) {
221
+ const error = err instanceof Error ? err : new Error(String(err));
222
+ const msg = error.message;
223
+ const scopeLabel = scope === "read" ? "Work Items (read)" : "Work Items (Read & Write)";
224
+ if (isSentinel(msg, "AUTH_FAILED")) {
225
+ process.stderr.write(
226
+ `Error: Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.
227
+ `
228
+ );
229
+ writeErrorDetail(msg, "AUTH_FAILED");
230
+ } else if (isSentinel(msg, "PERMISSION_DENIED")) {
231
+ process.stderr.write(
232
+ `Error: Access denied. Your PAT may lack ${scope} permissions for project "${context?.project}".
233
+ `
234
+ );
235
+ writeErrorDetail(msg, "PERMISSION_DENIED");
236
+ } else if (msg.startsWith("NOT_FOUND")) {
237
+ process.stderr.write(
238
+ `Error: Work item ${id} not found in ${context?.org}/${context?.project}.
239
+ `
240
+ );
241
+ } else if (msg === "NETWORK_ERROR") {
242
+ process.stderr.write(
243
+ "Error: Could not connect to Azure DevOps. Check your network connection.\n"
244
+ );
245
+ } else if (msg.startsWith("BAD_REQUEST:")) {
246
+ const serverMsg = msg.replace("BAD_REQUEST: ", "");
247
+ process.stderr.write(`Error: Request rejected: ${serverMsg}
248
+ `);
249
+ } else if (msg.startsWith("CREATE_REJECTED:")) {
250
+ process.stderr.write(`Error: ${formatCreateError(error)}
251
+ `);
252
+ } else if (msg.startsWith("UPDATE_REJECTED:")) {
253
+ const serverMsg = msg.replace("UPDATE_REJECTED: ", "");
254
+ process.stderr.write(`Error: Update rejected: ${serverMsg}
255
+ `);
256
+ } else {
257
+ process.stderr.write(`Error: ${msg}
258
+ `);
259
+ }
260
+ if (exit) {
261
+ process.exit(1);
262
+ } else {
263
+ process.exitCode = 1;
264
+ }
265
+ }
266
+
126
267
  // src/services/image-download.ts
127
268
  import { Jimp } from "jimp";
128
269
  import { writeFile } from "fs/promises";
@@ -326,65 +467,133 @@ async function fetchRaw(url, init) {
326
467
  const body = await response.text();
327
468
  return { status: response.status, body };
328
469
  }
329
- async function fetchWithErrors(url, init) {
470
+ var MAX_DETAIL_CHARS = 500;
471
+ var MAX_RAW_BODY_CHARS = 200;
472
+ var failureDetails = /* @__PURE__ */ new WeakMap();
473
+ function renderErrorFields(body, requireMessage = false) {
474
+ let parsed;
475
+ try {
476
+ parsed = JSON.parse(body);
477
+ } catch {
478
+ return null;
479
+ }
480
+ if (typeof parsed !== "object" || parsed === null) return null;
481
+ return renderErrorRecord(parsed, requireMessage);
482
+ }
483
+ function renderErrorRecord(record, requireMessage) {
484
+ const parts = [];
485
+ const message = typeof record.message === "string" ? record.message.trim() : "";
486
+ if (message !== "") {
487
+ parts.push(message);
488
+ } else if (requireMessage) {
489
+ return null;
490
+ }
491
+ if (typeof record.typeKey === "string" && record.typeKey.trim() !== "") {
492
+ parts.push(`[${record.typeKey.trim()}]`);
493
+ } else if (typeof record.errorCode === "number" || typeof record.errorCode === "string") {
494
+ parts.push(`[errorCode ${record.errorCode}]`);
495
+ }
496
+ return parts.length === 0 ? null : parts.join(" ");
497
+ }
498
+ function describeFailureBody(body, contentType) {
499
+ if (body === null) return null;
500
+ const trimmed = body.trim();
501
+ if (trimmed === "") return null;
502
+ if (contentType.toLowerCase().startsWith("text/html") || trimmed.startsWith("<")) {
503
+ return null;
504
+ }
505
+ const redacted = redactBody(trimmed) ?? trimmed;
506
+ return truncateDetail(renderErrorFields(redacted) ?? redacted.slice(0, MAX_RAW_BODY_CHARS));
507
+ }
508
+ function truncateDetail(detail) {
509
+ const collapsed = redactText(detail).replace(/\s+/g, " ").trim();
510
+ if (collapsed === "") return null;
511
+ return collapsed.length > MAX_DETAIL_CHARS ? `${collapsed.slice(0, MAX_DETAIL_CHARS)}\u2026(truncated)` : collapsed;
512
+ }
513
+ function httpError(response) {
514
+ return new Error(withDetail(`HTTP_${response.status}`, failureDetails.get(response) ?? null));
515
+ }
516
+ var failureBodies = /* @__PURE__ */ new WeakMap();
517
+ async function readFailureBody(response, tracedBody) {
518
+ if (tracedBody !== null) return tracedBody;
519
+ try {
520
+ return await response.clone().text();
521
+ } catch {
522
+ try {
523
+ return await response.text();
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+ }
529
+ function writeTraceEntry(writer, url, init, response, responseBody) {
530
+ const respHeaders = {};
531
+ response.headers.forEach((v, k) => {
532
+ respHeaders[k] = v;
533
+ });
534
+ const entry = {
535
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
536
+ method: (init.method ?? "GET").toUpperCase(),
537
+ url: redactUrl(url),
538
+ requestHeaders: redactHeaders(init.headers ?? {}),
539
+ requestBody: typeof init.body === "string" ? redactBody(init.body) : null,
540
+ responseStatus: response.status,
541
+ responseHeaders: respHeaders,
542
+ responseBody
543
+ };
544
+ writer.append(entry);
545
+ }
546
+ async function traceResponse(url, init, response) {
330
547
  const writer = getActiveTraceWriter();
548
+ if (!writer) return null;
549
+ let responseBody = "";
550
+ try {
551
+ responseBody = await response.clone().text();
552
+ } catch {
553
+ }
554
+ writeTraceEntry(writer, url, init, response, responseBody);
555
+ return responseBody;
556
+ }
557
+ async function fetchWithErrors(url, init) {
331
558
  let response;
332
559
  try {
333
560
  response = await fetch(url, init);
334
561
  } catch (err) {
335
562
  throw new Error("NETWORK_ERROR", { cause: err });
336
563
  }
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);
564
+ const tracedBody = await traceResponse(url, init, response);
565
+ const contentType = response.headers?.get("content-type") ?? "";
566
+ let failureDetail = null;
567
+ if (!response.ok) {
568
+ const body = await readFailureBody(response, tracedBody);
569
+ if (body !== null) failureBodies.set(response, body);
570
+ failureDetail = describeFailureBody(body, contentType);
571
+ if (failureDetail !== null) failureDetails.set(response, failureDetail);
361
572
  }
362
- if (response.status === 401) throw new Error("AUTH_FAILED");
363
- if (response.status === 403) throw new Error("PERMISSION_DENIED");
573
+ if (response.status === 401) throw new Error(withDetail("AUTH_FAILED", failureDetail));
574
+ if (response.status === 403) throw new Error(withDetail("PERMISSION_DENIED", failureDetail));
364
575
  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}`);
576
+ const body = failureBodies.get(response);
577
+ throw new Error(body === void 0 ? "NOT_FOUND" : `NOT_FOUND | url=${url} | body=${body}`);
372
578
  }
373
- const contentType = response.headers?.get("content-type") ?? "";
374
579
  if (contentType.toLowerCase().startsWith("text/html")) {
375
580
  throw new Error("AUTH_FAILED");
376
581
  }
377
582
  return response;
378
583
  }
379
584
  async function readResponseMessage(response) {
585
+ const captured = failureBodies.get(response);
586
+ const rendered = captured === void 0 ? await renderErrorStream(response) : renderErrorFields(redactBody(captured.trim()) ?? captured.trim(), true);
587
+ return rendered === null ? null : truncateDetail(rendered);
588
+ }
589
+ async function renderErrorStream(response) {
380
590
  try {
381
591
  const body = await response.json();
382
- if (typeof body.message === "string" && body.message.trim() !== "") {
383
- return body.message.trim();
384
- }
592
+ if (typeof body !== "object" || body === null) return null;
593
+ return renderErrorRecord(body, true);
385
594
  } catch {
595
+ return null;
386
596
  }
387
- return null;
388
597
  }
389
598
  function normalizeFieldList(fields) {
390
599
  return Array.from(new Set(fields.map((f) => f.trim()).filter((f) => f.length > 0)));
@@ -464,7 +673,7 @@ async function readWriteResponse(response, errorCode) {
464
673
  throw new Error(`${errorCode}: ${serverMessage}`);
465
674
  }
466
675
  if (!response.ok) {
467
- throw new Error(`HTTP_${response.status}`);
676
+ throw httpError(response);
468
677
  }
469
678
  const data = await response.json();
470
679
  return {
@@ -487,7 +696,7 @@ async function getWorkItemFields(context, id, cred) {
487
696
  }
488
697
  }
489
698
  if (!response.ok) {
490
- throw new Error(`HTTP_${response.status}`);
699
+ throw httpError(response);
491
700
  }
492
701
  const data = await response.json();
493
702
  return data.fields;
@@ -539,7 +748,7 @@ async function fetchWorkItemResponse(context, id, cred, options = {}) {
539
748
  }
540
749
  }
541
750
  if (!response.ok) {
542
- throw new Error(`HTTP_${response.status}`);
751
+ throw httpError(response);
543
752
  }
544
753
  return await response.json();
545
754
  }
@@ -550,7 +759,7 @@ async function getOrgFieldNames(context, cred) {
550
759
  url.searchParams.set("api-version", "7.1");
551
760
  const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
552
761
  if (!response.ok) {
553
- throw new Error(`HTTP_${response.status}`);
762
+ throw httpError(response);
554
763
  }
555
764
  const data = await response.json();
556
765
  return (data.value ?? []).map((f) => f.referenceName);
@@ -632,7 +841,7 @@ async function getWorkItemFieldValue(context, id, cred, fieldName) {
632
841
  }
633
842
  }
634
843
  if (!response.ok) {
635
- throw new Error(`HTTP_${response.status}`);
844
+ throw httpError(response);
636
845
  }
637
846
  const data = await response.json();
638
847
  const value = data.fields[fieldName];
@@ -650,7 +859,7 @@ async function listWorkItemComments(context, id, cred) {
650
859
  { headers: authHeaders(cred) }
651
860
  );
652
861
  if (!response.ok) {
653
- throw new Error(`HTTP_${response.status}`);
862
+ throw httpError(response);
654
863
  }
655
864
  const data = await response.json();
656
865
  comments.push(
@@ -680,7 +889,7 @@ async function addWorkItemComment(context, id, cred, text, format = "html") {
680
889
  throw new Error(`BAD_REQUEST: ${serverMessage}`);
681
890
  }
682
891
  if (!response.ok) {
683
- throw new Error(`HTTP_${response.status}`);
892
+ throw httpError(response);
684
893
  }
685
894
  const data = await response.json();
686
895
  return {
@@ -732,7 +941,7 @@ async function applyWorkItemPatch(context, id, cred, operations) {
732
941
  async function downloadAttachment(url, cred) {
733
942
  const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
734
943
  if (!response.ok) {
735
- throw new Error(`HTTP_${response.status}`);
944
+ throw httpError(response);
736
945
  }
737
946
  return response.arrayBuffer();
738
947
  }
@@ -757,7 +966,7 @@ async function createAttachment(context, fileName, content, cred) {
757
966
  }
758
967
  }
759
968
  if (!response.ok) {
760
- throw new Error(`HTTP_${response.status}`);
969
+ throw httpError(response);
761
970
  }
762
971
  return await response.json();
763
972
  }
@@ -1546,109 +1755,6 @@ function toMarkdown(content) {
1546
1755
  return decodeEntitiesInMarkdownCodeSpans(decodeAdoEntitiesInMarkdown(content));
1547
1756
  }
1548
1757
 
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
1758
  // src/commands/get-item.ts
1653
1759
  function parseRequestedFields(raw) {
1654
1760
  if (raw === void 0) return void 0;
@@ -3132,10 +3238,10 @@ function buildUpsertResult(action, writeResult, fields, fallbackWorkItemType) {
3132
3238
  };
3133
3239
  }
3134
3240
  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:");
3241
+ 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
3242
  }
3137
3243
  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_");
3244
+ 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
3245
  }
3140
3246
  function handleUpsertError(err, id, context) {
3141
3247
  if (!(err instanceof Error)) {
@@ -3465,7 +3571,7 @@ function isThreadResolved(status2) {
3465
3571
  }
3466
3572
  async function readJsonResponse(response) {
3467
3573
  if (!response.ok) {
3468
- throw new Error(`HTTP_${response.status}`);
3574
+ throw httpError(response);
3469
3575
  }
3470
3576
  return response.json();
3471
3577
  }
@@ -3552,19 +3658,35 @@ async function getPullRequestBuilds(context, cred, prId) {
3552
3658
  isBlocking: null
3553
3659
  }));
3554
3660
  }
3661
+ var MAX_PR_DESCRIPTION_CHARS = 4e3;
3662
+ var DESCRIPTION_SEPARATOR = "\n\n";
3555
3663
  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;
3664
+ if (description === void 0 && template === null) {
3665
+ return null;
3566
3666
  }
3567
- return null;
3667
+ const provided = description ?? "";
3668
+ const templateContent = template?.content ?? "";
3669
+ const separator = description !== void 0 && template !== null ? DESCRIPTION_SEPARATOR : "";
3670
+ const text = `${provided}${separator}${templateContent}`;
3671
+ return {
3672
+ text,
3673
+ providedChars: provided.length,
3674
+ separatorChars: separator.length,
3675
+ templateChars: templateContent.length,
3676
+ templatePath: template?.path ?? null,
3677
+ totalChars: text.length
3678
+ };
3679
+ }
3680
+ function formatDescriptionOverflow(composed) {
3681
+ const overflow = composed.totalChars - MAX_PR_DESCRIPTION_CHARS;
3682
+ 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.`;
3683
+ }
3684
+ function formatContributions(composed) {
3685
+ if (composed.templatePath === null) return "";
3686
+ return ` (${composed.providedChars} provided + ${composed.separatorChars} separator + ${composed.templateChars} from the repository pull request template ${composed.templatePath})`;
3687
+ }
3688
+ function describeDescriptionBudget(composed) {
3689
+ return `description: ${composed.providedChars} provided + ${composed.separatorChars} separator + ${composed.templateChars} template = ${composed.totalChars} characters (client limit ${MAX_PR_DESCRIPTION_CHARS})`;
3568
3690
  }
3569
3691
  async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
3570
3692
  const existing = await listPullRequests(context, repo, cred, sourceBranch, {
@@ -3585,29 +3707,40 @@ async function openPullRequest(context, repo, cred, sourceBranch, title, descrip
3585
3707
  const repository = await getRepository(context, repo, cred);
3586
3708
  const defaultBranch = repository.defaultBranch ? repository.defaultBranch.replace(/^refs\/heads\//, "") : "develop";
3587
3709
  const template = await resolvePullRequestTemplate(context, repo, cred, defaultBranch, "develop");
3588
- const finalDescription = composeDescription(description, template);
3589
- if (finalDescription === null) {
3710
+ const composed = composeDescription(description, template);
3711
+ if (composed === null) {
3590
3712
  throw new Error("DESCRIPTION_REQUIRED");
3591
3713
  }
3714
+ if (composed.totalChars > MAX_PR_DESCRIPTION_CHARS) {
3715
+ throw new Error(`DESCRIPTION_TOO_LONG: ${formatDescriptionOverflow(composed)}`);
3716
+ }
3592
3717
  const payload = {
3593
3718
  sourceRefName: `refs/heads/${sourceBranch}`,
3594
3719
  targetRefName: "refs/heads/develop",
3595
3720
  title,
3596
- description: finalDescription
3721
+ description: composed.text
3597
3722
  };
3598
3723
  const url = new URL(
3599
3724
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
3600
3725
  );
3601
3726
  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);
3727
+ let data;
3728
+ try {
3729
+ const response = await fetchWithErrors(url.toString(), {
3730
+ method: "POST",
3731
+ headers: {
3732
+ ...authHeaders(cred),
3733
+ "Content-Type": "application/json"
3734
+ },
3735
+ body: JSON.stringify(payload)
3736
+ });
3737
+ data = await readJsonResponse(response);
3738
+ } catch (err) {
3739
+ if (err instanceof Error && err.message.startsWith("HTTP_400")) {
3740
+ throw new Error(`${err.message} | ${describeDescriptionBudget(composed)}`, { cause: err });
3741
+ }
3742
+ throw err;
3743
+ }
3611
3744
  return {
3612
3745
  branch: sourceBranch,
3613
3746
  targetBranch: "develop",
@@ -3745,7 +3878,7 @@ async function patchWorkItemRelations(context, cred, workItemId, operation) {
3745
3878
  body: JSON.stringify([operation])
3746
3879
  });
3747
3880
  if (!response.ok) {
3748
- throw new Error(`HTTP_${response.status}`);
3881
+ throw httpError(response);
3749
3882
  }
3750
3883
  }
3751
3884
  async function linkWorkItemToPullRequest(context, repo, cred, prId, workItemId) {
@@ -3794,8 +3927,8 @@ async function resolveReviewerIdentity(org, cred, input) {
3794
3927
  headers: authHeaders(cred)
3795
3928
  });
3796
3929
  } catch (err) {
3797
- if (err instanceof Error && err.message === "AUTH_FAILED") {
3798
- throw new Error("IDENTITY_SCOPE_MISSING", { cause: err });
3930
+ if (err instanceof Error && isSentinel(err.message, "AUTH_FAILED")) {
3931
+ throw new Error(withDetail("IDENTITY_SCOPE_MISSING", sentinelDetail(err.message, "AUTH_FAILED")), { cause: err });
3799
3932
  }
3800
3933
  throw err;
3801
3934
  }
@@ -3853,7 +3986,7 @@ async function removePullRequestReviewer(context, repo, cred, prId, reviewerId)
3853
3986
  headers: authHeaders(cred)
3854
3987
  });
3855
3988
  if (!response.ok) {
3856
- throw new Error(`HTTP_${response.status}`);
3989
+ throw httpError(response);
3857
3990
  }
3858
3991
  return { reviewer: existing, noop: false };
3859
3992
  }
@@ -3892,7 +4025,7 @@ async function fetchRepositoryItemContent(context, repo, cred, path3, branch) {
3892
4025
  throw err;
3893
4026
  }
3894
4027
  if (!response.ok) {
3895
- throw new Error(`HTTP_${response.status}`);
4028
+ throw httpError(response);
3896
4029
  }
3897
4030
  return response.text();
3898
4031
  }
@@ -4027,7 +4160,7 @@ function writeError(message, exitCode = 1) {
4027
4160
  }
4028
4161
  function handlePrCommandError(err, context, mode = "read") {
4029
4162
  const error = err instanceof Error ? err : new Error(String(err));
4030
- if (error.message === "AUTH_FAILED") {
4163
+ if (isSentinel(error.message, "AUTH_FAILED")) {
4031
4164
  const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
4032
4165
  writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`, EXIT_NOT_PERMITTED);
4033
4166
  const credentialHint = describeResolvedCredential();
@@ -4035,17 +4168,20 @@ function handlePrCommandError(err, context, mode = "read") {
4035
4168
  process.stderr.write(` ${credentialHint}
4036
4169
  `);
4037
4170
  }
4171
+ writeErrorDetail(error.message, "AUTH_FAILED");
4038
4172
  return;
4039
4173
  }
4040
- if (error.message === "IDENTITY_SCOPE_MISSING") {
4174
+ if (isSentinel(error.message, "IDENTITY_SCOPE_MISSING")) {
4041
4175
  writeError(
4042
4176
  '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
4177
  EXIT_NOT_PERMITTED
4044
4178
  );
4179
+ writeErrorDetail(error.message, "IDENTITY_SCOPE_MISSING");
4045
4180
  return;
4046
4181
  }
4047
- if (error.message === "PERMISSION_DENIED") {
4182
+ if (isSentinel(error.message, "PERMISSION_DENIED")) {
4048
4183
  writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`, EXIT_NOT_PERMITTED);
4184
+ writeErrorDetail(error.message, "PERMISSION_DENIED");
4049
4185
  return;
4050
4186
  }
4051
4187
  if (error.message === "NETWORK_ERROR") {
@@ -4057,7 +4193,12 @@ function handlePrCommandError(err, context, mode = "read") {
4057
4193
  return;
4058
4194
  }
4059
4195
  if (error.message.startsWith("HTTP_")) {
4060
- writeError(`Azure DevOps request failed with ${error.message}.`);
4196
+ const { sentinel, detail } = splitSentinel(error.message);
4197
+ writeError(`Azure DevOps request failed with ${sentinel}.`);
4198
+ if (detail !== null) {
4199
+ process.stderr.write(` ${detail}
4200
+ `);
4201
+ }
4061
4202
  return;
4062
4203
  }
4063
4204
  writeError(error.message);
@@ -4232,6 +4373,23 @@ function createPrStatusCommand() {
4232
4373
  });
4233
4374
  return command;
4234
4375
  }
4376
+ function handlePrOpenError(err, context) {
4377
+ const message = err instanceof Error ? err.message : "";
4378
+ if (message.startsWith("AMBIGUOUS_PRS:")) {
4379
+ const ids = message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
4380
+ writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
4381
+ return;
4382
+ }
4383
+ if (message === "DESCRIPTION_REQUIRED") {
4384
+ writeError("--description is required for pull request creation.");
4385
+ return;
4386
+ }
4387
+ if (message.startsWith("DESCRIPTION_TOO_LONG: ")) {
4388
+ writeError(message.slice("DESCRIPTION_TOO_LONG: ".length));
4389
+ return;
4390
+ }
4391
+ handlePrCommandError(err, context, "write");
4392
+ }
4235
4393
  function createPrOpenCommand() {
4236
4394
  const command = new Command12("open");
4237
4395
  withCommonPrOptions(command).description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option(
@@ -4280,16 +4438,7 @@ ${result.pullRequest.url ?? "\u2014"}
4280
4438
  `
4281
4439
  );
4282
4440
  } 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");
4441
+ handlePrOpenError(err, context);
4293
4442
  }
4294
4443
  });
4295
4444
  return command;
@@ -5098,7 +5247,7 @@ function withApiVersion(url) {
5098
5247
  }
5099
5248
  async function readJsonResponse2(response) {
5100
5249
  if (!response.ok) {
5101
- throw new Error(`HTTP_${response.status}`);
5250
+ throw httpError(response);
5102
5251
  }
5103
5252
  return await response.json();
5104
5253
  }
@@ -5374,7 +5523,7 @@ async function getRunLog(context, cred, buildId, logId) {
5374
5523
  );
5375
5524
  const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
5376
5525
  if (!response.ok) {
5377
- throw new Error(`HTTP_${response.status}`);
5526
+ throw httpError(response);
5378
5527
  }
5379
5528
  return response.text();
5380
5529
  }
@@ -5390,12 +5539,14 @@ function writeError2(message) {
5390
5539
  }
5391
5540
  function handlePipelineError(err, context) {
5392
5541
  const error = err instanceof Error ? err : new Error(String(err));
5393
- if (error.message === "AUTH_FAILED") {
5542
+ if (isSentinel(error.message, "AUTH_FAILED")) {
5394
5543
  writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
5544
+ writeErrorDetail(error.message, "AUTH_FAILED");
5395
5545
  return;
5396
5546
  }
5397
- if (error.message === "PERMISSION_DENIED") {
5547
+ if (isSentinel(error.message, "PERMISSION_DENIED")) {
5398
5548
  writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
5549
+ writeErrorDetail(error.message, "PERMISSION_DENIED");
5399
5550
  return;
5400
5551
  }
5401
5552
  if (error.message === "NETWORK_ERROR") {
@@ -5407,7 +5558,12 @@ function handlePipelineError(err, context) {
5407
5558
  return;
5408
5559
  }
5409
5560
  if (error.message.startsWith("HTTP_")) {
5410
- writeError2(`Azure DevOps request failed with ${error.message}.`);
5561
+ const { sentinel, detail } = splitSentinel(error.message);
5562
+ writeError2(`Azure DevOps request failed with ${sentinel}.`);
5563
+ if (detail !== null) {
5564
+ process.stderr.write(` ${detail}
5565
+ `);
5566
+ }
5411
5567
  return;
5412
5568
  }
5413
5569
  writeError2(error.message);
@@ -6299,7 +6455,7 @@ function mapRelationType(raw) {
6299
6455
  };
6300
6456
  }
6301
6457
  async function readJsonResponse3(response) {
6302
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6458
+ if (!response.ok) throw httpError(response);
6303
6459
  return await response.json();
6304
6460
  }
6305
6461
  function parseTargetId(url) {
@@ -6347,7 +6503,7 @@ async function addWorkItemRelation(context, cred, type, id1, id2) {
6347
6503
  { op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
6348
6504
  ])
6349
6505
  });
6350
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6506
+ if (!response.ok) throw httpError(response);
6351
6507
  return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
6352
6508
  }
6353
6509
  async function removeWorkItemRelation(context, cred, type, id1, id2) {
@@ -6367,7 +6523,7 @@ async function removeWorkItemRelation(context, cred, type, id1, id2) {
6367
6523
  headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
6368
6524
  body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
6369
6525
  });
6370
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6526
+ if (!response.ok) throw httpError(response);
6371
6527
  return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
6372
6528
  }
6373
6529
  async function listWorkItemRelations(context, cred, id) {
@@ -6455,11 +6611,12 @@ function handleRelationError(err, id1) {
6455
6611
  const target = id1 !== void 0 ? id1 : "unknown";
6456
6612
  process.stderr.write(`Error: work item #${target} not found.
6457
6613
  `);
6458
- } else if (msg === "AUTH_FAILED") {
6614
+ } else if (isSentinel(msg, "AUTH_FAILED")) {
6459
6615
  process.stderr.write(
6460
6616
  `Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
6461
6617
  `
6462
6618
  );
6619
+ writeErrorDetail(msg, "AUTH_FAILED");
6463
6620
  } else {
6464
6621
  process.stderr.write(`Error: ${msg}
6465
6622
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.19.0-feature-037-api-error-surfacing.692",
3
+ "version": "0.19.0-feature-037-api-error-surfacing.695",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {