Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,31 @@ export class HttpClient {
return Buffer.from(await response.arrayBuffer());
}

async adoUpload<T>(
path: string,
buffer: Buffer,
contentType: string,
opts: { timeoutMs?: number } = {}
): Promise<T> {
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: <binary ${buffer.length} bytes>\n`);
process.exitCode = ExitCode.SUCCESS;
throw new PncliError('dry-run', 0);
}

const fetcher = await this.getAdoFetcher();
return request<T>(url, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': contentType },
body: buffer
}, opts.timeoutMs ?? 60000, fetcher);
}

async adoBuffer(absoluteUrl: string, opts: { timeoutMs?: number } = {}): Promise<Buffer> {
if (this.dryRun) {
fs.writeSync(process.stderr.fd, `DRY RUN: GET ${absoluteUrl}\n`);
Expand Down
64 changes: 64 additions & 0 deletions src/lib/mime.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
36 changes: 36 additions & 0 deletions src/lib/mime.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'.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';
}
85 changes: 85 additions & 0 deletions src/services/ado/client/work.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string>)['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');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — temp file isn't cleaned up if an assertion throws

fs.unlinkSync(tmpFile) is called after the assertions, so a failing assertion will leak the temp file. Wrapping in try/finally is the safe pattern here:

Suggested change
expect(capturedRequests[1].method).toBe('PATCH');
try {
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);
} finally {
fs.unlinkSync(tmpFile);
}

Same applies to the second test (pncli-test-notes.txt). Low-risk since these run in tmpdir, but worth doing right.

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');
});
});
34 changes: 34 additions & 0 deletions src/services/ado/client/work.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -140,4 +143,35 @@ export class AdoWorkClient {
async downloadAttachment(absoluteUrl: string): Promise<Buffer> {
return this.http.adoBuffer(absoluteUrl);
}

async uploadAttachment(
collection: string,
workItemId: number,
filePath: string,
comment?: string
): Promise<AdoWorkItemAttachment> {
const fileContent = readFileSync(filePath);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion — no file-existence check before the synchronous read

readFileSync throws a raw Node.js ENOENT error (not a PncliError) when the file doesn't exist. The fail() handler will still catch it, but the output will be a raw system error message rather than the usual PncliError format.

A lightweight pre-check improves the UX without much overhead:

Suggested change
const fileContent = readFileSync(filePath);
if (!require('fs').existsSync(filePath)) {
throw new PncliError(`File not found: ${filePath}`, 1);
}
const fileContent = readFileSync(filePath);

Or even simpler — import existsSync alongside readFileSync at the top. Not a blocker since the error still surfaces, just noisier than a PncliError.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude[agent] adjust this

const fileName = basename(filePath);
const mimeType = guessMimeType(filePath);

// Step 1: Upload the file to ADO's attachment store
const attachment = await this.http.adoUpload<AdoWorkItemAttachment>(
`/${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<string, unknown> = {
rel: 'AttachedFile',
url: attachment.url,
...(comment ? { attributes: { comment } } : {})
};
await this.http.ado<AdoWorkItem>(
`/${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;
}
}
15 changes: 15 additions & 0 deletions src/services/ado/commands/work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>', 'Work item ID')
.requiredOption('--file <path>', 'Path to the file to upload')
.option('--comment <text>', '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)')
Expand Down
32 changes: 2 additions & 30 deletions src/services/jira/client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, string> = {
'.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;
Expand Down
Loading