azdo-cli 0.5.0-013-comments-markdown.174 → 0.5.0-014-work-item-attachments.182

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 +109 -10
  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";
@@ -857,6 +888,23 @@ function summarizeDescription(text, label, markdown) {
857
888
  }
858
889
  return [`${label("Description:")}${content}`];
859
890
  }
891
+ function formatFileSize(bytes) {
892
+ if (bytes < 1024) return `${bytes} B`;
893
+ const kb = bytes / 1024;
894
+ if (kb < 1024) return `${kb.toFixed(1)} KB`;
895
+ const mb = kb / 1024;
896
+ return `${mb.toFixed(1)} MB`;
897
+ }
898
+ function formatAttachments(attachments, short) {
899
+ if (short) {
900
+ return [`Attachments: ${attachments.length}`];
901
+ }
902
+ const lines = ["", "Attachments:"];
903
+ for (const att of attachments) {
904
+ lines.push(` ${att.name} (${formatFileSize(att.size)})`);
905
+ }
906
+ return lines;
907
+ }
860
908
  function formatWorkItem(workItem, short, markdown = false) {
861
909
  const label = (name) => name.padEnd(13);
862
910
  const lines = [
@@ -883,6 +931,9 @@ function formatWorkItem(workItem, short, markdown = false) {
883
931
  } else {
884
932
  lines.push("Description:", descriptionText);
885
933
  }
934
+ if (workItem.attachments) {
935
+ lines.push(...formatAttachments(workItem.attachments, short));
936
+ }
886
937
  return lines.join("\n");
887
938
  }
888
939
  function createGetItemCommand() {
@@ -2171,8 +2222,55 @@ function createCommentsCommand() {
2171
2222
  return command;
2172
2223
  }
2173
2224
 
2225
+ // src/commands/download-attachment.ts
2226
+ import { Command as Command13 } from "commander";
2227
+ import { writeFile } from "fs/promises";
2228
+ import { existsSync as existsSync4 } from "fs";
2229
+ import { join as join2 } from "path";
2230
+ function createDownloadAttachmentCommand() {
2231
+ const command = new Command13("download-attachment");
2232
+ 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(
2233
+ async (idStr, filename, options) => {
2234
+ const id = parseWorkItemId(idStr);
2235
+ validateOrgProjectPair(options);
2236
+ let context;
2237
+ try {
2238
+ context = resolveContext(options);
2239
+ const credential = await resolvePat();
2240
+ const outputDir = options.output ?? ".";
2241
+ if (!existsSync4(outputDir)) {
2242
+ process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
2243
+ `);
2244
+ process.exit(1);
2245
+ }
2246
+ const workItem = await getWorkItem(context, id, credential.pat);
2247
+ const attachment = workItem.attachments?.find(
2248
+ (a) => a.name === filename
2249
+ );
2250
+ if (!attachment) {
2251
+ process.stderr.write(
2252
+ `Error: Attachment "${filename}" not found on work item ${id}.
2253
+ `
2254
+ );
2255
+ process.exit(1);
2256
+ }
2257
+ const data = await downloadAttachment(attachment.url, credential.pat);
2258
+ const outputPath = join2(outputDir, filename);
2259
+ await writeFile(outputPath, Buffer.from(data));
2260
+ process.stdout.write(
2261
+ `Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
2262
+ `
2263
+ );
2264
+ } catch (err) {
2265
+ handleCommandError(err, id, context, "read", false);
2266
+ }
2267
+ }
2268
+ );
2269
+ return command;
2270
+ }
2271
+
2174
2272
  // src/index.ts
2175
- var program = new Command13();
2273
+ var program = new Command14();
2176
2274
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
2177
2275
  program.addCommand(createGetItemCommand());
2178
2276
  program.addCommand(createClearPatCommand());
@@ -2186,6 +2284,7 @@ program.addCommand(createUpsertCommand());
2186
2284
  program.addCommand(createListFieldsCommand());
2187
2285
  program.addCommand(createPrCommand());
2188
2286
  program.addCommand(createCommentsCommand());
2287
+ program.addCommand(createDownloadAttachmentCommand());
2189
2288
  program.showHelpAfterError();
2190
2289
  program.parse();
2191
2290
  if (process.argv.length <= 2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.5.0-013-comments-markdown.174",
3
+ "version": "0.5.0-014-work-item-attachments.182",
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
  },