diff --git a/src/lib/http.ts b/src/lib/http.ts index 0bd9077..e70369b 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -205,6 +205,31 @@ export class HttpClient { return Buffer.from(await response.arrayBuffer()); } + async adoUpload( + path: string, + buffer: Buffer, + contentType: string, + opts: { timeoutMs?: number } = {} + ): Promise { + const baseUrl = this.config.ado.baseUrl; + if (!baseUrl) throw new PncliError('Azure DevOps baseUrl not configured. Run: pncli config init'); + + const url = buildUrl(baseUrl, path); + + if (this.dryRun) { + fs.writeSync(process.stderr.fd, `DRY RUN: POST ${url}\nBody: \n`); + process.exitCode = ExitCode.SUCCESS; + throw new PncliError('dry-run', 0); + } + + const fetcher = await this.getAdoFetcher(); + return request(url, { + method: 'POST', + headers: { 'Accept': 'application/json', 'Content-Type': contentType }, + body: buffer + }, opts.timeoutMs ?? 60000, fetcher); + } + async adoBuffer(absoluteUrl: string, opts: { timeoutMs?: number } = {}): Promise { if (this.dryRun) { fs.writeSync(process.stderr.fd, `DRY RUN: GET ${absoluteUrl}\n`); diff --git a/src/lib/mime.test.ts b/src/lib/mime.test.ts new file mode 100644 index 0000000..1ffbf2b --- /dev/null +++ b/src/lib/mime.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { guessMimeType } from './mime.js'; + +describe('guessMimeType', () => { + it('returns correct MIME type for image extensions', () => { + expect(guessMimeType('photo.jpg')).toBe('image/jpeg'); + expect(guessMimeType('photo.jpeg')).toBe('image/jpeg'); + expect(guessMimeType('icon.png')).toBe('image/png'); + expect(guessMimeType('animation.gif')).toBe('image/gif'); + expect(guessMimeType('logo.svg')).toBe('image/svg+xml'); + }); + + it('returns correct MIME type for document extensions', () => { + expect(guessMimeType('document.pdf')).toBe('application/pdf'); + expect(guessMimeType('notes.txt')).toBe('text/plain'); + expect(guessMimeType('app.log')).toBe('text/plain'); + expect(guessMimeType('data.csv')).toBe('text/csv'); + expect(guessMimeType('config.json')).toBe('application/json'); + expect(guessMimeType('data.xml')).toBe('application/xml'); + expect(guessMimeType('readme.md')).toBe('text/markdown'); + expect(guessMimeType('page.html')).toBe('text/html'); + expect(guessMimeType('page.htm')).toBe('text/html'); + }); + + it('returns correct MIME type for Microsoft Office extensions', () => { + expect(guessMimeType('report.doc')).toBe('application/msword'); + expect(guessMimeType('report.docx')).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + expect(guessMimeType('spreadsheet.xls')).toBe('application/vnd.ms-excel'); + expect(guessMimeType('spreadsheet.xlsx')).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + }); + + it('returns correct MIME type for archive extensions', () => { + expect(guessMimeType('archive.zip')).toBe('application/zip'); + expect(guessMimeType('archive.tar')).toBe('application/x-tar'); + expect(guessMimeType('archive.gz')).toBe('application/gzip'); + }); + + it('is case-insensitive', () => { + expect(guessMimeType('PHOTO.JPG')).toBe('image/jpeg'); + expect(guessMimeType('Photo.PNG')).toBe('image/png'); + expect(guessMimeType('Document.PDF')).toBe('application/pdf'); + }); + + it('works with absolute paths', () => { + expect(guessMimeType('/home/user/documents/report.pdf')).toBe('application/pdf'); + expect(guessMimeType('/var/log/app.log')).toBe('text/plain'); + }); + + it('works with relative paths', () => { + expect(guessMimeType('./photos/vacation.jpg')).toBe('image/jpeg'); + expect(guessMimeType('../../docs/readme.md')).toBe('text/markdown'); + }); + + it('returns application/octet-stream for unknown extensions', () => { + expect(guessMimeType('file.unknown')).toBe('application/octet-stream'); + expect(guessMimeType('file.xyz')).toBe('application/octet-stream'); + expect(guessMimeType('file.custom')).toBe('application/octet-stream'); + }); + + it('returns application/octet-stream for files without extensions', () => { + expect(guessMimeType('Makefile')).toBe('application/octet-stream'); + expect(guessMimeType('README')).toBe('application/octet-stream'); + }); +}); diff --git a/src/lib/mime.ts b/src/lib/mime.ts new file mode 100644 index 0000000..cf96806 --- /dev/null +++ b/src/lib/mime.ts @@ -0,0 +1,36 @@ +import { extname } from 'path'; + +/** + * Guess MIME type from file extension. + * Falls back to application/octet-stream for unknown extensions. + * + * @param filePath - Path to the file (can be absolute or relative) + * @returns MIME type string + */ +export function guessMimeType(filePath: string): string { + const ext = extname(filePath).toLowerCase(); + const map: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.log': 'text/plain', + '.csv': 'text/csv', + '.json': 'application/json', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.md': 'text/markdown', + '.html': 'text/html', + '.htm': 'text/html', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }; + return map[ext] ?? 'application/octet-stream'; +} diff --git a/src/services/ado/client/work.test.ts b/src/services/ado/client/work.test.ts index 04d0a53..57ea78c 100644 --- a/src/services/ado/client/work.test.ts +++ b/src/services/ado/client/work.test.ts @@ -1,3 +1,6 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { AdoWorkClient } from './work.js'; import { HttpClient } from '../../../lib/http.js'; @@ -303,3 +306,85 @@ describe('AdoWorkClient — downloadAttachment', () => { expect(buffer[0]).toBe(137); }); }); + +describe('AdoWorkClient — uploadAttachment', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + it('uploads the file and links it to the work item', async () => { + const tmpFile = path.join(os.tmpdir(), 'pncli-test-report.txt'); + fs.writeFileSync(tmpFile, 'file content'); + + const capturedRequests: Array<{ url: string; method: string; body: unknown; contentType?: string }> = []; + const attachmentResponse = { + id: 'att-guid-123', + url: 'https://ado.example.com/myorg/_apis/wit/attachments/att-guid-123', + name: 'pncli-test-report.txt' + }; + + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + const contentType = (init.headers as Record)['Content-Type'] ?? ''; + let body: unknown = undefined; + if (typeof init.body === 'string') { + body = JSON.parse(init.body); + } + capturedRequests.push({ url: String(url), method: String(init.method), body, contentType }); + + if (String(url).includes('attachments?fileName')) { + return new Response(JSON.stringify(attachmentResponse), { status: 200 }); + } + return new Response(JSON.stringify(makeWorkItem('')), { status: 200 }); + }); + + const http = new HttpClient(makeConfig()); + const client = new AdoWorkClient(http); + const result = await client.uploadAttachment('myorg', 42, tmpFile); + + fs.unlinkSync(tmpFile); + + expect(result.id).toBe('att-guid-123'); + expect(result.url).toBe('https://ado.example.com/myorg/_apis/wit/attachments/att-guid-123'); + + // First request: upload to attachments endpoint + expect(capturedRequests[0].url).toContain('_apis/wit/attachments'); + expect(capturedRequests[0].url).toContain('fileName=pncli-test-report.txt'); + expect(capturedRequests[0].method).toBe('POST'); + expect(capturedRequests[0].contentType).toBe('text/plain'); + + // Second request: PATCH work item to link the attachment + expect(capturedRequests[1].url).toContain('_apis/wit/workitems/42'); + expect(capturedRequests[1].method).toBe('PATCH'); + const patch = capturedRequests[1].body as Array<{ op: string; path: string; value: { rel: string; url: string } }>; + expect(patch[0].op).toBe('add'); + expect(patch[0].path).toBe('/relations/-'); + expect(patch[0].value.rel).toBe('AttachedFile'); + expect(patch[0].value.url).toBe(attachmentResponse.url); + }); + + it('includes comment in the relation attributes when provided', async () => { + const tmpFile = path.join(os.tmpdir(), 'pncli-test-notes.txt'); + fs.writeFileSync(tmpFile, 'meeting notes'); + + const capturedBodies: unknown[] = []; + + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + if (String(url).includes('attachments?fileName')) { + return new Response(JSON.stringify({ + id: 'att-guid-456', + url: 'https://ado.example.com/myorg/_apis/wit/attachments/att-guid-456', + name: 'pncli-test-notes.txt' + }), { status: 200 }); + } + capturedBodies.push(JSON.parse(init.body as string)); + return new Response(JSON.stringify(makeWorkItem('')), { status: 200 }); + }); + + const http = new HttpClient(makeConfig()); + const client = new AdoWorkClient(http); + await client.uploadAttachment('myorg', 42, tmpFile, 'Meeting notes'); + + fs.unlinkSync(tmpFile); + + const patch = capturedBodies[0] as Array<{ op: string; path: string; value: { rel: string; attributes?: { comment: string } } }>; + expect(patch[0].value.attributes?.comment).toBe('Meeting notes'); + }); +}); diff --git a/src/services/ado/client/work.ts b/src/services/ado/client/work.ts index ab7d26d..d4bd36a 100644 --- a/src/services/ado/client/work.ts +++ b/src/services/ado/client/work.ts @@ -1,4 +1,7 @@ +import { readFileSync } from 'fs'; +import { basename } from 'path'; import type { HttpClient } from '../../../lib/http.js'; +import { guessMimeType } from '../../../lib/mime.js'; import type { AdoWorkItem, AdoWorkItemComment, @@ -140,4 +143,35 @@ export class AdoWorkClient { async downloadAttachment(absoluteUrl: string): Promise { return this.http.adoBuffer(absoluteUrl); } + + async uploadAttachment( + collection: string, + workItemId: number, + filePath: string, + comment?: string + ): Promise { + const fileContent = readFileSync(filePath); + const fileName = basename(filePath); + const mimeType = guessMimeType(filePath); + + // Step 1: Upload the file to ADO's attachment store + const attachment = await this.http.adoUpload( + `/${encodeURIComponent(collection)}/_apis/wit/attachments?fileName=${encodeURIComponent(fileName)}&api-version=${API}`, + fileContent, + mimeType + ); + + // Step 2: Link the uploaded attachment to the work item + const relationValue: Record = { + rel: 'AttachedFile', + url: attachment.url, + ...(comment ? { attributes: { comment } } : {}) + }; + await this.http.ado( + `/${encodeURIComponent(collection)}/_apis/wit/workitems/${workItemId}?api-version=${API}`, + { method: 'PATCH', body: [{ op: 'add', path: '/relations/-', value: relationValue }], headers: { 'Content-Type': 'application/json-patch+json' } } + ); + + return attachment; + } } diff --git a/src/services/ado/commands/work.ts b/src/services/ado/commands/work.ts index 0141d96..af3a2ba 100644 --- a/src/services/ado/commands/work.ts +++ b/src/services/ado/commands/work.ts @@ -247,6 +247,21 @@ export function registerAdoWorkCommands(ado: Command): void { } catch (err) { fail(err, 'ado', 'work-list-attachments', start); } }); + work + .command('add-attachment') + .description('Upload a local file and attach it to a work item') + .requiredOption('--id ', 'Work item ID') + .requiredOption('--file ', 'Path to the file to upload') + .option('--comment ', 'Optional comment for the attachment') + .action(async (opts: { id: string; file: string; comment?: string }) => { + const start = Date.now(); + try { + const { collection, workClient } = getAdoContext(ado); + const data = await workClient.uploadAttachment(collection, parseInt(opts.id, 10), opts.file, opts.comment); + success(data, 'ado', 'work-add-attachment', start); + } catch (err) { fail(err, 'ado', 'work-add-attachment', start); } + }); + work .command('download-attachment') .description('Download a work item attachment to .pncli/ (or --dir)') diff --git a/src/services/jira/client.ts b/src/services/jira/client.ts index f405e28..a34d59b 100644 --- a/src/services/jira/client.ts +++ b/src/services/jira/client.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'fs'; -import { basename, extname } from 'path'; +import { basename } from 'path'; import type { HttpClient } from '../../lib/http.js'; +import { guessMimeType } from '../../lib/mime.js'; import type { JiraIssue, JiraTransition, @@ -292,35 +293,6 @@ export class JiraClient { } } -/** Guess MIME type from file extension; falls back to application/octet-stream. */ -function guessMimeType(filePath: string): string { - const ext = extname(filePath).toLowerCase(); - const map: Record = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.pdf': 'application/pdf', - '.txt': 'text/plain', - '.log': 'text/plain', - '.csv': 'text/csv', - '.json': 'application/json', - '.xml': 'application/xml', - '.zip': 'application/zip', - '.tar': 'application/x-tar', - '.gz': 'application/gzip', - '.md': 'text/markdown', - '.html': 'text/html', - '.htm': 'text/html', - '.doc': 'application/msword', - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - '.xls': 'application/vnd.ms-excel', - '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }; - return map[ext] ?? 'application/octet-stream'; -} - /** Map a Jira field schema to the recommended pncli CustomFieldType. */ function schemaToPncliType(schema?: { type: string; custom?: string }): CustomFieldType | undefined { if (!schema) return undefined;