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

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 +13 -1
  2. package/dist/index.js +582 -212
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,8 @@ Azure DevOps CLI focused on work item read/write workflows.
15
15
  - Attach a local file to a work item, or remove a named attachment (`add-attachment`, `delete-attachment`)
16
16
  - Read/write rich-text fields as markdown (`get-md-field`, `set-md-field`)
17
17
  - Download images embedded in rich-text fields, optionally resized for LLM use (`get-item`/`get-md-field` `--download-images`, `--resize-images`)
18
- - Check branch pull request status, open PRs to `develop` (optionally pre-filled from a repository-defined template), list PR comment threads for any PR (`--pr-number`), resolve/reopen threads, link/unlink work items, and add/remove required or optional reviewers — all from the CLI (`pr`)
18
+ - Check branch pull request status, open PRs to `develop` (optionally pre-filled from a repository-defined template), update an existing PR's title or description (`pr update`), list PR comment threads for any PR (`--pr-number`), resolve/reopen threads, link/unlink work items, and add/remove required or optional reviewers — all from the CLI (`pr`)
19
+ - Feed long PR titles, descriptions and comment bodies from a file or a pipe instead of the shell (`--title-file`, `--description-file`, `--file`; `-` reads standard input)
19
20
  - Persist org/project/default fields in local config (`config`)
20
21
  - List all fields of a work item (`list-fields`)
21
22
  - Authenticate per Azure DevOps organization with `azdo auth login` — OAuth (Microsoft Entra) by default, or a Personal Access Token via `--use-pat` (or the `AZDO_PAT` env var). Credentials are stored in the OS credential store. Inspect with `azdo auth status`, remove with `azdo auth logout`. Diagnose auth problems with `azdo auth diagnose`. See [docs/authentication.md](docs/authentication.md).
@@ -85,10 +86,21 @@ azdo pr comments edit 148 --file plan.md --pr-number 64 # rewrite it in p
85
86
  azdo pr comments reply 148 "Great suggestion, I'll address it." # human-readable output
86
87
  azdo pr comments reply 148 "Done." --pr-number 64 --json # JSON: { pullRequestId, threadId, commentId, content }
87
88
  azdo pr comment-reply 148 "Done." --pr-number 64 # flat alias, identical behaviour
89
+ git log -1 --format=%B | azdo pr comments add --file - --pr-number 64 # "-" reads standard input
88
90
 
89
91
  # Open a pull request — description from a repo template when you don't pass one
90
92
  azdo pr open --title "Fix the thing" --description "Because X was broken"
91
93
  azdo pr open --title "Fix the thing" # uses docs/pull_request_template[/branches/<branch>].md if present
94
+ azdo pr open --title "Fix the thing" --description-file body.md # or --description-file - to pipe it in
95
+ # The description plus the template must stay within Azure DevOps' 4000-character cap; over it,
96
+ # the command says by how much and creates nothing. See docs/commands.md.
97
+
98
+ # Fix a title or description after the fact — a PR is no longer write-once
99
+ azdo pr update --pr-number 64 --title "A real title" # description untouched
100
+ azdo pr update --pr-number 64 --description-file body.md # replaces literally; no template is prepended
101
+ cat body.md | azdo pr edit --pr-number 64 --description-file - # "edit" is an alias for "update"
102
+ azdo pr update --pr-number 64 --title "A real title" --json # { pullRequestId, title, description, url, noop, updatedFields }
103
+ # Re-running with the values it already has writes nothing and reports "noop": true.
92
104
 
93
105
  # Link/unlink a work item, add/remove reviewers
94
106
  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,35 +3709,68 @@ 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");
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
+ }
3746
+ return {
3747
+ branch: sourceBranch,
3748
+ targetBranch: "develop",
3749
+ created: true,
3750
+ pullRequest: mapPullRequest(context, repo, data)
3751
+ };
3752
+ }
3753
+ async function updatePullRequest(context, repo, cred, prId, fields) {
3754
+ if (fields.description !== void 0 && fields.description.length > MAX_PR_DESCRIPTION_CHARS) {
3755
+ const overflow = fields.description.length - MAX_PR_DESCRIPTION_CHARS;
3756
+ throw new Error(
3757
+ `DESCRIPTION_TOO_LONG: description is ${fields.description.length} characters, exceeding the Azure DevOps limit of ${MAX_PR_DESCRIPTION_CHARS} characters. Shorten the description by at least ${overflow} characters.`
3758
+ );
3759
+ }
3760
+ const url = new URL(
3761
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests/${prId}`
3762
+ );
3763
+ url.searchParams.set("api-version", "7.1");
3602
3764
  const response = await fetchWithErrors(url.toString(), {
3603
- method: "POST",
3765
+ method: "PATCH",
3604
3766
  headers: {
3605
3767
  ...authHeaders(cred),
3606
3768
  "Content-Type": "application/json"
3607
3769
  },
3608
- body: JSON.stringify(payload)
3770
+ body: JSON.stringify(fields)
3609
3771
  });
3610
3772
  const data = await readJsonResponse(response);
3611
- return {
3612
- branch: sourceBranch,
3613
- targetBranch: "develop",
3614
- created: true,
3615
- pullRequest: mapPullRequest(context, repo, data)
3616
- };
3773
+ return mapPullRequest(context, repo, data);
3617
3774
  }
3618
3775
  async function getPullRequestThreads(context, repo, cred, prId) {
3619
3776
  const url = new URL(
@@ -3745,7 +3902,7 @@ async function patchWorkItemRelations(context, cred, workItemId, operation) {
3745
3902
  body: JSON.stringify([operation])
3746
3903
  });
3747
3904
  if (!response.ok) {
3748
- throw new Error(`HTTP_${response.status}`);
3905
+ throw httpError(response);
3749
3906
  }
3750
3907
  }
3751
3908
  async function linkWorkItemToPullRequest(context, repo, cred, prId, workItemId) {
@@ -3794,8 +3951,8 @@ async function resolveReviewerIdentity(org, cred, input) {
3794
3951
  headers: authHeaders(cred)
3795
3952
  });
3796
3953
  } catch (err) {
3797
- if (err instanceof Error && err.message === "AUTH_FAILED") {
3798
- throw new Error("IDENTITY_SCOPE_MISSING", { cause: err });
3954
+ if (err instanceof Error && isSentinel(err.message, "AUTH_FAILED")) {
3955
+ throw new Error(withDetail("IDENTITY_SCOPE_MISSING", sentinelDetail(err.message, "AUTH_FAILED")), { cause: err });
3799
3956
  }
3800
3957
  throw err;
3801
3958
  }
@@ -3853,7 +4010,7 @@ async function removePullRequestReviewer(context, repo, cred, prId, reviewerId)
3853
4010
  headers: authHeaders(cred)
3854
4011
  });
3855
4012
  if (!response.ok) {
3856
- throw new Error(`HTTP_${response.status}`);
4013
+ throw httpError(response);
3857
4014
  }
3858
4015
  return { reviewer: existing, noop: false };
3859
4016
  }
@@ -3892,7 +4049,7 @@ async function fetchRepositoryItemContent(context, repo, cred, path3, branch) {
3892
4049
  throw err;
3893
4050
  }
3894
4051
  if (!response.ok) {
3895
- throw new Error(`HTTP_${response.status}`);
4052
+ throw httpError(response);
3896
4053
  }
3897
4054
  return response.text();
3898
4055
  }
@@ -3972,6 +4129,27 @@ function writeContractError(line) {
3972
4129
  process.exitCode = 1;
3973
4130
  }
3974
4131
  var EMPTY_BODY_ERROR = "Comment text must not be empty. Pass the text inline or use --file <path>.";
4132
+ var STDIN_PATH = "-";
4133
+ function readTextSource(file) {
4134
+ if (file === STDIN_PATH) {
4135
+ try {
4136
+ return readFileSync5(0, "utf-8");
4137
+ } catch {
4138
+ writeError("Cannot read standard input.");
4139
+ return null;
4140
+ }
4141
+ }
4142
+ if (!existsSync5(file)) {
4143
+ writeError(`File not found: ${file}`);
4144
+ return null;
4145
+ }
4146
+ try {
4147
+ return readFileSync5(file, "utf-8");
4148
+ } catch {
4149
+ writeError(`Cannot read file: ${file}`);
4150
+ return null;
4151
+ }
4152
+ }
3975
4153
  function resolveCommentBody(inline, file, emptyMessage = EMPTY_BODY_ERROR) {
3976
4154
  if (inline !== void 0 && file !== void 0) {
3977
4155
  writeError("Cannot specify both inline text and --file.");
@@ -3979,16 +4157,11 @@ function resolveCommentBody(inline, file, emptyMessage = EMPTY_BODY_ERROR) {
3979
4157
  }
3980
4158
  let body;
3981
4159
  if (file !== void 0) {
3982
- if (!existsSync5(file)) {
3983
- writeError(`File not found: ${file}`);
3984
- return null;
3985
- }
3986
- try {
3987
- body = readFileSync5(file, "utf-8");
3988
- } catch {
3989
- writeError(`Cannot read file: ${file}`);
4160
+ const read = readTextSource(file);
4161
+ if (read === null) {
3990
4162
  return null;
3991
4163
  }
4164
+ body = read;
3992
4165
  } else if (inline !== void 0) {
3993
4166
  body = inline;
3994
4167
  } else {
@@ -4002,6 +4175,33 @@ function resolveCommentBody(inline, file, emptyMessage = EMPTY_BODY_ERROR) {
4002
4175
  }
4003
4176
  return trimmed;
4004
4177
  }
4178
+ function resolveOptionalTextInput(inline, file, flag) {
4179
+ if (inline !== void 0 && file !== void 0) {
4180
+ writeError(`Cannot specify both --${flag} and --${flag}-file.`);
4181
+ return null;
4182
+ }
4183
+ if (inline === void 0 && file === void 0) {
4184
+ return void 0;
4185
+ }
4186
+ const label = flag === "title" ? "Title" : "Description";
4187
+ const emptyMessage = `${label} must not be empty. Pass the text inline or use --${flag}-file <path>.`;
4188
+ let value;
4189
+ if (file !== void 0) {
4190
+ const read = readTextSource(file);
4191
+ if (read === null) {
4192
+ return null;
4193
+ }
4194
+ value = read;
4195
+ } else {
4196
+ value = inline;
4197
+ }
4198
+ const trimmed = value.trim();
4199
+ if (trimmed === "") {
4200
+ writeError(emptyMessage);
4201
+ return null;
4202
+ }
4203
+ return trimmed;
4204
+ }
4005
4205
  function parseNonNegativeInt(raw) {
4006
4206
  if (!/^\d+$/.test(raw)) {
4007
4207
  return null;
@@ -4027,7 +4227,7 @@ function writeError(message, exitCode = 1) {
4027
4227
  }
4028
4228
  function handlePrCommandError(err, context, mode = "read") {
4029
4229
  const error = err instanceof Error ? err : new Error(String(err));
4030
- if (error.message === "AUTH_FAILED") {
4230
+ if (isSentinel(error.message, "AUTH_FAILED")) {
4031
4231
  const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
4032
4232
  writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`, EXIT_NOT_PERMITTED);
4033
4233
  const credentialHint = describeResolvedCredential();
@@ -4035,17 +4235,20 @@ function handlePrCommandError(err, context, mode = "read") {
4035
4235
  process.stderr.write(` ${credentialHint}
4036
4236
  `);
4037
4237
  }
4238
+ writeErrorDetail(error.message, "AUTH_FAILED");
4038
4239
  return;
4039
4240
  }
4040
- if (error.message === "IDENTITY_SCOPE_MISSING") {
4241
+ if (isSentinel(error.message, "IDENTITY_SCOPE_MISSING")) {
4041
4242
  writeError(
4042
4243
  '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
4244
  EXIT_NOT_PERMITTED
4044
4245
  );
4246
+ writeErrorDetail(error.message, "IDENTITY_SCOPE_MISSING");
4045
4247
  return;
4046
4248
  }
4047
- if (error.message === "PERMISSION_DENIED") {
4249
+ if (isSentinel(error.message, "PERMISSION_DENIED")) {
4048
4250
  writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`, EXIT_NOT_PERMITTED);
4251
+ writeErrorDetail(error.message, "PERMISSION_DENIED");
4049
4252
  return;
4050
4253
  }
4051
4254
  if (error.message === "NETWORK_ERROR") {
@@ -4057,7 +4260,12 @@ function handlePrCommandError(err, context, mode = "read") {
4057
4260
  return;
4058
4261
  }
4059
4262
  if (error.message.startsWith("HTTP_")) {
4060
- writeError(`Azure DevOps request failed with ${error.message}.`);
4263
+ const { sentinel, detail } = splitSentinel(error.message);
4264
+ writeError(`Azure DevOps request failed with ${sentinel}.`);
4265
+ if (detail !== null) {
4266
+ process.stderr.write(` ${detail}
4267
+ `);
4268
+ }
4061
4269
  return;
4062
4270
  }
4063
4271
  writeError(error.message);
@@ -4232,11 +4440,54 @@ function createPrStatusCommand() {
4232
4440
  });
4233
4441
  return command;
4234
4442
  }
4443
+ function handlePrOpenError(err, context) {
4444
+ const message = err instanceof Error ? err.message : "";
4445
+ if (message.startsWith("AMBIGUOUS_PRS:")) {
4446
+ const ids = message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
4447
+ writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
4448
+ return;
4449
+ }
4450
+ if (message === "DESCRIPTION_REQUIRED") {
4451
+ writeError("--description is required for pull request creation.");
4452
+ return;
4453
+ }
4454
+ if (message.startsWith("DESCRIPTION_TOO_LONG: ")) {
4455
+ writeError(message.slice("DESCRIPTION_TOO_LONG: ".length));
4456
+ return;
4457
+ }
4458
+ handlePrCommandError(err, context, "write");
4459
+ }
4460
+ function resolveOpenDescription(inline, file) {
4461
+ if (inline !== void 0 && file !== void 0) {
4462
+ writeError("Cannot specify both --description and --description-file.");
4463
+ return null;
4464
+ }
4465
+ if (file !== void 0) {
4466
+ const fromFile = readTextSource(file);
4467
+ if (fromFile === null) {
4468
+ return null;
4469
+ }
4470
+ const trimmed = fromFile.trim();
4471
+ if (trimmed === "") {
4472
+ writeError("Description must not be empty. Pass the text inline or use --description-file <path>.");
4473
+ return null;
4474
+ }
4475
+ return trimmed;
4476
+ }
4477
+ const trimmedInline = inline?.trim();
4478
+ if (trimmedInline === void 0 || trimmedInline === "") {
4479
+ return void 0;
4480
+ }
4481
+ return trimmedInline;
4482
+ }
4235
4483
  function createPrOpenCommand() {
4236
4484
  const command = new Command12("open");
4237
4485
  withCommonPrOptions(command).description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option(
4238
4486
  "--description <description>",
4239
4487
  "pull request description; when omitted, a repository-defined pull request template is used if one exists (prepended by this text when both are present)"
4488
+ ).option(
4489
+ "--description-file <path>",
4490
+ 'read the description from a UTF-8 file instead of --description; "-" reads standard input'
4240
4491
  ).option("--json", "output JSON").action(async (options) => {
4241
4492
  validateOrgProjectPair(options);
4242
4493
  const title = options.title?.trim();
@@ -4244,8 +4495,10 @@ function createPrOpenCommand() {
4244
4495
  writeError("--title is required for pull request creation.");
4245
4496
  return;
4246
4497
  }
4247
- const trimmedDescription = options.description?.trim();
4248
- const description = trimmedDescription && trimmedDescription.length > 0 ? trimmedDescription : void 0;
4498
+ const description = resolveOpenDescription(options.description, options.descriptionFile);
4499
+ if (description === null) {
4500
+ return;
4501
+ }
4249
4502
  let context;
4250
4503
  try {
4251
4504
  const resolved = await resolvePrCommandContext(options);
@@ -4280,17 +4533,125 @@ ${result.pullRequest.url ?? "\u2014"}
4280
4533
  `
4281
4534
  );
4282
4535
  } 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.");
4536
+ handlePrOpenError(err, context);
4537
+ }
4538
+ });
4539
+ return command;
4540
+ }
4541
+ function handlePrUpdateError(err, context) {
4542
+ const message = err instanceof Error ? err.message : "";
4543
+ if (message.startsWith("DESCRIPTION_TOO_LONG: ")) {
4544
+ writeError(message.slice("DESCRIPTION_TOO_LONG: ".length));
4545
+ return;
4546
+ }
4547
+ handlePrCommandError(err, context, "write");
4548
+ }
4549
+ function joinFieldNames(fields) {
4550
+ return fields.join(" and ");
4551
+ }
4552
+ function resolveRequestedFields(options) {
4553
+ if (options.titleFile === STDIN_PATH && options.descriptionFile === STDIN_PATH) {
4554
+ writeError('Cannot read standard input twice; only one of --title-file and --description-file may be "-".');
4555
+ return null;
4556
+ }
4557
+ const title = resolveOptionalTextInput(options.title, options.titleFile, "title");
4558
+ if (title === null) {
4559
+ return null;
4560
+ }
4561
+ const description = resolveOptionalTextInput(options.description, options.descriptionFile, "description");
4562
+ if (description === null) {
4563
+ return null;
4564
+ }
4565
+ if (title === void 0 && description === void 0) {
4566
+ writeError("pr update requires at least one of --title, --title-file, --description or --description-file.");
4567
+ return null;
4568
+ }
4569
+ return { title, description };
4570
+ }
4571
+ function diffRequestedFields(requested, current) {
4572
+ const fields = {};
4573
+ const updatedFields = [];
4574
+ const requestedFields = [];
4575
+ if (requested.title !== void 0) {
4576
+ requestedFields.push("title");
4577
+ if (requested.title !== current.title) {
4578
+ fields.title = requested.title;
4579
+ updatedFields.push("title");
4580
+ }
4581
+ }
4582
+ if (requested.description !== void 0) {
4583
+ requestedFields.push("description");
4584
+ if (requested.description !== (current.description ?? "")) {
4585
+ fields.description = requested.description;
4586
+ updatedFields.push("description");
4587
+ }
4588
+ }
4589
+ return { fields, updatedFields, requestedFields };
4590
+ }
4591
+ async function runPrUpdate(options) {
4592
+ const requested = resolveRequestedFields(options);
4593
+ if (requested === null) {
4594
+ return;
4595
+ }
4596
+ let context;
4597
+ try {
4598
+ const target = await resolvePullRequestTarget(options);
4599
+ if (target === null) {
4600
+ return;
4601
+ }
4602
+ context = target.context;
4603
+ const current = target.pullRequest;
4604
+ const { fields, updatedFields, requestedFields } = diffRequestedFields(requested, current);
4605
+ if (updatedFields.length === 0) {
4606
+ const noopResult = {
4607
+ pullRequestId: current.id,
4608
+ title: current.title,
4609
+ description: current.description ?? null,
4610
+ url: current.url,
4611
+ noop: true,
4612
+ updatedFields: []
4613
+ };
4614
+ if (options.json) {
4615
+ process.stdout.write(`${JSON.stringify(noopResult, null, 2)}
4616
+ `);
4290
4617
  return;
4291
4618
  }
4292
- handlePrCommandError(err, context, "write");
4619
+ process.stdout.write(
4620
+ `Pull request #${current.id} already has the requested ${joinFieldNames(requestedFields)}; nothing to update.
4621
+ `
4622
+ );
4623
+ return;
4624
+ }
4625
+ const updated = await updatePullRequest(target.context, target.repo, target.pat, current.id, fields);
4626
+ const result = {
4627
+ pullRequestId: updated.id,
4628
+ title: updated.title,
4629
+ description: updated.description ?? null,
4630
+ url: updated.url,
4631
+ noop: false,
4632
+ updatedFields
4633
+ };
4634
+ if (options.json) {
4635
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4636
+ `);
4637
+ return;
4293
4638
  }
4639
+ process.stdout.write(
4640
+ `Updated pull request #${updated.id} (${updatedFields.join(", ")}).
4641
+ ${updated.url ?? "\u2014"}
4642
+ `
4643
+ );
4644
+ } catch (err) {
4645
+ handlePrUpdateError(err, context);
4646
+ }
4647
+ }
4648
+ function createPrUpdateCommand() {
4649
+ const command = new Command12("update");
4650
+ withCommonPrOptions(configureUnwrappedHelp(command)).alias("edit").description("Update a pull request's title and/or description").option("--pr-number <N>", PR_NUMBER_HELP).option("--title <title>", "new pull request title").option("--title-file <path>", 'read the new title from a UTF-8 file; "-" reads standard input').option(
4651
+ "--description <description>",
4652
+ 'new pull request description; REPLACES the existing description literally \u2014 no repository pull request template is looked up or prepended, unlike "azdo pr open"'
4653
+ ).option("--description-file <path>", 'read the new description from a UTF-8 file; "-" reads standard input').option("--json", "output JSON").action(async (_options, command2) => {
4654
+ await runPrUpdate(mergedPrOptions(command2));
4294
4655
  });
4295
4656
  return command;
4296
4657
  }
@@ -5073,6 +5434,7 @@ function createPrCommand() {
5073
5434
  command.addCommand(createPrListCommand());
5074
5435
  command.addCommand(createPrStatusCommand());
5075
5436
  command.addCommand(createPrOpenCommand());
5437
+ command.addCommand(createPrUpdateCommand());
5076
5438
  command.addCommand(createPrCommentsCommand());
5077
5439
  command.addCommand(createPrCommentResolveCommand());
5078
5440
  command.addCommand(createPrCommentReopenCommand());
@@ -5098,7 +5460,7 @@ function withApiVersion(url) {
5098
5460
  }
5099
5461
  async function readJsonResponse2(response) {
5100
5462
  if (!response.ok) {
5101
- throw new Error(`HTTP_${response.status}`);
5463
+ throw httpError(response);
5102
5464
  }
5103
5465
  return await response.json();
5104
5466
  }
@@ -5374,7 +5736,7 @@ async function getRunLog(context, cred, buildId, logId) {
5374
5736
  );
5375
5737
  const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
5376
5738
  if (!response.ok) {
5377
- throw new Error(`HTTP_${response.status}`);
5739
+ throw httpError(response);
5378
5740
  }
5379
5741
  return response.text();
5380
5742
  }
@@ -5390,12 +5752,14 @@ function writeError2(message) {
5390
5752
  }
5391
5753
  function handlePipelineError(err, context) {
5392
5754
  const error = err instanceof Error ? err : new Error(String(err));
5393
- if (error.message === "AUTH_FAILED") {
5755
+ if (isSentinel(error.message, "AUTH_FAILED")) {
5394
5756
  writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
5757
+ writeErrorDetail(error.message, "AUTH_FAILED");
5395
5758
  return;
5396
5759
  }
5397
- if (error.message === "PERMISSION_DENIED") {
5760
+ if (isSentinel(error.message, "PERMISSION_DENIED")) {
5398
5761
  writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
5762
+ writeErrorDetail(error.message, "PERMISSION_DENIED");
5399
5763
  return;
5400
5764
  }
5401
5765
  if (error.message === "NETWORK_ERROR") {
@@ -5407,7 +5771,12 @@ function handlePipelineError(err, context) {
5407
5771
  return;
5408
5772
  }
5409
5773
  if (error.message.startsWith("HTTP_")) {
5410
- writeError2(`Azure DevOps request failed with ${error.message}.`);
5774
+ const { sentinel, detail } = splitSentinel(error.message);
5775
+ writeError2(`Azure DevOps request failed with ${sentinel}.`);
5776
+ if (detail !== null) {
5777
+ process.stderr.write(` ${detail}
5778
+ `);
5779
+ }
5411
5780
  return;
5412
5781
  }
5413
5782
  writeError2(error.message);
@@ -6299,7 +6668,7 @@ function mapRelationType(raw) {
6299
6668
  };
6300
6669
  }
6301
6670
  async function readJsonResponse3(response) {
6302
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6671
+ if (!response.ok) throw httpError(response);
6303
6672
  return await response.json();
6304
6673
  }
6305
6674
  function parseTargetId(url) {
@@ -6347,7 +6716,7 @@ async function addWorkItemRelation(context, cred, type, id1, id2) {
6347
6716
  { op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
6348
6717
  ])
6349
6718
  });
6350
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6719
+ if (!response.ok) throw httpError(response);
6351
6720
  return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
6352
6721
  }
6353
6722
  async function removeWorkItemRelation(context, cred, type, id1, id2) {
@@ -6367,7 +6736,7 @@ async function removeWorkItemRelation(context, cred, type, id1, id2) {
6367
6736
  headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
6368
6737
  body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
6369
6738
  });
6370
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
6739
+ if (!response.ok) throw httpError(response);
6371
6740
  return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
6372
6741
  }
6373
6742
  async function listWorkItemRelations(context, cred, id) {
@@ -6455,11 +6824,12 @@ function handleRelationError(err, id1) {
6455
6824
  const target = id1 !== void 0 ? id1 : "unknown";
6456
6825
  process.stderr.write(`Error: work item #${target} not found.
6457
6826
  `);
6458
- } else if (msg === "AUTH_FAILED") {
6827
+ } else if (isSentinel(msg, "AUTH_FAILED")) {
6459
6828
  process.stderr.write(
6460
6829
  `Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
6461
6830
  `
6462
6831
  );
6832
+ writeErrorDetail(msg, "AUTH_FAILED");
6463
6833
  } else {
6464
6834
  process.stderr.write(`Error: ${msg}
6465
6835
  `);
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.710",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {