diff --git a/package-lock.json b/package-lock.json index d696a6d1..7fa4b7e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -156,9 +156,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -172,9 +169,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -188,9 +182,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -204,9 +195,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ diff --git a/src/agents/definitions/contextSteps.ts b/src/agents/definitions/contextSteps.ts index a5d283db..d47b0a43 100644 --- a/src/agents/definitions/contextSteps.ts +++ b/src/agents/definitions/contextSteps.ts @@ -106,6 +106,7 @@ export async function fetchWorkItemStep(params: FetchContextParams): Promise { @@ -113,6 +114,8 @@ export async function fetchWorkItemStep(params: FetchContextParams): Promise { + const { apiKey } = getLinearCredentials(); + const { downloadMedia } = await import('../pm/media.js'); + return downloadMedia(url, { Authorization: apiKey }); + }, + // ===== Reactions ===== async createReaction(commentId: string, emoji: string): Promise { diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts index 977b2e36..6f8a422a 100644 --- a/src/pm/linear/adapter.ts +++ b/src/pm/linear/adapter.ts @@ -23,6 +23,7 @@ import { } from '../_shared/inline-checklist.js'; import type { LinearConfig } from '../config.js'; import type { ContainerId, LabelId } from '../ids.js'; +import { extractMarkdownImages } from '../media.js'; import type { Attachment, Checklist, @@ -57,6 +58,7 @@ export class LinearPMProvider implements PMProvider { async getWorkItem(id: string): Promise { const issue = await linearClient.getIssue(id); + const inlineMedia = extractMarkdownImages(issue.description ?? '', 'description'); return { id: issue.identifier || issue.id, title: issue.title, @@ -70,21 +72,26 @@ export class LinearPMProvider implements PMProvider { color: l.color, }), ), + inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, }; } async getWorkItemComments(id: string): Promise { const comments = await linearClient.getIssueComments(id); - return comments.map((c) => ({ - id: c.id, - date: c.createdAt, - text: c.body, - author: { - id: c.user?.id ?? '', - name: c.user?.displayName ?? c.user?.name ?? '', - username: c.user?.email ?? '', - }, - })); + return comments.map((c) => { + const inlineMedia = extractMarkdownImages(c.body, 'comment'); + return { + id: c.id, + date: c.createdAt, + text: c.body, + author: { + id: c.user?.id ?? '', + name: c.user?.displayName ?? c.user?.name ?? '', + username: c.user?.email ?? '', + }, + inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined, + }; + }); } async updateWorkItem( diff --git a/tests/unit/agents/definitions/contextSteps.test.ts b/tests/unit/agents/definitions/contextSteps.test.ts index 3d0fd5cd..78ab8217 100644 --- a/tests/unit/agents/definitions/contextSteps.test.ts +++ b/tests/unit/agents/definitions/contextSteps.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../../src/gadgets/todo/storage.js', () => ({ const mockTrelloDownload = vi.fn(); const mockJiraDownload = vi.fn(); +const mockLinearDownload = vi.fn(); vi.mock('../../../../src/trello/client.js', () => ({ trelloClient: { @@ -27,6 +28,12 @@ vi.mock('../../../../src/jira/client.js', () => ({ }, })); +vi.mock('../../../../src/linear/client.js', () => ({ + linearClient: { + downloadAttachment: mockLinearDownload, + }, +})); + vi.mock('../../../../src/gadgets/pm/core/readWorkItem.js', () => ({ readWorkItem: vi.fn(), readWorkItemWithMedia: vi.fn(), @@ -206,6 +213,7 @@ describe('fetchWorkItemStep', () => { beforeEach(() => { mockTrelloDownload.mockReset(); mockJiraDownload.mockReset(); + mockLinearDownload.mockReset(); }); it('returns empty array when no workItemId', async () => { @@ -288,6 +296,39 @@ describe('fetchWorkItemStep', () => { expect(mockTrelloDownload).not.toHaveBeenCalled(); }); + it('uses linearClient.downloadAttachment for linear provider', async () => { + mockReadWorkItemWithMedia.mockResolvedValue({ + text: '# Linear issue with screenshot', + media: [ + { + url: 'https://uploads.linear.app/abc/screenshot.png', + mimeType: 'image/png', + altText: 'screenshot', + source: 'description', + }, + ], + }); + mockGetPMProviderOrNull.mockReturnValue({ type: 'linear' } as never); + mockLinearDownload.mockResolvedValue({ + buffer: Buffer.from('linear-image-data'), + mimeType: 'image/png', + }); + + const result = await fetchWorkItemStep(makeParams({ workItemId: 'LINEAR-1' })); + + expect(result[0].images).toHaveLength(1); + expect(result[0].images?.[0]).toEqual({ + base64Data: Buffer.from('linear-image-data').toString('base64'), + mimeType: 'image/png', + altText: 'screenshot', + }); + expect(mockLinearDownload).toHaveBeenCalledWith( + 'https://uploads.linear.app/abc/screenshot.png', + ); + expect(mockTrelloDownload).not.toHaveBeenCalled(); + expect(mockJiraDownload).not.toHaveBeenCalled(); + }); + it('logs WARN and skips when download returns null, stripping query params from URL', async () => { mockReadWorkItemWithMedia.mockResolvedValue({ text: '# Card', diff --git a/tests/unit/linear/client.test.ts b/tests/unit/linear/client.test.ts index 621df750..b5278d4d 100644 --- a/tests/unit/linear/client.test.ts +++ b/tests/unit/linear/client.test.ts @@ -286,3 +286,57 @@ describe('linearClient.createLabel — duplicate idempotency', () => { ).rejects.toThrow('team not found'); }); }); + +// ===== downloadAttachment ===== + +describe('linearClient.downloadAttachment', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('sends bare Authorization header (no Bearer prefix) and returns buffer + mimeType', async () => { + const imageBytes = Buffer.from('linear-image-data'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(imageBytes, { + status: 200, + headers: { 'Content-Type': 'image/png' }, + }), + ); + + const result = await withLinearCredentials({ apiKey: 'lin_api_testkey' }, () => + linearClient.downloadAttachment('https://uploads.linear.app/abc/screenshot.png'), + ); + + expect(result).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: guarded by expect above + expect(result!.mimeType).toBe('image/png'); + // biome-ignore lint/style/noNonNullAssertion: guarded by expect above + expect(result!.buffer).toBeInstanceOf(Buffer); + + const [url, options] = fetchSpy.mock.calls[0]; + expect(url).toBe('https://uploads.linear.app/abc/screenshot.png'); + // Linear personal keys are bare — no "Bearer" prefix + expect(options?.headers).toEqual({ Authorization: 'lin_api_testkey' }); + // Content-Type is NOT included (this is a GET download, not a GraphQL mutation) + expect((options?.headers as Record)?.['Content-Type']).toBeUndefined(); + }); + + it('returns null on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Forbidden', { status: 403 })); + + const result = await withLinearCredentials({ apiKey: 'lin_api_testkey' }, () => + linearClient.downloadAttachment('https://uploads.linear.app/abc/screenshot.png'), + ); + + expect(result).toBeNull(); + }); + + it('throws when called outside withLinearCredentials scope', async () => { + await expect( + linearClient.downloadAttachment('https://uploads.linear.app/abc/screenshot.png'), + ).rejects.toThrow('No Linear credentials in scope'); + }); +}); diff --git a/tests/unit/pm/linear/adapter.test.ts b/tests/unit/pm/linear/adapter.test.ts index bb4460a4..daee419c 100644 --- a/tests/unit/pm/linear/adapter.test.ts +++ b/tests/unit/pm/linear/adapter.test.ts @@ -126,6 +126,39 @@ describe('LinearPMProvider', () => { const result = await provider.getWorkItem('issue-uuid'); expect(result.description).toBe(''); }); + + it('populates inlineMedia when description contains markdown images', async () => { + mockGetIssue.mockResolvedValue( + makeIssue({ + description: + 'Here is a screenshot:\n\n![screenshot](https://uploads.linear.app/abc/def.png)', + }), + ); + + const result = await provider.getWorkItem('issue-uuid'); + + expect(result.inlineMedia).toHaveLength(1); + expect(result.inlineMedia?.[0]).toMatchObject({ + url: 'https://uploads.linear.app/abc/def.png', + mimeType: 'image/png', + altText: 'screenshot', + source: 'description', + }); + }); + + it('returns undefined inlineMedia when description has no images', async () => { + mockGetIssue.mockResolvedValue(makeIssue({ description: 'Plain text, no images here.' })); + + const result = await provider.getWorkItem('issue-uuid'); + + expect(result.inlineMedia).toBeUndefined(); + }); + + it('returns undefined inlineMedia when description is null', async () => { + mockGetIssue.mockResolvedValue(makeIssue({ description: null })); + const result = await provider.getWorkItem('issue-uuid'); + expect(result.inlineMedia).toBeUndefined(); + }); }); // ========================================================================= @@ -179,6 +212,53 @@ describe('LinearPMProvider', () => { expect(result[0].author.name).toBe(''); expect(result[0].author.username).toBe(''); }); + + it('populates inlineMedia when comment body contains markdown images', async () => { + mockGetIssueComments.mockResolvedValue([ + { + id: 'c3', + body: 'See this image: ![diagram](https://uploads.linear.app/xyz/diagram.png)', + createdAt: '2024-01-03T00:00:00Z', + updatedAt: '2024-01-03T00:00:00Z', + issueId: 'issue-uuid', + user: { + id: 'u1', + name: 'Bob', + email: 'bob@example.com', + displayName: 'Bob', + avatarUrl: null, + active: true, + }, + }, + ]); + + const result = await provider.getWorkItemComments('issue-uuid'); + + expect(result[0].inlineMedia).toHaveLength(1); + expect(result[0].inlineMedia?.[0]).toMatchObject({ + url: 'https://uploads.linear.app/xyz/diagram.png', + mimeType: 'image/png', + altText: 'diagram', + source: 'comment', + }); + }); + + it('returns undefined inlineMedia when comment body has no images', async () => { + mockGetIssueComments.mockResolvedValue([ + { + id: 'c4', + body: 'Just text, no images.', + createdAt: '2024-01-04T00:00:00Z', + updatedAt: '2024-01-04T00:00:00Z', + issueId: 'issue-uuid', + user: null, + }, + ]); + + const result = await provider.getWorkItemComments('issue-uuid'); + + expect(result[0].inlineMedia).toBeUndefined(); + }); }); // =========================================================================