azdo-cli 0.18.0-develop.649 → 0.18.0-develop.675
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 +7 -0
- package/dist/index.js +371 -194
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ Azure DevOps CLI focused on work item read/write workflows.
|
|
|
12
12
|
- Update work item state, assignee, or any field (`set-state`, `assign`, `set-field`)
|
|
13
13
|
- Create or update work items from markdown documents (`upsert`)
|
|
14
14
|
- Read and post work item comments (`comments`)
|
|
15
|
+
- Attach a local file to a work item, or remove a named attachment (`add-attachment`, `delete-attachment`)
|
|
15
16
|
- Read/write rich-text fields as markdown (`get-md-field`, `set-md-field`)
|
|
16
17
|
- Download images embedded in rich-text fields, optionally resized for LLM use (`get-item`/`get-md-field` `--download-images`, `--resize-images`)
|
|
17
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`)
|
|
@@ -54,6 +55,12 @@ azdo upsert --type "User Story" --content $'---\nTitle: Improve markdown import
|
|
|
54
55
|
azdo comments list 12345
|
|
55
56
|
azdo comments add 12345 "Investigating the root cause now."
|
|
56
57
|
|
|
58
|
+
# Attach a local file to a work item, or remove one
|
|
59
|
+
azdo add-attachment 12345 ./screenshot.png --comment "Repro captured on staging"
|
|
60
|
+
azdo delete-attachment 12345 screenshot.png # prompts for confirmation
|
|
61
|
+
azdo delete-attachment 12345 screenshot.png --yes # skip the prompt (scripting)
|
|
62
|
+
azdo delete-attachment 12345 screenshot.png --id <guid> # disambiguate when the name is shared
|
|
63
|
+
|
|
57
64
|
# Find a pull request — one API call, any branch
|
|
58
65
|
azdo pr list # active PRs in the repository
|
|
59
66
|
azdo pr list --branch feature/x --json # id, title, source/target, author, url, description
|
package/dist/index.js
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
} from "./chunk-TY5KENBQ.js";
|
|
39
39
|
|
|
40
40
|
// src/index.ts
|
|
41
|
-
import { Command as
|
|
41
|
+
import { Command as Command19 } from "commander";
|
|
42
42
|
|
|
43
43
|
// src/version.ts
|
|
44
44
|
import { readFileSync } from "fs";
|
|
@@ -123,6 +123,176 @@ function getActiveTraceWriter() {
|
|
|
123
123
|
return activeWriter;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// src/services/image-download.ts
|
|
127
|
+
import { Jimp } from "jimp";
|
|
128
|
+
import { writeFile } from "fs/promises";
|
|
129
|
+
import { existsSync } from "fs";
|
|
130
|
+
import { tmpdir } from "os";
|
|
131
|
+
import { join } from "path";
|
|
132
|
+
var ATTACHMENT_GUID_RE = /_apis\/wit\/attachments\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/;
|
|
133
|
+
function extractAttachmentGuid(url) {
|
|
134
|
+
const match = ATTACHMENT_GUID_RE.exec(url);
|
|
135
|
+
return match ? match[1].toLowerCase() : null;
|
|
136
|
+
}
|
|
137
|
+
function isAzureDevOpsAttachmentHost(hostname) {
|
|
138
|
+
const host = hostname.toLowerCase();
|
|
139
|
+
return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
|
|
140
|
+
}
|
|
141
|
+
function decodeHtmlEntities(value) {
|
|
142
|
+
return value.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
143
|
+
}
|
|
144
|
+
function parseAttachmentReference(rawUrl, sourceField) {
|
|
145
|
+
const url = decodeHtmlEntities(rawUrl.trim());
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = new URL(url);
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const guid = extractAttachmentGuid(parsed.pathname);
|
|
156
|
+
if (!guid) return null;
|
|
157
|
+
let suggestedExtension = ".png";
|
|
158
|
+
const fileName = parsed.searchParams.get("fileName");
|
|
159
|
+
if (fileName?.includes(".")) {
|
|
160
|
+
suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
return { url, sourceField, guid, suggestedExtension };
|
|
163
|
+
}
|
|
164
|
+
function extractImageReferences(content, sourceField) {
|
|
165
|
+
if (!content) return [];
|
|
166
|
+
const references = [];
|
|
167
|
+
const seen = /* @__PURE__ */ new Set();
|
|
168
|
+
const add = (rawUrl) => {
|
|
169
|
+
const reference = parseAttachmentReference(rawUrl, sourceField);
|
|
170
|
+
if (reference && !seen.has(reference.guid)) {
|
|
171
|
+
seen.add(reference.guid);
|
|
172
|
+
references.push(reference);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
|
|
176
|
+
let match;
|
|
177
|
+
while ((match = imgRegex.exec(content)) !== null) {
|
|
178
|
+
add(match[1]);
|
|
179
|
+
}
|
|
180
|
+
const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
|
|
181
|
+
while ((match = markdownRegex.exec(content)) !== null) {
|
|
182
|
+
add(match[1]);
|
|
183
|
+
}
|
|
184
|
+
return references;
|
|
185
|
+
}
|
|
186
|
+
function addImageDownloadOptions(command) {
|
|
187
|
+
return command.option("--download-images", "download images embedded in rich-text fields to local files").option("--resize-images <pixels>", "max image width in px; downloads and resizes embedded images to PNG (implies --download-images)").option("--images-path <dir>", "destination directory for downloaded images (default: system temp dir)");
|
|
188
|
+
}
|
|
189
|
+
function resolveImageDownloadOptionsOrExit(flags) {
|
|
190
|
+
try {
|
|
191
|
+
return resolveImageDownloadOptions(flags);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
194
|
+
`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function resolveImageDownloadOptions(flags) {
|
|
199
|
+
const wantsResize = flags.resizeImages !== void 0;
|
|
200
|
+
const enabled = Boolean(flags.downloadImages) || wantsResize;
|
|
201
|
+
let maxWidth;
|
|
202
|
+
if (wantsResize) {
|
|
203
|
+
const parsed = Number(flags.resizeImages);
|
|
204
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
maxWidth = parsed;
|
|
210
|
+
}
|
|
211
|
+
const outputDir = flags.imagesPath ?? tmpdir();
|
|
212
|
+
if (flags.imagesPath !== void 0 && !existsSync(outputDir)) {
|
|
213
|
+
throw new Error(`Images path "${outputDir}" does not exist.`);
|
|
214
|
+
}
|
|
215
|
+
return { enabled, maxWidth, outputDir };
|
|
216
|
+
}
|
|
217
|
+
function buildImageFileName(workItemId, index, reference, resizing) {
|
|
218
|
+
const ext = resizing ? ".png" : reference.suggestedExtension;
|
|
219
|
+
return `wi-${workItemId}-${index}${ext}`;
|
|
220
|
+
}
|
|
221
|
+
async function processImageBytes(bytes, maxWidth) {
|
|
222
|
+
if (maxWidth === void 0) {
|
|
223
|
+
return { buffer: Buffer.from(bytes), resized: false, format: "original" };
|
|
224
|
+
}
|
|
225
|
+
const image = await Jimp.read(Buffer.from(bytes));
|
|
226
|
+
let resized = false;
|
|
227
|
+
if (image.bitmap.width > maxWidth) {
|
|
228
|
+
image.resize({ w: maxWidth });
|
|
229
|
+
resized = true;
|
|
230
|
+
}
|
|
231
|
+
const buffer = await image.getBuffer("image/png");
|
|
232
|
+
return { buffer, resized, format: "png" };
|
|
233
|
+
}
|
|
234
|
+
async function downloadImagesFromFields(fields, args, credential) {
|
|
235
|
+
const { workItemId, options } = args;
|
|
236
|
+
const resizing = options.maxWidth !== void 0;
|
|
237
|
+
const seen = /* @__PURE__ */ new Set();
|
|
238
|
+
const references = [];
|
|
239
|
+
for (const field of fields) {
|
|
240
|
+
for (const reference of extractImageReferences(field.content, field.field)) {
|
|
241
|
+
if (!seen.has(reference.guid)) {
|
|
242
|
+
seen.add(reference.guid);
|
|
243
|
+
references.push(reference);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const results = [];
|
|
248
|
+
let index = 0;
|
|
249
|
+
for (const reference of references) {
|
|
250
|
+
index += 1;
|
|
251
|
+
try {
|
|
252
|
+
const bytes = await downloadAttachment(reference.url, credential);
|
|
253
|
+
const processed = await processImageBytes(bytes, options.maxWidth);
|
|
254
|
+
const fileName = buildImageFileName(workItemId, index, reference, resizing);
|
|
255
|
+
const outputPath = join(options.outputDir, fileName);
|
|
256
|
+
await writeFile(outputPath, processed.buffer);
|
|
257
|
+
results.push({
|
|
258
|
+
reference,
|
|
259
|
+
path: outputPath,
|
|
260
|
+
resized: processed.resized,
|
|
261
|
+
format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
|
|
262
|
+
});
|
|
263
|
+
} catch (err) {
|
|
264
|
+
results.push({
|
|
265
|
+
reference,
|
|
266
|
+
resized: false,
|
|
267
|
+
format: reference.suggestedExtension.replace(/^\./, ""),
|
|
268
|
+
error: err instanceof Error ? err.message : String(err)
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return results;
|
|
273
|
+
}
|
|
274
|
+
async function runImageDownload(fields, args, credential) {
|
|
275
|
+
const results = await downloadImagesFromFields(fields, args, credential);
|
|
276
|
+
process.stdout.write(formatImageSummary(results) + "\n");
|
|
277
|
+
for (const result of results) {
|
|
278
|
+
if (result.error) {
|
|
279
|
+
process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
|
|
280
|
+
`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function formatImageSummary(results) {
|
|
285
|
+
if (results.length === 0) {
|
|
286
|
+
return "Images: no images found in rich-text fields";
|
|
287
|
+
}
|
|
288
|
+
const saved = results.filter((r) => r.path);
|
|
289
|
+
const lines = [`Images: ${saved.length} downloaded`];
|
|
290
|
+
for (const result of saved) {
|
|
291
|
+
lines.push(` ${result.path}`);
|
|
292
|
+
}
|
|
293
|
+
return lines.join("\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
126
296
|
// src/services/azdo-client.ts
|
|
127
297
|
var DEFAULT_FIELDS = [
|
|
128
298
|
"System.Title",
|
|
@@ -325,12 +495,25 @@ async function getWorkItemFields(context, id, cred) {
|
|
|
325
495
|
function extractAttachments(relations) {
|
|
326
496
|
if (!relations) return null;
|
|
327
497
|
const attachments = relations.filter((r) => r.rel === "AttachedFile").map((r) => ({
|
|
498
|
+
id: extractAttachmentGuid(r.url) ?? "",
|
|
328
499
|
name: r.attributes.name ?? "unknown",
|
|
329
500
|
size: r.attributes.resourceSize ?? 0,
|
|
330
501
|
url: r.url
|
|
331
502
|
}));
|
|
332
503
|
return attachments.length > 0 ? attachments : null;
|
|
333
504
|
}
|
|
505
|
+
async function findAttachmentRelations(context, id, cred, filename) {
|
|
506
|
+
const data = await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
|
|
507
|
+
const relations = data.relations ?? [];
|
|
508
|
+
return relations.map((r, index) => ({ r, index })).filter(({ r }) => r.rel === "AttachedFile" && r.attributes.name === filename).map(({ r, index }) => ({
|
|
509
|
+
index,
|
|
510
|
+
id: extractAttachmentGuid(r.url) ?? "",
|
|
511
|
+
name: r.attributes.name ?? filename,
|
|
512
|
+
size: r.attributes.resourceSize ?? 0,
|
|
513
|
+
uploadedDate: r.attributes.resourceCreatedDate ?? r.attributes.resourceModifiedDate,
|
|
514
|
+
url: r.url
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
334
517
|
function buildWorkItemUrl(context, id, options = {}) {
|
|
335
518
|
const url = new URL(
|
|
336
519
|
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
@@ -513,7 +696,7 @@ async function updateWorkItem(context, id, cred, fieldName, operations) {
|
|
|
513
696
|
const result = await applyWorkItemPatch(context, id, cred, operations);
|
|
514
697
|
const title = result.fields["System.Title"];
|
|
515
698
|
const lastOp = operations.at(-1);
|
|
516
|
-
const fieldValue = lastOp?.value
|
|
699
|
+
const fieldValue = typeof lastOp?.value === "string" ? lastOp.value : null;
|
|
517
700
|
return {
|
|
518
701
|
id: result.id,
|
|
519
702
|
rev: result.rev,
|
|
@@ -553,11 +736,36 @@ async function downloadAttachment(url, cred) {
|
|
|
553
736
|
}
|
|
554
737
|
return response.arrayBuffer();
|
|
555
738
|
}
|
|
739
|
+
async function createAttachment(context, fileName, content, cred) {
|
|
740
|
+
const url = new URL(
|
|
741
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/attachments`
|
|
742
|
+
);
|
|
743
|
+
url.searchParams.set("fileName", fileName);
|
|
744
|
+
url.searchParams.set("api-version", "7.1");
|
|
745
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
746
|
+
method: "POST",
|
|
747
|
+
headers: {
|
|
748
|
+
...authHeaders(cred),
|
|
749
|
+
"Content-Type": "application/octet-stream"
|
|
750
|
+
},
|
|
751
|
+
body: new Uint8Array(content)
|
|
752
|
+
});
|
|
753
|
+
if (response.status === 400) {
|
|
754
|
+
const serverMessage = await readResponseMessage(response);
|
|
755
|
+
if (serverMessage) {
|
|
756
|
+
throw new Error(`BAD_REQUEST: ${serverMessage}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (!response.ok) {
|
|
760
|
+
throw new Error(`HTTP_${response.status}`);
|
|
761
|
+
}
|
|
762
|
+
return await response.json();
|
|
763
|
+
}
|
|
556
764
|
|
|
557
765
|
// src/services/auth.ts
|
|
558
766
|
import { createInterface } from "readline";
|
|
559
|
-
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
560
|
-
import { dirname as dirname2, join } from "path";
|
|
767
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
768
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
561
769
|
|
|
562
770
|
// src/services/oauth-device-code.ts
|
|
563
771
|
var DeviceCodeFlowError = class extends Error {
|
|
@@ -728,8 +936,8 @@ async function promptForPat() {
|
|
|
728
936
|
function findDotEnvPat(startDir = process.cwd()) {
|
|
729
937
|
let current = startDir;
|
|
730
938
|
while (true) {
|
|
731
|
-
const envFile =
|
|
732
|
-
if (
|
|
939
|
+
const envFile = join2(current, ".env");
|
|
940
|
+
if (existsSync2(envFile)) {
|
|
733
941
|
const contents = readFileSync2(envFile, "utf8");
|
|
734
942
|
for (const line of contents.split("\n")) {
|
|
735
943
|
const match = line.match(/^AZDO_PAT\s*=([^\n\r]+)$/);
|
|
@@ -1339,6 +1547,24 @@ function toMarkdown(content) {
|
|
|
1339
1547
|
}
|
|
1340
1548
|
|
|
1341
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
|
+
}
|
|
1342
1568
|
function parseWorkItemId(idStr) {
|
|
1343
1569
|
const id = Number.parseInt(idStr, 10);
|
|
1344
1570
|
if (!Number.isInteger(id) || id <= 0) {
|
|
@@ -1423,173 +1649,6 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
|
|
|
1423
1649
|
}
|
|
1424
1650
|
}
|
|
1425
1651
|
|
|
1426
|
-
// src/services/image-download.ts
|
|
1427
|
-
import { Jimp } from "jimp";
|
|
1428
|
-
import { writeFile } from "fs/promises";
|
|
1429
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1430
|
-
import { tmpdir } from "os";
|
|
1431
|
-
import { join as join2 } from "path";
|
|
1432
|
-
var ATTACHMENT_GUID_RE = /_apis\/wit\/attachments\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/;
|
|
1433
|
-
function isAzureDevOpsAttachmentHost(hostname) {
|
|
1434
|
-
const host = hostname.toLowerCase();
|
|
1435
|
-
return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
|
|
1436
|
-
}
|
|
1437
|
-
function decodeHtmlEntities(value) {
|
|
1438
|
-
return value.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
1439
|
-
}
|
|
1440
|
-
function parseAttachmentReference(rawUrl, sourceField) {
|
|
1441
|
-
const url = decodeHtmlEntities(rawUrl.trim());
|
|
1442
|
-
let parsed;
|
|
1443
|
-
try {
|
|
1444
|
-
parsed = new URL(url);
|
|
1445
|
-
} catch {
|
|
1446
|
-
return null;
|
|
1447
|
-
}
|
|
1448
|
-
if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
|
|
1449
|
-
return null;
|
|
1450
|
-
}
|
|
1451
|
-
const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
|
|
1452
|
-
if (!match) return null;
|
|
1453
|
-
const guid = match[1].toLowerCase();
|
|
1454
|
-
let suggestedExtension = ".png";
|
|
1455
|
-
const fileName = parsed.searchParams.get("fileName");
|
|
1456
|
-
if (fileName?.includes(".")) {
|
|
1457
|
-
suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
|
|
1458
|
-
}
|
|
1459
|
-
return { url, sourceField, guid, suggestedExtension };
|
|
1460
|
-
}
|
|
1461
|
-
function extractImageReferences(content, sourceField) {
|
|
1462
|
-
if (!content) return [];
|
|
1463
|
-
const references = [];
|
|
1464
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1465
|
-
const add = (rawUrl) => {
|
|
1466
|
-
const reference = parseAttachmentReference(rawUrl, sourceField);
|
|
1467
|
-
if (reference && !seen.has(reference.guid)) {
|
|
1468
|
-
seen.add(reference.guid);
|
|
1469
|
-
references.push(reference);
|
|
1470
|
-
}
|
|
1471
|
-
};
|
|
1472
|
-
const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
|
|
1473
|
-
let match;
|
|
1474
|
-
while ((match = imgRegex.exec(content)) !== null) {
|
|
1475
|
-
add(match[1]);
|
|
1476
|
-
}
|
|
1477
|
-
const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
|
|
1478
|
-
while ((match = markdownRegex.exec(content)) !== null) {
|
|
1479
|
-
add(match[1]);
|
|
1480
|
-
}
|
|
1481
|
-
return references;
|
|
1482
|
-
}
|
|
1483
|
-
function addImageDownloadOptions(command) {
|
|
1484
|
-
return command.option("--download-images", "download images embedded in rich-text fields to local files").option("--resize-images <pixels>", "max image width in px; downloads and resizes embedded images to PNG (implies --download-images)").option("--images-path <dir>", "destination directory for downloaded images (default: system temp dir)");
|
|
1485
|
-
}
|
|
1486
|
-
function resolveImageDownloadOptionsOrExit(flags) {
|
|
1487
|
-
try {
|
|
1488
|
-
return resolveImageDownloadOptions(flags);
|
|
1489
|
-
} catch (err) {
|
|
1490
|
-
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1491
|
-
`);
|
|
1492
|
-
process.exit(1);
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
function resolveImageDownloadOptions(flags) {
|
|
1496
|
-
const wantsResize = flags.resizeImages !== void 0;
|
|
1497
|
-
const enabled = Boolean(flags.downloadImages) || wantsResize;
|
|
1498
|
-
let maxWidth;
|
|
1499
|
-
if (wantsResize) {
|
|
1500
|
-
const parsed = Number(flags.resizeImages);
|
|
1501
|
-
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1502
|
-
throw new Error(
|
|
1503
|
-
`Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
|
|
1504
|
-
);
|
|
1505
|
-
}
|
|
1506
|
-
maxWidth = parsed;
|
|
1507
|
-
}
|
|
1508
|
-
const outputDir = flags.imagesPath ?? tmpdir();
|
|
1509
|
-
if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
|
|
1510
|
-
throw new Error(`Images path "${outputDir}" does not exist.`);
|
|
1511
|
-
}
|
|
1512
|
-
return { enabled, maxWidth, outputDir };
|
|
1513
|
-
}
|
|
1514
|
-
function buildImageFileName(workItemId, index, reference, resizing) {
|
|
1515
|
-
const ext = resizing ? ".png" : reference.suggestedExtension;
|
|
1516
|
-
return `wi-${workItemId}-${index}${ext}`;
|
|
1517
|
-
}
|
|
1518
|
-
async function processImageBytes(bytes, maxWidth) {
|
|
1519
|
-
if (maxWidth === void 0) {
|
|
1520
|
-
return { buffer: Buffer.from(bytes), resized: false, format: "original" };
|
|
1521
|
-
}
|
|
1522
|
-
const image = await Jimp.read(Buffer.from(bytes));
|
|
1523
|
-
let resized = false;
|
|
1524
|
-
if (image.bitmap.width > maxWidth) {
|
|
1525
|
-
image.resize({ w: maxWidth });
|
|
1526
|
-
resized = true;
|
|
1527
|
-
}
|
|
1528
|
-
const buffer = await image.getBuffer("image/png");
|
|
1529
|
-
return { buffer, resized, format: "png" };
|
|
1530
|
-
}
|
|
1531
|
-
async function downloadImagesFromFields(fields, args, credential) {
|
|
1532
|
-
const { workItemId, options } = args;
|
|
1533
|
-
const resizing = options.maxWidth !== void 0;
|
|
1534
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1535
|
-
const references = [];
|
|
1536
|
-
for (const field of fields) {
|
|
1537
|
-
for (const reference of extractImageReferences(field.content, field.field)) {
|
|
1538
|
-
if (!seen.has(reference.guid)) {
|
|
1539
|
-
seen.add(reference.guid);
|
|
1540
|
-
references.push(reference);
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
const results = [];
|
|
1545
|
-
let index = 0;
|
|
1546
|
-
for (const reference of references) {
|
|
1547
|
-
index += 1;
|
|
1548
|
-
try {
|
|
1549
|
-
const bytes = await downloadAttachment(reference.url, credential);
|
|
1550
|
-
const processed = await processImageBytes(bytes, options.maxWidth);
|
|
1551
|
-
const fileName = buildImageFileName(workItemId, index, reference, resizing);
|
|
1552
|
-
const outputPath = join2(options.outputDir, fileName);
|
|
1553
|
-
await writeFile(outputPath, processed.buffer);
|
|
1554
|
-
results.push({
|
|
1555
|
-
reference,
|
|
1556
|
-
path: outputPath,
|
|
1557
|
-
resized: processed.resized,
|
|
1558
|
-
format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
|
|
1559
|
-
});
|
|
1560
|
-
} catch (err) {
|
|
1561
|
-
results.push({
|
|
1562
|
-
reference,
|
|
1563
|
-
resized: false,
|
|
1564
|
-
format: reference.suggestedExtension.replace(/^\./, ""),
|
|
1565
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1566
|
-
});
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
return results;
|
|
1570
|
-
}
|
|
1571
|
-
async function runImageDownload(fields, args, credential) {
|
|
1572
|
-
const results = await downloadImagesFromFields(fields, args, credential);
|
|
1573
|
-
process.stdout.write(formatImageSummary(results) + "\n");
|
|
1574
|
-
for (const result of results) {
|
|
1575
|
-
if (result.error) {
|
|
1576
|
-
process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
|
|
1577
|
-
`);
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
function formatImageSummary(results) {
|
|
1582
|
-
if (results.length === 0) {
|
|
1583
|
-
return "Images: no images found in rich-text fields";
|
|
1584
|
-
}
|
|
1585
|
-
const saved = results.filter((r) => r.path);
|
|
1586
|
-
const lines = [`Images: ${saved.length} downloaded`];
|
|
1587
|
-
for (const result of saved) {
|
|
1588
|
-
lines.push(` ${result.path}`);
|
|
1589
|
-
}
|
|
1590
|
-
return lines.join("\n");
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
1652
|
// src/commands/get-item.ts
|
|
1594
1653
|
function parseRequestedFields(raw) {
|
|
1595
1654
|
if (raw === void 0) return void 0;
|
|
@@ -1885,24 +1944,6 @@ async function readStdinToString() {
|
|
|
1885
1944
|
}
|
|
1886
1945
|
return Buffer.concat(chunks).toString("utf8");
|
|
1887
1946
|
}
|
|
1888
|
-
async function promptYesNo(prompt) {
|
|
1889
|
-
if (!process.stdin.isTTY) return true;
|
|
1890
|
-
process.stderr.write(prompt);
|
|
1891
|
-
return await new Promise((resolve2) => {
|
|
1892
|
-
process.stdin.setEncoding("utf8");
|
|
1893
|
-
let answered = false;
|
|
1894
|
-
const handler = (data) => {
|
|
1895
|
-
if (answered) return;
|
|
1896
|
-
answered = true;
|
|
1897
|
-
process.stdin.removeListener("data", handler);
|
|
1898
|
-
process.stdin.pause();
|
|
1899
|
-
const trimmed = data.trim().toLowerCase();
|
|
1900
|
-
resolve2(trimmed === "y" || trimmed === "yes");
|
|
1901
|
-
};
|
|
1902
|
-
process.stdin.resume();
|
|
1903
|
-
process.stdin.on("data", handler);
|
|
1904
|
-
});
|
|
1905
|
-
}
|
|
1906
1947
|
async function confirmOverwrite(org) {
|
|
1907
1948
|
return promptYesNo(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
|
|
1908
1949
|
}
|
|
@@ -6085,8 +6126,142 @@ function createDownloadAttachmentCommand() {
|
|
|
6085
6126
|
return command;
|
|
6086
6127
|
}
|
|
6087
6128
|
|
|
6088
|
-
// src/commands/
|
|
6129
|
+
// src/commands/add-attachment.ts
|
|
6089
6130
|
import { Command as Command16 } from "commander";
|
|
6131
|
+
import { existsSync as existsSync7, statSync } from "fs";
|
|
6132
|
+
import { readFile } from "fs/promises";
|
|
6133
|
+
import { basename } from "path";
|
|
6134
|
+
function createAddAttachmentCommand() {
|
|
6135
|
+
const command = new Command16("add-attachment");
|
|
6136
|
+
command.description("Attach a local file to an Azure DevOps work item").argument("<id>", "work item ID").argument("<file>", "path to the local file to upload").option("--comment <text>", "optional comment to store with the attachment").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
|
|
6137
|
+
async (idStr, file, options) => {
|
|
6138
|
+
const id = parseWorkItemId(idStr);
|
|
6139
|
+
validateOrgProjectPair(options);
|
|
6140
|
+
if (!existsSync7(file)) {
|
|
6141
|
+
process.stderr.write(`Error: File not found: ${file}
|
|
6142
|
+
`);
|
|
6143
|
+
process.exit(1);
|
|
6144
|
+
}
|
|
6145
|
+
if (!statSync(file).isFile()) {
|
|
6146
|
+
process.stderr.write(`Error: "${file}" is not a regular file.
|
|
6147
|
+
`);
|
|
6148
|
+
process.exit(1);
|
|
6149
|
+
}
|
|
6150
|
+
let context;
|
|
6151
|
+
try {
|
|
6152
|
+
const filename = basename(file);
|
|
6153
|
+
const content = await readFile(file);
|
|
6154
|
+
context = resolveContext(options);
|
|
6155
|
+
const credential = await requireAuthCredential(context.org);
|
|
6156
|
+
await getWorkItem(context, id, credential);
|
|
6157
|
+
const attachment = await createAttachment(context, filename, content, credential);
|
|
6158
|
+
await applyWorkItemPatch(context, id, credential, [
|
|
6159
|
+
{
|
|
6160
|
+
op: "add",
|
|
6161
|
+
path: "/relations/-",
|
|
6162
|
+
value: {
|
|
6163
|
+
rel: "AttachedFile",
|
|
6164
|
+
url: attachment.url,
|
|
6165
|
+
...options.comment ? { attributes: { comment: options.comment } } : {}
|
|
6166
|
+
}
|
|
6167
|
+
}
|
|
6168
|
+
]);
|
|
6169
|
+
process.stdout.write(
|
|
6170
|
+
`Attached "${filename}" (${formatFileSize(content.length)}) to work item ${id} [id: ${attachment.id}]
|
|
6171
|
+
`
|
|
6172
|
+
);
|
|
6173
|
+
} catch (err) {
|
|
6174
|
+
handleCommandError(err, id, context, "write");
|
|
6175
|
+
}
|
|
6176
|
+
}
|
|
6177
|
+
);
|
|
6178
|
+
return command;
|
|
6179
|
+
}
|
|
6180
|
+
|
|
6181
|
+
// src/commands/delete-attachment.ts
|
|
6182
|
+
import { Command as Command17 } from "commander";
|
|
6183
|
+
function formatUploadDate(iso) {
|
|
6184
|
+
return iso ? iso.slice(0, 10) : "unknown date";
|
|
6185
|
+
}
|
|
6186
|
+
function createDeleteAttachmentCommand() {
|
|
6187
|
+
const command = new Command17("delete-attachment");
|
|
6188
|
+
command.description("Remove an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to remove").option("--id <attachmentId>", "attachment GUID, to disambiguate when the filename is shared by more than one attachment").option("-y, --yes", "skip the interactive confirmation prompt").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
|
|
6189
|
+
async (idStr, filename, options) => {
|
|
6190
|
+
const id = parseWorkItemId(idStr);
|
|
6191
|
+
validateOrgProjectPair(options);
|
|
6192
|
+
let context;
|
|
6193
|
+
try {
|
|
6194
|
+
context = resolveContext(options);
|
|
6195
|
+
const credential = await requireAuthCredential(context.org);
|
|
6196
|
+
const matches = await findAttachmentRelations(context, id, credential, filename);
|
|
6197
|
+
if (matches.length === 0) {
|
|
6198
|
+
process.stderr.write(`Error: Attachment "${filename}" not found on work item ${id}.
|
|
6199
|
+
`);
|
|
6200
|
+
process.exitCode = 1;
|
|
6201
|
+
return;
|
|
6202
|
+
}
|
|
6203
|
+
let target;
|
|
6204
|
+
if (options.id) {
|
|
6205
|
+
const wantedId = options.id.toLowerCase();
|
|
6206
|
+
const narrowed = matches.find((match) => match.id === wantedId);
|
|
6207
|
+
if (!narrowed) {
|
|
6208
|
+
process.stderr.write(
|
|
6209
|
+
`Error: No attachment named "${filename}" with id ${options.id} found on work item ${id}.
|
|
6210
|
+
`
|
|
6211
|
+
);
|
|
6212
|
+
process.exitCode = 1;
|
|
6213
|
+
return;
|
|
6214
|
+
}
|
|
6215
|
+
target = narrowed;
|
|
6216
|
+
} else if (matches.length > 1) {
|
|
6217
|
+
process.stderr.write(
|
|
6218
|
+
`Error: multiple attachments named "${filename}" on work item ${id}:
|
|
6219
|
+
`
|
|
6220
|
+
);
|
|
6221
|
+
for (const match of matches) {
|
|
6222
|
+
process.stderr.write(
|
|
6223
|
+
` ${match.id} ${formatFileSize(match.size)} ${formatUploadDate(match.uploadedDate)}
|
|
6224
|
+
`
|
|
6225
|
+
);
|
|
6226
|
+
}
|
|
6227
|
+
process.stderr.write("Re-run with --id <guid> to remove a specific one.\n");
|
|
6228
|
+
process.exitCode = 1;
|
|
6229
|
+
return;
|
|
6230
|
+
} else {
|
|
6231
|
+
target = matches[0];
|
|
6232
|
+
}
|
|
6233
|
+
let confirmed = options.yes === true;
|
|
6234
|
+
if (!confirmed) {
|
|
6235
|
+
if (!process.stdin.isTTY) {
|
|
6236
|
+
process.stderr.write(
|
|
6237
|
+
"Error: confirmation required. Re-run with --yes to skip the prompt in a non-interactive shell.\n"
|
|
6238
|
+
);
|
|
6239
|
+
process.exitCode = 1;
|
|
6240
|
+
return;
|
|
6241
|
+
}
|
|
6242
|
+
confirmed = await promptYesNo(`Remove "${filename}" from work item ${id}? [y/N] `);
|
|
6243
|
+
}
|
|
6244
|
+
if (!confirmed) {
|
|
6245
|
+
process.stderr.write("Aborted: attachment not removed.\n");
|
|
6246
|
+
process.exitCode = 1;
|
|
6247
|
+
return;
|
|
6248
|
+
}
|
|
6249
|
+
await applyWorkItemPatch(context, id, credential, [
|
|
6250
|
+
{ op: "test", path: `/relations/${target.index}/url`, value: target.url },
|
|
6251
|
+
{ op: "remove", path: `/relations/${target.index}` }
|
|
6252
|
+
]);
|
|
6253
|
+
process.stdout.write(`Removed "${filename}" (id: ${target.id}) from work item ${id}
|
|
6254
|
+
`);
|
|
6255
|
+
} catch (err) {
|
|
6256
|
+
handleCommandError(err, id, context, "write");
|
|
6257
|
+
}
|
|
6258
|
+
}
|
|
6259
|
+
);
|
|
6260
|
+
return command;
|
|
6261
|
+
}
|
|
6262
|
+
|
|
6263
|
+
// src/commands/relations.ts
|
|
6264
|
+
import { Command as Command18 } from "commander";
|
|
6090
6265
|
|
|
6091
6266
|
// src/services/relations-client.ts
|
|
6092
6267
|
var API_VERSION2 = "7.1";
|
|
@@ -6301,7 +6476,7 @@ function parsePositiveInt(value, label) {
|
|
|
6301
6476
|
return n;
|
|
6302
6477
|
}
|
|
6303
6478
|
function createRelationsCommand() {
|
|
6304
|
-
const relations = new
|
|
6479
|
+
const relations = new Command18("relations").description("Manage work item relations");
|
|
6305
6480
|
addCommonOptions(
|
|
6306
6481
|
relations.command("types").description("List all available work item relation types")
|
|
6307
6482
|
).action(async (opts) => {
|
|
@@ -6491,7 +6666,7 @@ function exitOnEpipe(err) {
|
|
|
6491
6666
|
}
|
|
6492
6667
|
process.stdout.on("error", exitOnEpipe);
|
|
6493
6668
|
process.stderr.on("error", exitOnEpipe);
|
|
6494
|
-
var program = new
|
|
6669
|
+
var program = new Command19();
|
|
6495
6670
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
6496
6671
|
program.option("--no-update-check", "Skip the check for a newer published version");
|
|
6497
6672
|
program.option("--trace <filepath>", "Append redacted HTTP request/response trace to a file (owner-read-only permissions)");
|
|
@@ -6510,6 +6685,8 @@ program.addCommand(createPrCommand());
|
|
|
6510
6685
|
program.addCommand(createPipelineCommand());
|
|
6511
6686
|
program.addCommand(createCommentsCommand());
|
|
6512
6687
|
program.addCommand(createDownloadAttachmentCommand());
|
|
6688
|
+
program.addCommand(createAddAttachmentCommand());
|
|
6689
|
+
program.addCommand(createDeleteAttachmentCommand());
|
|
6513
6690
|
program.addCommand(createRelationsCommand());
|
|
6514
6691
|
program.showHelpAfterError();
|
|
6515
6692
|
program.hook("preAction", () => {
|