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

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 +361 -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(`(\\b(?:access|refresh|api)[_-]?(?:token|key)\\b${SECRET_ASSIGNMENT}`, "gi"), `$1${REDACTED}`],
85
+ [new RegExp(`(\\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,133 @@ 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
+ return truncateDetail(renderErrorFields(redacted) ?? redacted.slice(0, MAX_RAW_BODY_CHARS));
506
+ }
507
+ function truncateDetail(detail) {
508
+ const collapsed = redactText(detail).replace(/\s+/g, " ").trim();
509
+ if (collapsed === "") return null;
510
+ return collapsed.length > MAX_DETAIL_CHARS ? `${collapsed.slice(0, MAX_DETAIL_CHARS)}\u2026(truncated)` : collapsed;
511
+ }
512
+ function httpError(response) {
513
+ return new Error(withDetail(`HTTP_${response.status}`, failureDetails.get(response) ?? null));
514
+ }
515
+ var failureBodies = /* @__PURE__ */ new WeakMap();
516
+ async function readFailureBody(response, tracedBody) {
517
+ if (tracedBody !== null) return tracedBody;
518
+ try {
519
+ return await response.clone().text();
520
+ } catch {
521
+ try {
522
+ return await response.text();
523
+ } catch {
524
+ return null;
525
+ }
526
+ }
527
+ }
528
+ function writeTraceEntry(writer, url, init, response, responseBody) {
529
+ const respHeaders = {};
530
+ response.headers.forEach((v, k) => {
531
+ respHeaders[k] = v;
532
+ });
533
+ const entry = {
534
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
535
+ method: (init.method ?? "GET").toUpperCase(),
536
+ url: redactUrl(url),
537
+ requestHeaders: redactHeaders(init.headers ?? {}),
538
+ requestBody: typeof init.body === "string" ? redactBody(init.body) : null,
539
+ responseStatus: response.status,
540
+ responseHeaders: respHeaders,
541
+ responseBody
542
+ };
543
+ writer.append(entry);
544
+ }
545
+ async function traceResponse(url, init, response) {
330
546
  const writer = getActiveTraceWriter();
547
+ if (!writer) return null;
548
+ let responseBody = "";
549
+ try {
550
+ responseBody = await response.clone().text();
551
+ } catch {
552
+ }
553
+ writeTraceEntry(writer, url, init, response, responseBody);
554
+ return responseBody;
555
+ }
556
+ async function fetchWithErrors(url, init) {
331
557
  let response;
332
558
  try {
333
559
  response = await fetch(url, init);
334
560
  } catch (err) {
335
561
  throw new Error("NETWORK_ERROR", { cause: err });
336
562
  }
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);
563
+ const tracedBody = await traceResponse(url, init, response);
564
+ const contentType = response.headers?.get("content-type") ?? "";
565
+ let failureDetail = null;
566
+ if (!response.ok) {
567
+ const body = await readFailureBody(response, tracedBody);
568
+ if (body !== null) failureBodies.set(response, body);
569
+ failureDetail = describeFailureBody(body, contentType);
570
+ if (failureDetail !== null) failureDetails.set(response, failureDetail);
361
571
  }
362
- if (response.status === 401) throw new Error("AUTH_FAILED");
363
- if (response.status === 403) throw new Error("PERMISSION_DENIED");
572
+ if (response.status === 401) throw new Error(withDetail("AUTH_FAILED", failureDetail));
573
+ if (response.status === 403) throw new Error(withDetail("PERMISSION_DENIED", failureDetail));
364
574
  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}`);
575
+ const body = failureBodies.get(response);
576
+ throw new Error(body === void 0 ? "NOT_FOUND" : `NOT_FOUND | url=${url} | body=${body}`);
372
577
  }
373
- const contentType = response.headers?.get("content-type") ?? "";
374
578
  if (contentType.toLowerCase().startsWith("text/html")) {
375
579
  throw new Error("AUTH_FAILED");
376
580
  }
377
581
  return response;
378
582
  }
379
583
  async function readResponseMessage(response) {
584
+ const captured = failureBodies.get(response);
585
+ const rendered = captured === void 0 ? await renderErrorStream(response) : renderErrorFields(redactBody(captured.trim()) ?? captured.trim(), true);
586
+ return rendered === null ? null : truncateDetail(rendered);
587
+ }
588
+ async function renderErrorStream(response) {
380
589
  try {
381
590
  const body = await response.json();
382
- if (typeof body.message === "string" && body.message.trim() !== "") {
383
- return body.message.trim();
384
- }
591
+ if (typeof body !== "object" || body === null) return null;
592
+ return renderErrorRecord(body, true);
385
593
  } catch {
594
+ return null;
386
595
  }
387
- return null;
388
596
  }
389
597
  function normalizeFieldList(fields) {
390
598
  return Array.from(new Set(fields.map((f) => f.trim()).filter((f) => f.length > 0)));
@@ -464,7 +672,7 @@ async function readWriteResponse(response, errorCode) {
464
672
  throw new Error(`${errorCode}: ${serverMessage}`);
465
673
  }
466
674
  if (!response.ok) {
467
- throw new Error(`HTTP_${response.status}`);
675
+ throw httpError(response);
468
676
  }
469
677
  const data = await response.json();
470
678
  return {
@@ -487,7 +695,7 @@ async function getWorkItemFields(context, id, cred) {
487
695
  }
488
696
  }
489
697
  if (!response.ok) {
490
- throw new Error(`HTTP_${response.status}`);
698
+ throw httpError(response);
491
699
  }
492
700
  const data = await response.json();
493
701
  return data.fields;
@@ -539,7 +747,7 @@ async function fetchWorkItemResponse(context, id, cred, options = {}) {
539
747
  }
540
748
  }
541
749
  if (!response.ok) {
542
- throw new Error(`HTTP_${response.status}`);
750
+ throw httpError(response);
543
751
  }
544
752
  return await response.json();
545
753
  }
@@ -550,7 +758,7 @@ async function getOrgFieldNames(context, cred) {
550
758
  url.searchParams.set("api-version", "7.1");
551
759
  const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
552
760
  if (!response.ok) {
553
- throw new Error(`HTTP_${response.status}`);
761
+ throw httpError(response);
554
762
  }
555
763
  const data = await response.json();
556
764
  return (data.value ?? []).map((f) => f.referenceName);
@@ -632,7 +840,7 @@ async function getWorkItemFieldValue(context, id, cred, fieldName) {
632
840
  }
633
841
  }
634
842
  if (!response.ok) {
635
- throw new Error(`HTTP_${response.status}`);
843
+ throw httpError(response);
636
844
  }
637
845
  const data = await response.json();
638
846
  const value = data.fields[fieldName];
@@ -650,7 +858,7 @@ async function listWorkItemComments(context, id, cred) {
650
858
  { headers: authHeaders(cred) }
651
859
  );
652
860
  if (!response.ok) {
653
- throw new Error(`HTTP_${response.status}`);
861
+ throw httpError(response);
654
862
  }
655
863
  const data = await response.json();
656
864
  comments.push(
@@ -680,7 +888,7 @@ async function addWorkItemComment(context, id, cred, text, format = "html") {
680
888
  throw new Error(`BAD_REQUEST: ${serverMessage}`);
681
889
  }
682
890
  if (!response.ok) {
683
- throw new Error(`HTTP_${response.status}`);
891
+ throw httpError(response);
684
892
  }
685
893
  const data = await response.json();
686
894
  return {
@@ -732,7 +940,7 @@ async function applyWorkItemPatch(context, id, cred, operations) {
732
940
  async function downloadAttachment(url, cred) {
733
941
  const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
734
942
  if (!response.ok) {
735
- throw new Error(`HTTP_${response.status}`);
943
+ throw httpError(response);
736
944
  }
737
945
  return response.arrayBuffer();
738
946
  }
@@ -757,7 +965,7 @@ async function createAttachment(context, fileName, content, cred) {
757
965
  }
758
966
  }
759
967
  if (!response.ok) {
760
- throw new Error(`HTTP_${response.status}`);
968
+ throw httpError(response);
761
969
  }
762
970
  return await response.json();
763
971
  }
@@ -1546,109 +1754,6 @@ function toMarkdown(content) {
1546
1754
  return decodeEntitiesInMarkdownCodeSpans(decodeAdoEntitiesInMarkdown(content));
1547
1755
  }
1548
1756
 
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
1757
  // src/commands/get-item.ts
1653
1758
  function parseRequestedFields(raw) {
1654
1759
  if (raw === void 0) return void 0;
@@ -3132,10 +3237,10 @@ function buildUpsertResult(action, writeResult, fields, fallbackWorkItemType) {
3132
3237
  };
3133
3238
  }
3134
3239
  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:");
3240
+ 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
3241
  }
3137
3242
  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_");
3243
+ 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
3244
  }
3140
3245
  function handleUpsertError(err, id, context) {
3141
3246
  if (!(err instanceof Error)) {
@@ -3465,7 +3570,7 @@ function isThreadResolved(status2) {
3465
3570
  }
3466
3571
  async function readJsonResponse(response) {
3467
3572
  if (!response.ok) {
3468
- throw new Error(`HTTP_${response.status}`);
3573
+ throw httpError(response);
3469
3574
  }
3470
3575
  return response.json();
3471
3576
  }
@@ -3552,19 +3657,35 @@ async function getPullRequestBuilds(context, cred, prId) {
3552
3657
  isBlocking: null
3553
3658
  }));
3554
3659
  }
3660
+ var MAX_PR_DESCRIPTION_CHARS = 4e3;
3661
+ var DESCRIPTION_SEPARATOR = "\n\n";
3555
3662
  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;
3663
+ if (description === void 0 && template === null) {
3664
+ return null;
3566
3665
  }
3567
- return null;
3666
+ const provided = description ?? "";
3667
+ const templateContent = template?.content ?? "";
3668
+ const separator = description !== void 0 && template !== null ? DESCRIPTION_SEPARATOR : "";
3669
+ const text = `${provided}${separator}${templateContent}`;
3670
+ return {
3671
+ text,
3672
+ providedChars: provided.length,
3673
+ separatorChars: separator.length,
3674
+ templateChars: templateContent.length,
3675
+ templatePath: template?.path ?? null,
3676
+ totalChars: text.length
3677
+ };
3678
+ }
3679
+ function formatDescriptionOverflow(composed) {
3680
+ const overflow = composed.totalChars - MAX_PR_DESCRIPTION_CHARS;
3681
+ 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.`;
3682
+ }
3683
+ function formatContributions(composed) {
3684
+ if (composed.templatePath === null) return "";
3685
+ return ` (${composed.providedChars} provided + ${composed.separatorChars} separator + ${composed.templateChars} from the repository pull request template ${composed.templatePath})`;
3686
+ }
3687
+ function describeDescriptionBudget(composed) {
3688
+ return `description: ${composed.providedChars} provided + ${composed.separatorChars} separator + ${composed.templateChars} template = ${composed.totalChars} characters (client limit ${MAX_PR_DESCRIPTION_CHARS})`;
3568
3689
  }
3569
3690
  async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
3570
3691
  const existing = await listPullRequests(context, repo, cred, sourceBranch, {
@@ -3585,29 +3706,40 @@ async function openPullRequest(context, repo, cred, sourceBranch, title, descrip
3585
3706
  const repository = await getRepository(context, repo, cred);
3586
3707
  const defaultBranch = repository.defaultBranch ? repository.defaultBranch.replace(/^refs\/heads\//, "") : "develop";
3587
3708
  const template = await resolvePullRequestTemplate(context, repo, cred, defaultBranch, "develop");
3588
- const finalDescription = composeDescription(description, template);
3589
- if (finalDescription === null) {
3709
+ const composed = composeDescription(description, template);
3710
+ if (composed === null) {
3590
3711
  throw new Error("DESCRIPTION_REQUIRED");
3591
3712
  }
3713
+ if (composed.totalChars > MAX_PR_DESCRIPTION_CHARS) {
3714
+ throw new Error(`DESCRIPTION_TOO_LONG: ${formatDescriptionOverflow(composed)}`);
3715
+ }
3592
3716
  const payload = {
3593
3717
  sourceRefName: `refs/heads/${sourceBranch}`,
3594
3718
  targetRefName: "refs/heads/develop",
3595
3719
  title,
3596
- description: finalDescription
3720
+ description: composed.text
3597
3721
  };
3598
3722
  const url = new URL(
3599
3723
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests`
3600
3724
  );
3601
3725
  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);
3726
+ let data;
3727
+ try {
3728
+ const response = await fetchWithErrors(url.toString(), {
3729
+ method: "POST",
3730
+ headers: {
3731
+ ...authHeaders(cred),
3732
+ "Content-Type": "application/json"
3733
+ },
3734
+ body: JSON.stringify(payload)
3735
+ });
3736
+ data = await readJsonResponse(response);
3737
+ } catch (err) {
3738
+ if (err instanceof Error && err.message.startsWith("HTTP_400")) {
3739
+ throw new Error(`${err.message} | ${describeDescriptionBudget(composed)}`, { cause: err });
3740
+ }
3741
+ throw err;
3742
+ }
3611
3743
  return {
3612
3744
  branch: sourceBranch,
3613
3745
  targetBranch: "develop",
@@ -3745,7 +3877,7 @@ async function patchWorkItemRelations(context, cred, workItemId, operation) {
3745
3877
  body: JSON.stringify([operation])
3746
3878
  });
3747
3879
  if (!response.ok) {
3748
- throw new Error(`HTTP_${response.status}`);
3880
+ throw httpError(response);
3749
3881
  }
3750
3882
  }
3751
3883
  async function linkWorkItemToPullRequest(context, repo, cred, prId, workItemId) {
@@ -3794,8 +3926,8 @@ async function resolveReviewerIdentity(org, cred, input) {
3794
3926
  headers: authHeaders(cred)
3795
3927
  });
3796
3928
  } catch (err) {
3797
- if (err instanceof Error && err.message === "AUTH_FAILED") {
3798
- throw new Error("IDENTITY_SCOPE_MISSING", { cause: err });
3929
+ if (err instanceof Error && isSentinel(err.message, "AUTH_FAILED")) {
3930
+ throw new Error(withDetail("IDENTITY_SCOPE_MISSING", sentinelDetail(err.message, "AUTH_FAILED")), { cause: err });
3799
3931
  }
3800
3932
  throw err;
3801
3933
  }
@@ -3853,7 +3985,7 @@ async function removePullRequestReviewer(context, repo, cred, prId, reviewerId)
3853
3985
  headers: authHeaders(cred)
3854
3986
  });
3855
3987
  if (!response.ok) {
3856
- throw new Error(`HTTP_${response.status}`);
3988
+ throw httpError(response);
3857
3989
  }
3858
3990
  return { reviewer: existing, noop: false };
3859
3991
  }
@@ -3892,7 +4024,7 @@ async function fetchRepositoryItemContent(context, repo, cred, path3, branch) {
3892
4024
  throw err;
3893
4025
  }
3894
4026
  if (!response.ok) {
3895
- throw new Error(`HTTP_${response.status}`);
4027
+ throw httpError(response);
3896
4028
  }
3897
4029
  return response.text();
3898
4030
  }
@@ -4027,7 +4159,7 @@ function writeError(message, exitCode = 1) {
4027
4159
  }
4028
4160
  function handlePrCommandError(err, context, mode = "read") {
4029
4161
  const error = err instanceof Error ? err : new Error(String(err));
4030
- if (error.message === "AUTH_FAILED") {
4162
+ if (isSentinel(error.message, "AUTH_FAILED")) {
4031
4163
  const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
4032
4164
  writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`, EXIT_NOT_PERMITTED);
4033
4165
  const credentialHint = describeResolvedCredential();
@@ -4035,17 +4167,20 @@ function handlePrCommandError(err, context, mode = "read") {
4035
4167
  process.stderr.write(` ${credentialHint}
4036
4168
  `);
4037
4169
  }
4170
+ writeErrorDetail(error.message, "AUTH_FAILED");
4038
4171
  return;
4039
4172
  }
4040
- if (error.message === "IDENTITY_SCOPE_MISSING") {
4173
+ if (isSentinel(error.message, "IDENTITY_SCOPE_MISSING")) {
4041
4174
  writeError(
4042
4175
  '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
4176
  EXIT_NOT_PERMITTED
4044
4177
  );
4178
+ writeErrorDetail(error.message, "IDENTITY_SCOPE_MISSING");
4045
4179
  return;
4046
4180
  }
4047
- if (error.message === "PERMISSION_DENIED") {
4181
+ if (isSentinel(error.message, "PERMISSION_DENIED")) {
4048
4182
  writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`, EXIT_NOT_PERMITTED);
4183
+ writeErrorDetail(error.message, "PERMISSION_DENIED");
4049
4184
  return;
4050
4185
  }
4051
4186
  if (error.message === "NETWORK_ERROR") {
@@ -4057,7 +4192,12 @@ function handlePrCommandError(err, context, mode = "read") {
4057
4192
  return;
4058
4193
  }
4059
4194
  if (error.message.startsWith("HTTP_")) {
4060
- writeError(`Azure DevOps request failed with ${error.message}.`);
4195
+ const { sentinel, detail } = splitSentinel(error.message);
4196
+ writeError(`Azure DevOps request failed with ${sentinel}.`);
4197
+ if (detail !== null) {
4198
+ process.stderr.write(` ${detail}
4199
+ `);
4200
+ }
4061
4201
  return;
4062
4202
  }
4063
4203
  writeError(error.message);
@@ -4232,6 +4372,23 @@ function createPrStatusCommand() {
4232
4372
  });
4233
4373
  return command;
4234
4374
  }
4375
+ function handlePrOpenError(err, context) {
4376
+ const message = err instanceof Error ? err.message : "";
4377
+ if (message.startsWith("AMBIGUOUS_PRS:")) {
4378
+ const ids = message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
4379
+ writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
4380
+ return;
4381
+ }
4382
+ if (message === "DESCRIPTION_REQUIRED") {
4383
+ writeError("--description is required for pull request creation.");
4384
+ return;
4385
+ }
4386
+ if (message.startsWith("DESCRIPTION_TOO_LONG: ")) {
4387
+ writeError(message.slice("DESCRIPTION_TOO_LONG: ".length));
4388
+ return;
4389
+ }
4390
+ handlePrCommandError(err, context, "write");
4391
+ }
4235
4392
  function createPrOpenCommand() {
4236
4393
  const command = new Command12("open");
4237
4394
  withCommonPrOptions(command).description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option(
@@ -4280,16 +4437,7 @@ ${result.pullRequest.url ?? "\u2014"}
4280
4437
  `
4281
4438
  );
4282
4439
  } 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");
4440
+ handlePrOpenError(err, context);
4293
4441
  }
4294
4442
  });
4295
4443
  return command;
@@ -5098,7 +5246,7 @@ function withApiVersion(url) {
5098
5246
  }
5099
5247
  async function readJsonResponse2(response) {
5100
5248
  if (!response.ok) {
5101
- throw new Error(`HTTP_${response.status}`);
5249
+ throw httpError(response);
5102
5250
  }
5103
5251
  return await response.json();
5104
5252
  }
@@ -5374,7 +5522,7 @@ async function getRunLog(context, cred, buildId, logId) {
5374
5522
  );
5375
5523
  const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
5376
5524
  if (!response.ok) {
5377
- throw new Error(`HTTP_${response.status}`);
5525
+ throw httpError(response);
5378
5526
  }
5379
5527
  return response.text();
5380
5528
  }
@@ -5390,12 +5538,14 @@ function writeError2(message) {
5390
5538
  }
5391
5539
  function handlePipelineError(err, context) {
5392
5540
  const error = err instanceof Error ? err : new Error(String(err));
5393
- if (error.message === "AUTH_FAILED") {
5541
+ if (isSentinel(error.message, "AUTH_FAILED")) {
5394
5542
  writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
5543
+ writeErrorDetail(error.message, "AUTH_FAILED");
5395
5544
  return;
5396
5545
  }
5397
- if (error.message === "PERMISSION_DENIED") {
5546
+ if (isSentinel(error.message, "PERMISSION_DENIED")) {
5398
5547
  writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
5548
+ writeErrorDetail(error.message, "PERMISSION_DENIED");
5399
5549
  return;
5400
5550
  }
5401
5551
  if (error.message === "NETWORK_ERROR") {
@@ -5407,7 +5557,12 @@ function handlePipelineError(err, context) {
5407
5557
  return;
5408
5558
  }
5409
5559
  if (error.message.startsWith("HTTP_")) {
5410
- writeError2(`Azure DevOps request failed with ${error.message}.`);
5560
+ const { sentinel, detail } = splitSentinel(error.message);
5561
+ writeError2(`Azure DevOps request failed with ${sentinel}.`);
5562
+ if (detail !== null) {
5563
+ process.stderr.write(` ${detail}
5564
+ `);
5565
+ }
5411
5566
  return;
5412
5567
  }
5413
5568
  writeError2(error.message);
@@ -6299,7 +6454,7 @@ function mapRelationType(raw) {
6299
6454
  };
6300
6455
  }
6301
6456
  async function readJsonResponse3(response) {
6302
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6457
+ if (!response.ok) throw httpError(response);
6303
6458
  return await response.json();
6304
6459
  }
6305
6460
  function parseTargetId(url) {
@@ -6347,7 +6502,7 @@ async function addWorkItemRelation(context, cred, type, id1, id2) {
6347
6502
  { op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
6348
6503
  ])
6349
6504
  });
6350
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6505
+ if (!response.ok) throw httpError(response);
6351
6506
  return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
6352
6507
  }
6353
6508
  async function removeWorkItemRelation(context, cred, type, id1, id2) {
@@ -6367,7 +6522,7 @@ async function removeWorkItemRelation(context, cred, type, id1, id2) {
6367
6522
  headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
6368
6523
  body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
6369
6524
  });
6370
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6525
+ if (!response.ok) throw httpError(response);
6371
6526
  return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
6372
6527
  }
6373
6528
  async function listWorkItemRelations(context, cred, id) {
@@ -6455,11 +6610,12 @@ function handleRelationError(err, id1) {
6455
6610
  const target = id1 !== void 0 ? id1 : "unknown";
6456
6611
  process.stderr.write(`Error: work item #${target} not found.
6457
6612
  `);
6458
- } else if (msg === "AUTH_FAILED") {
6613
+ } else if (isSentinel(msg, "AUTH_FAILED")) {
6459
6614
  process.stderr.write(
6460
6615
  `Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
6461
6616
  `
6462
6617
  );
6618
+ writeErrorDetail(msg, "AUTH_FAILED");
6463
6619
  } else {
6464
6620
  process.stderr.write(`Error: ${msg}
6465
6621
  `);
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.697",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {