azdo-cli 0.8.0-develop.176 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +128 -11
  2. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command13 } from "commander";
4
+ import { Command as Command14 } from "commander";
5
5
 
6
6
  // src/version.ts
7
7
  import { readFileSync } from "fs";
@@ -166,17 +166,33 @@ async function getWorkItemFields(context, id, pat) {
166
166
  const data = await response.json();
167
167
  return data.fields;
168
168
  }
169
- async function getWorkItem(context, id, pat, extraFields) {
169
+ function extractAttachments(relations) {
170
+ if (!relations) return null;
171
+ const attachments = relations.filter((r) => r.rel === "AttachedFile").map((r) => ({
172
+ name: r.attributes.name ?? "unknown",
173
+ size: r.attributes.resourceSize ?? 0,
174
+ url: r.url
175
+ }));
176
+ return attachments.length > 0 ? attachments : null;
177
+ }
178
+ function buildWorkItemUrl(context, id, options = {}) {
170
179
  const url = new URL(
171
180
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
172
181
  );
173
182
  url.searchParams.set("api-version", "7.1");
174
- const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
175
- if (normalizedExtraFields.length > 0) {
176
- const allFields = normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields]);
177
- url.searchParams.set("fields", allFields.join(","));
183
+ if (options.includeRelations) {
184
+ url.searchParams.set("$expand", "relations");
178
185
  }
179
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
186
+ if (options.fields && options.fields.length > 0) {
187
+ url.searchParams.set("fields", options.fields.join(","));
188
+ }
189
+ return url;
190
+ }
191
+ async function fetchWorkItemResponse(context, id, pat, options = {}) {
192
+ const response = await fetchWithErrors(
193
+ buildWorkItemUrl(context, id, options).toString(),
194
+ { headers: authHeaders(pat) }
195
+ );
180
196
  if (response.status === 400) {
181
197
  const serverMessage = await readResponseMessage(response);
182
198
  if (serverMessage) {
@@ -186,7 +202,14 @@ async function getWorkItem(context, id, pat, extraFields) {
186
202
  if (!response.ok) {
187
203
  throw new Error(`HTTP_${response.status}`);
188
204
  }
189
- const data = await response.json();
205
+ return await response.json();
206
+ }
207
+ async function getWorkItem(context, id, pat, extraFields) {
208
+ const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
209
+ const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, {
210
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
211
+ }) : await fetchWorkItemResponse(context, id, pat, { includeRelations: true });
212
+ const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, { includeRelations: true }) : data;
190
213
  const descriptionParts = [];
191
214
  if (data.fields["System.Description"]) {
192
215
  descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
@@ -214,7 +237,8 @@ async function getWorkItem(context, id, pat, extraFields) {
214
237
  areaPath: data.fields["System.AreaPath"],
215
238
  iterationPath: data.fields["System.IterationPath"],
216
239
  url: data._links.html.href,
217
- extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null
240
+ extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null,
241
+ attachments: extractAttachments(relationsData.relations)
218
242
  };
219
243
  }
220
244
  async function getWorkItemFieldValue(context, id, pat, fieldName) {
@@ -328,6 +352,13 @@ async function applyWorkItemPatch(context, id, pat, operations) {
328
352
  });
329
353
  return readWriteResponse(response, "UPDATE_REJECTED");
330
354
  }
355
+ async function downloadAttachment(url, pat) {
356
+ const response = await fetchWithErrors(url, { headers: authHeaders(pat) });
357
+ if (!response.ok) {
358
+ throw new Error(`HTTP_${response.status}`);
359
+ }
360
+ return response.arrayBuffer();
361
+ }
331
362
 
332
363
  // src/services/auth.ts
333
364
  import { createInterface } from "readline";
@@ -816,7 +847,7 @@ function stripHtml(html) {
816
847
  text = text.replaceAll(/<br\s*\/?>/gi, "\n");
817
848
  text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
818
849
  text = text.replaceAll(/<li>/gi, "\n");
819
- text = text.replaceAll(/<[^>]*>/g, "");
850
+ text = removeHtmlTags(text);
820
851
  text = text.replaceAll("&amp;", "&");
821
852
  text = text.replaceAll("&lt;", "<");
822
853
  text = text.replaceAll("&gt;", ">");
@@ -826,6 +857,24 @@ function stripHtml(html) {
826
857
  text = text.replaceAll(/\n{3,}/g, "\n\n");
827
858
  return text.trim();
828
859
  }
860
+ function removeHtmlTags(value) {
861
+ let result = "";
862
+ let insideTag = false;
863
+ for (const char of value) {
864
+ if (char === "<") {
865
+ insideTag = true;
866
+ continue;
867
+ }
868
+ if (char === ">" && insideTag) {
869
+ insideTag = false;
870
+ continue;
871
+ }
872
+ if (!insideTag) {
873
+ result += char;
874
+ }
875
+ }
876
+ return result;
877
+ }
829
878
  function convertRichText(html, markdown) {
830
879
  if (!html) return "";
831
880
  return markdown ? toMarkdown(html) : stripHtml(html);
@@ -857,6 +906,23 @@ function summarizeDescription(text, label, markdown) {
857
906
  }
858
907
  return [`${label("Description:")}${content}`];
859
908
  }
909
+ function formatFileSize(bytes) {
910
+ if (bytes < 1024) return `${bytes} B`;
911
+ const kb = bytes / 1024;
912
+ if (kb < 1024) return `${kb.toFixed(1)} KB`;
913
+ const mb = kb / 1024;
914
+ return `${mb.toFixed(1)} MB`;
915
+ }
916
+ function formatAttachments(attachments, short) {
917
+ if (short) {
918
+ return [`Attachments: ${attachments.length}`];
919
+ }
920
+ const lines = ["", "Attachments:"];
921
+ for (const att of attachments) {
922
+ lines.push(` ${att.name} (${formatFileSize(att.size)})`);
923
+ }
924
+ return lines;
925
+ }
860
926
  function formatWorkItem(workItem, short, markdown = false) {
861
927
  const label = (name) => name.padEnd(13);
862
928
  const lines = [
@@ -883,6 +949,9 @@ function formatWorkItem(workItem, short, markdown = false) {
883
949
  } else {
884
950
  lines.push("Description:", descriptionText);
885
951
  }
952
+ if (workItem.attachments) {
953
+ lines.push(...formatAttachments(workItem.attachments, short));
954
+ }
886
955
  return lines.join("\n");
887
956
  }
888
957
  function createGetItemCommand() {
@@ -2171,8 +2240,55 @@ function createCommentsCommand() {
2171
2240
  return command;
2172
2241
  }
2173
2242
 
2243
+ // src/commands/download-attachment.ts
2244
+ import { Command as Command13 } from "commander";
2245
+ import { writeFile } from "fs/promises";
2246
+ import { existsSync as existsSync4 } from "fs";
2247
+ import { join as join2 } from "path";
2248
+ function createDownloadAttachmentCommand() {
2249
+ const command = new Command13("download-attachment");
2250
+ 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(
2251
+ async (idStr, filename, options) => {
2252
+ const id = parseWorkItemId(idStr);
2253
+ validateOrgProjectPair(options);
2254
+ let context;
2255
+ try {
2256
+ context = resolveContext(options);
2257
+ const credential = await resolvePat();
2258
+ const outputDir = options.output ?? ".";
2259
+ if (!existsSync4(outputDir)) {
2260
+ process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
2261
+ `);
2262
+ process.exit(1);
2263
+ }
2264
+ const workItem = await getWorkItem(context, id, credential.pat);
2265
+ const attachment = workItem.attachments?.find(
2266
+ (a) => a.name === filename
2267
+ );
2268
+ if (!attachment) {
2269
+ process.stderr.write(
2270
+ `Error: Attachment "${filename}" not found on work item ${id}.
2271
+ `
2272
+ );
2273
+ process.exit(1);
2274
+ }
2275
+ const data = await downloadAttachment(attachment.url, credential.pat);
2276
+ const outputPath = join2(outputDir, filename);
2277
+ await writeFile(outputPath, Buffer.from(data));
2278
+ process.stdout.write(
2279
+ `Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
2280
+ `
2281
+ );
2282
+ } catch (err) {
2283
+ handleCommandError(err, id, context, "read", false);
2284
+ }
2285
+ }
2286
+ );
2287
+ return command;
2288
+ }
2289
+
2174
2290
  // src/index.ts
2175
- var program = new Command13();
2291
+ var program = new Command14();
2176
2292
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
2177
2293
  program.addCommand(createGetItemCommand());
2178
2294
  program.addCommand(createClearPatCommand());
@@ -2186,6 +2302,7 @@ program.addCommand(createUpsertCommand());
2186
2302
  program.addCommand(createListFieldsCommand());
2187
2303
  program.addCommand(createPrCommand());
2188
2304
  program.addCommand(createCommentsCommand());
2305
+ program.addCommand(createDownloadAttachmentCommand());
2189
2306
  program.showHelpAfterError();
2190
2307
  program.parse();
2191
2308
  if (process.argv.length <= 2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.8.0-develop.176",
3
+ "version": "0.8.1",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,8 @@
14
14
  "lint": "eslint src/",
15
15
  "typecheck": "tsc --noEmit",
16
16
  "format": "prettier --check src/",
17
- "test": "npm run build && vitest run tests/unit",
17
+ "test": "npm run build && vitest run tests/unit tests/integration",
18
+ "test:unit": "npm run build && vitest run tests/unit",
18
19
  "test:integration": "npm run build && vitest run tests/integration",
19
20
  "test:integration:full": "bash scripts/setup-keyring.sh && npm run test:integration"
20
21
  },