-
Notifications
You must be signed in to change notification settings - Fork 1
feat(ado): add work item attachment upload #205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||
| }); | ||
| }); |
| 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'; | ||
| } |
| 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, | ||||||||||||
|
|
@@ -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); | ||||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion — no file-existence check before the synchronous read
A lightweight pre-check improves the UX without much overhead:
Suggested change
Or even simpler — import
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
There was a problem hiding this comment.
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 intry/finallyis the safe pattern here:Same applies to the second test (
pncli-test-notes.txt). Low-risk since these run in tmpdir, but worth doing right.