azdo-cli 0.10.0-develop.348 → 0.10.0-develop.373
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 +6 -0
- package/dist/index.js +201 -12
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Azure DevOps CLI focused on work item read/write workflows.
|
|
|
13
13
|
- Create or update work items from markdown documents (`upsert`)
|
|
14
14
|
- Read and post work item comments (`comments`)
|
|
15
15
|
- Read/write rich-text fields as markdown (`get-md-field`, `set-md-field`)
|
|
16
|
+
- Download images embedded in rich-text fields, optionally resized for LLM use (`get-item`/`get-md-field` `--download-images`, `--resize-images`)
|
|
16
17
|
- Check branch pull request status, open PRs to `develop`, list PR comment threads for any PR (`--pr-number`), and resolve/reopen threads from the CLI (`pr`)
|
|
17
18
|
- Persist org/project/default fields in local config (`config`)
|
|
18
19
|
- List all fields of a work item (`list-fields`)
|
|
@@ -37,6 +38,11 @@ azdo config set project myproject
|
|
|
37
38
|
# Read a work item
|
|
38
39
|
azdo get-item 12345
|
|
39
40
|
|
|
41
|
+
# Download images embedded in a work item's rich-text fields (opt-in)
|
|
42
|
+
azdo get-item 12345 --download-images # saved to the system temp dir
|
|
43
|
+
azdo get-item 12345 --resize-images 1024 --images-path ./img # cap width at 1024px, save as PNG
|
|
44
|
+
azdo get-md-field 12345 System.Description --download-images # same flags on get-md-field
|
|
45
|
+
|
|
40
46
|
# Update state
|
|
41
47
|
azdo set-state 12345 "Active"
|
|
42
48
|
|
package/dist/index.js
CHANGED
|
@@ -1053,6 +1053,173 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
|
|
|
1053
1053
|
}
|
|
1054
1054
|
}
|
|
1055
1055
|
|
|
1056
|
+
// src/services/image-download.ts
|
|
1057
|
+
import { Jimp } from "jimp";
|
|
1058
|
+
import { writeFile } from "fs/promises";
|
|
1059
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1060
|
+
import { tmpdir } from "os";
|
|
1061
|
+
import { join as join2 } from "path";
|
|
1062
|
+
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})/;
|
|
1063
|
+
function isAzureDevOpsAttachmentHost(hostname) {
|
|
1064
|
+
const host = hostname.toLowerCase();
|
|
1065
|
+
return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
|
|
1066
|
+
}
|
|
1067
|
+
function decodeHtmlEntities(value) {
|
|
1068
|
+
return value.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
1069
|
+
}
|
|
1070
|
+
function parseAttachmentReference(rawUrl, sourceField) {
|
|
1071
|
+
const url = decodeHtmlEntities(rawUrl.trim());
|
|
1072
|
+
let parsed;
|
|
1073
|
+
try {
|
|
1074
|
+
parsed = new URL(url);
|
|
1075
|
+
} catch {
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
|
|
1082
|
+
if (!match) return null;
|
|
1083
|
+
const guid = match[1].toLowerCase();
|
|
1084
|
+
let suggestedExtension = ".png";
|
|
1085
|
+
const fileName = parsed.searchParams.get("fileName");
|
|
1086
|
+
if (fileName?.includes(".")) {
|
|
1087
|
+
suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
|
|
1088
|
+
}
|
|
1089
|
+
return { url, sourceField, guid, suggestedExtension };
|
|
1090
|
+
}
|
|
1091
|
+
function extractImageReferences(content, sourceField) {
|
|
1092
|
+
if (!content) return [];
|
|
1093
|
+
const references = [];
|
|
1094
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1095
|
+
const add = (rawUrl) => {
|
|
1096
|
+
const reference = parseAttachmentReference(rawUrl, sourceField);
|
|
1097
|
+
if (reference && !seen.has(reference.guid)) {
|
|
1098
|
+
seen.add(reference.guid);
|
|
1099
|
+
references.push(reference);
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
|
|
1103
|
+
let match;
|
|
1104
|
+
while ((match = imgRegex.exec(content)) !== null) {
|
|
1105
|
+
add(match[1]);
|
|
1106
|
+
}
|
|
1107
|
+
const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
|
|
1108
|
+
while ((match = markdownRegex.exec(content)) !== null) {
|
|
1109
|
+
add(match[1]);
|
|
1110
|
+
}
|
|
1111
|
+
return references;
|
|
1112
|
+
}
|
|
1113
|
+
function addImageDownloadOptions(command) {
|
|
1114
|
+
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)");
|
|
1115
|
+
}
|
|
1116
|
+
function resolveImageDownloadOptionsOrExit(flags) {
|
|
1117
|
+
try {
|
|
1118
|
+
return resolveImageDownloadOptions(flags);
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1121
|
+
`);
|
|
1122
|
+
process.exit(1);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
function resolveImageDownloadOptions(flags) {
|
|
1126
|
+
const wantsResize = flags.resizeImages !== void 0;
|
|
1127
|
+
const enabled = Boolean(flags.downloadImages) || wantsResize;
|
|
1128
|
+
let maxWidth;
|
|
1129
|
+
if (wantsResize) {
|
|
1130
|
+
const parsed = Number(flags.resizeImages);
|
|
1131
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1132
|
+
throw new Error(
|
|
1133
|
+
`Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
maxWidth = parsed;
|
|
1137
|
+
}
|
|
1138
|
+
const outputDir = flags.imagesPath ?? tmpdir();
|
|
1139
|
+
if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
|
|
1140
|
+
throw new Error(`Images path "${outputDir}" does not exist.`);
|
|
1141
|
+
}
|
|
1142
|
+
return { enabled, maxWidth, outputDir };
|
|
1143
|
+
}
|
|
1144
|
+
function buildImageFileName(workItemId, index, reference, resizing) {
|
|
1145
|
+
const ext = resizing ? ".png" : reference.suggestedExtension;
|
|
1146
|
+
return `wi-${workItemId}-${index}${ext}`;
|
|
1147
|
+
}
|
|
1148
|
+
async function processImageBytes(bytes, maxWidth) {
|
|
1149
|
+
if (maxWidth === void 0) {
|
|
1150
|
+
return { buffer: Buffer.from(bytes), resized: false, format: "original" };
|
|
1151
|
+
}
|
|
1152
|
+
const image = await Jimp.read(Buffer.from(bytes));
|
|
1153
|
+
let resized = false;
|
|
1154
|
+
if (image.bitmap.width > maxWidth) {
|
|
1155
|
+
image.resize({ w: maxWidth });
|
|
1156
|
+
resized = true;
|
|
1157
|
+
}
|
|
1158
|
+
const buffer = await image.getBuffer("image/png");
|
|
1159
|
+
return { buffer, resized, format: "png" };
|
|
1160
|
+
}
|
|
1161
|
+
async function downloadImagesFromFields(fields, args, credential) {
|
|
1162
|
+
const { workItemId, options } = args;
|
|
1163
|
+
const resizing = options.maxWidth !== void 0;
|
|
1164
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1165
|
+
const references = [];
|
|
1166
|
+
for (const field of fields) {
|
|
1167
|
+
for (const reference of extractImageReferences(field.content, field.field)) {
|
|
1168
|
+
if (!seen.has(reference.guid)) {
|
|
1169
|
+
seen.add(reference.guid);
|
|
1170
|
+
references.push(reference);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const results = [];
|
|
1175
|
+
let index = 0;
|
|
1176
|
+
for (const reference of references) {
|
|
1177
|
+
index += 1;
|
|
1178
|
+
try {
|
|
1179
|
+
const bytes = await downloadAttachment(reference.url, credential);
|
|
1180
|
+
const processed = await processImageBytes(bytes, options.maxWidth);
|
|
1181
|
+
const fileName = buildImageFileName(workItemId, index, reference, resizing);
|
|
1182
|
+
const outputPath = join2(options.outputDir, fileName);
|
|
1183
|
+
await writeFile(outputPath, processed.buffer);
|
|
1184
|
+
results.push({
|
|
1185
|
+
reference,
|
|
1186
|
+
path: outputPath,
|
|
1187
|
+
resized: processed.resized,
|
|
1188
|
+
format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
|
|
1189
|
+
});
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
results.push({
|
|
1192
|
+
reference,
|
|
1193
|
+
resized: false,
|
|
1194
|
+
format: reference.suggestedExtension.replace(/^\./, ""),
|
|
1195
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return results;
|
|
1200
|
+
}
|
|
1201
|
+
async function runImageDownload(fields, args, credential) {
|
|
1202
|
+
const results = await downloadImagesFromFields(fields, args, credential);
|
|
1203
|
+
process.stdout.write(formatImageSummary(results) + "\n");
|
|
1204
|
+
for (const result of results) {
|
|
1205
|
+
if (result.error) {
|
|
1206
|
+
process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
|
|
1207
|
+
`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
function formatImageSummary(results) {
|
|
1212
|
+
if (results.length === 0) {
|
|
1213
|
+
return "Images: no images found in rich-text fields";
|
|
1214
|
+
}
|
|
1215
|
+
const saved = results.filter((r) => r.path);
|
|
1216
|
+
const lines = [`Images: ${saved.length} downloaded`];
|
|
1217
|
+
for (const result of saved) {
|
|
1218
|
+
lines.push(` ${result.path}`);
|
|
1219
|
+
}
|
|
1220
|
+
return lines.join("\n");
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1056
1223
|
// src/commands/get-item.ts
|
|
1057
1224
|
function parseRequestedFields(raw) {
|
|
1058
1225
|
if (raw === void 0) return void 0;
|
|
@@ -1176,10 +1343,13 @@ function formatWorkItem(workItem, short, markdown = false) {
|
|
|
1176
1343
|
}
|
|
1177
1344
|
function createGetItemCommand() {
|
|
1178
1345
|
const command = new Command("get-item");
|
|
1179
|
-
command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown")
|
|
1346
|
+
command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown");
|
|
1347
|
+
addImageDownloadOptions(command);
|
|
1348
|
+
command.action(
|
|
1180
1349
|
async (idStr, options) => {
|
|
1181
1350
|
const id = parseWorkItemId(idStr);
|
|
1182
1351
|
validateOrgProjectPair(options);
|
|
1352
|
+
const imageOptions = resolveImageDownloadOptionsOrExit(options);
|
|
1183
1353
|
let context;
|
|
1184
1354
|
try {
|
|
1185
1355
|
context = resolveContext(options);
|
|
@@ -1189,6 +1359,15 @@ function createGetItemCommand() {
|
|
|
1189
1359
|
const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
|
|
1190
1360
|
const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
|
|
1191
1361
|
process.stdout.write(output + "\n");
|
|
1362
|
+
if (imageOptions.enabled) {
|
|
1363
|
+
const fields = [{ content: workItem.description ?? "", field: "Description" }];
|
|
1364
|
+
if (workItem.extraFields) {
|
|
1365
|
+
for (const [name, value] of Object.entries(workItem.extraFields)) {
|
|
1366
|
+
fields.push({ content: value, field: name });
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
await runImageDownload(fields, { workItemId: id, options: imageOptions }, credential);
|
|
1370
|
+
}
|
|
1192
1371
|
} catch (err) {
|
|
1193
1372
|
handleCommandError(err, id, context, "read", false);
|
|
1194
1373
|
}
|
|
@@ -1943,10 +2122,13 @@ function createSetFieldCommand() {
|
|
|
1943
2122
|
import { Command as Command8 } from "commander";
|
|
1944
2123
|
function createGetMdFieldCommand() {
|
|
1945
2124
|
const command = new Command8("get-md-field");
|
|
1946
|
-
command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project")
|
|
2125
|
+
command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
|
|
2126
|
+
addImageDownloadOptions(command);
|
|
2127
|
+
command.action(
|
|
1947
2128
|
async (idStr, field, options) => {
|
|
1948
2129
|
const id = parseWorkItemId(idStr);
|
|
1949
2130
|
validateOrgProjectPair(options);
|
|
2131
|
+
const imageOptions = resolveImageDownloadOptionsOrExit(options);
|
|
1950
2132
|
let context;
|
|
1951
2133
|
try {
|
|
1952
2134
|
context = resolveContext(options);
|
|
@@ -1957,6 +2139,13 @@ function createGetMdFieldCommand() {
|
|
|
1957
2139
|
} else {
|
|
1958
2140
|
process.stdout.write(toMarkdown(value) + "\n");
|
|
1959
2141
|
}
|
|
2142
|
+
if (imageOptions.enabled) {
|
|
2143
|
+
await runImageDownload(
|
|
2144
|
+
[{ content: value ?? "", field }],
|
|
2145
|
+
{ workItemId: id, options: imageOptions },
|
|
2146
|
+
credential
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
1960
2149
|
} catch (err) {
|
|
1961
2150
|
handleCommandError(err, id, context, "read");
|
|
1962
2151
|
}
|
|
@@ -1966,7 +2155,7 @@ function createGetMdFieldCommand() {
|
|
|
1966
2155
|
}
|
|
1967
2156
|
|
|
1968
2157
|
// src/commands/set-md-field.ts
|
|
1969
|
-
import { existsSync as
|
|
2158
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
1970
2159
|
import { Command as Command9 } from "commander";
|
|
1971
2160
|
function fail(message) {
|
|
1972
2161
|
process.stderr.write(`Error: ${message}
|
|
@@ -1986,7 +2175,7 @@ function resolveContent(inlineContent, options) {
|
|
|
1986
2175
|
return null;
|
|
1987
2176
|
}
|
|
1988
2177
|
function readFileContent(filePath) {
|
|
1989
|
-
if (!
|
|
2178
|
+
if (!existsSync3(filePath)) {
|
|
1990
2179
|
fail(`File not found: ${filePath}`);
|
|
1991
2180
|
}
|
|
1992
2181
|
try {
|
|
@@ -2054,7 +2243,7 @@ function createSetMdFieldCommand() {
|
|
|
2054
2243
|
}
|
|
2055
2244
|
|
|
2056
2245
|
// src/commands/upsert.ts
|
|
2057
|
-
import { existsSync as
|
|
2246
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
|
|
2058
2247
|
import { Command as Command10 } from "commander";
|
|
2059
2248
|
|
|
2060
2249
|
// src/services/task-document.ts
|
|
@@ -2218,7 +2407,7 @@ function loadSourceContent(options) {
|
|
|
2218
2407
|
return { content: options.content };
|
|
2219
2408
|
}
|
|
2220
2409
|
const filePath = options.file;
|
|
2221
|
-
if (!
|
|
2410
|
+
if (!existsSync4(filePath)) {
|
|
2222
2411
|
fail2(`File not found: ${filePath}`);
|
|
2223
2412
|
}
|
|
2224
2413
|
try {
|
|
@@ -3117,9 +3306,9 @@ function createCommentsCommand() {
|
|
|
3117
3306
|
|
|
3118
3307
|
// src/commands/download-attachment.ts
|
|
3119
3308
|
import { Command as Command14 } from "commander";
|
|
3120
|
-
import { writeFile } from "fs/promises";
|
|
3121
|
-
import { existsSync as
|
|
3122
|
-
import { join as
|
|
3309
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
3310
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3311
|
+
import { join as join3 } from "path";
|
|
3123
3312
|
function createDownloadAttachmentCommand() {
|
|
3124
3313
|
const command = new Command14("download-attachment");
|
|
3125
3314
|
command.description("Download an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to download").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--output <dir>", "target directory for the downloaded file").action(
|
|
@@ -3131,7 +3320,7 @@ function createDownloadAttachmentCommand() {
|
|
|
3131
3320
|
context = resolveContext(options);
|
|
3132
3321
|
const credential = await requireAuthCredential(context.org);
|
|
3133
3322
|
const outputDir = options.output ?? ".";
|
|
3134
|
-
if (!
|
|
3323
|
+
if (!existsSync5(outputDir)) {
|
|
3135
3324
|
process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
|
|
3136
3325
|
`);
|
|
3137
3326
|
process.exit(1);
|
|
@@ -3148,8 +3337,8 @@ function createDownloadAttachmentCommand() {
|
|
|
3148
3337
|
process.exit(1);
|
|
3149
3338
|
}
|
|
3150
3339
|
const data = await downloadAttachment(attachment.url, credential);
|
|
3151
|
-
const outputPath =
|
|
3152
|
-
await
|
|
3340
|
+
const outputPath = join3(outputDir, filename);
|
|
3341
|
+
await writeFile2(outputPath, Buffer.from(data));
|
|
3153
3342
|
process.stdout.write(
|
|
3154
3343
|
`Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
|
|
3155
3344
|
`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "azdo-cli",
|
|
3
|
-
"version": "0.10.0-develop.
|
|
3
|
+
"version": "0.10.0-develop.373",
|
|
4
4
|
"description": "Azure DevOps CLI tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@napi-rs/keyring": "^1.2.0",
|
|
29
29
|
"commander": "^14.0.3",
|
|
30
|
+
"jimp": "^1.6.1",
|
|
30
31
|
"node-html-markdown": "^2.0.0"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|