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.
- package/README.md +13 -1
- package/dist/index.js +582 -212
- 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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
383
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3557
|
-
return
|
|
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
|
-
|
|
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
|
|
3589
|
-
if (
|
|
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:
|
|
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: "
|
|
3765
|
+
method: "PATCH",
|
|
3604
3766
|
headers: {
|
|
3605
3767
|
...authHeaders(cred),
|
|
3606
3768
|
"Content-Type": "application/json"
|
|
3607
3769
|
},
|
|
3608
|
-
body: JSON.stringify(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3983
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
4248
|
-
|
|
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
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
`);
|