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
12 changes: 0 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/agents/definitions/contextSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,16 @@ export async function fetchWorkItemStep(params: FetchContextParams): Promise<Con

const { jiraClient } = await import('../../jira/client.js');
const { trelloClient } = await import('../../trello/client.js');
const { linearClient } = await import('../../linear/client.js');

const results = await Promise.all(
limited.map(async (ref) => {
try {
let downloaded: { buffer: Buffer; mimeType: string } | null = null;
if (provider?.type === 'jira') {
downloaded = await jiraClient.downloadAttachment(ref.url);
} else if (provider?.type === 'linear') {
downloaded = await linearClient.downloadAttachment(ref.url);
} else {
downloaded = await trelloClient.downloadAttachment(ref.url);
}
Expand Down
17 changes: 17 additions & 0 deletions src/linear/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,23 @@ export const linearClient = {
};
},

/**
* Downloads a Linear-hosted image (e.g. `uploads.linear.app/…`) and
* returns its raw bytes and MIME type.
*
* Linear personal API keys are sent **bare** in the `Authorization` header
* (no `Bearer` prefix). `Content-Type` is intentionally omitted here
* because this is a GET download request, not a JSON API call.
*
* @param url - The attachment/inline image URL to download.
* @returns `{ buffer, mimeType }` on success, `null` on any failure.
*/
async downloadAttachment(url: string): Promise<{ buffer: Buffer; mimeType: string } | null> {
const { apiKey } = getLinearCredentials();
const { downloadMedia } = await import('../pm/media.js');
return downloadMedia(url, { Authorization: apiKey });
},

// ===== Reactions =====

async createReaction(commentId: string, emoji: string): Promise<LinearReaction> {
Expand Down
27 changes: 17 additions & 10 deletions src/pm/linear/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -57,6 +58,7 @@ export class LinearPMProvider implements PMProvider {

async getWorkItem(id: string): Promise<WorkItem> {
const issue = await linearClient.getIssue(id);
const inlineMedia = extractMarkdownImages(issue.description ?? '', 'description');
return {
id: issue.identifier || issue.id,
title: issue.title,
Expand All @@ -70,21 +72,26 @@ export class LinearPMProvider implements PMProvider {
color: l.color,
}),
),
inlineMedia: inlineMedia.length > 0 ? inlineMedia : undefined,
};
}

async getWorkItemComments(id: string): Promise<WorkItemComment[]> {
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(
Expand Down
41 changes: 41 additions & 0 deletions tests/unit/agents/definitions/contextSteps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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(),
Expand Down Expand Up @@ -206,6 +213,7 @@ describe('fetchWorkItemStep', () => {
beforeEach(() => {
mockTrelloDownload.mockReset();
mockJiraDownload.mockReset();
mockLinearDownload.mockReset();
});

it('returns empty array when no workItemId', async () => {
Expand Down Expand Up @@ -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',
Expand Down
54 changes: 54 additions & 0 deletions tests/unit/linear/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>)?.['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');
});
});
80 changes: 80 additions & 0 deletions tests/unit/pm/linear/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

// =========================================================================
Expand Down Expand Up @@ -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();
});
});

// =========================================================================
Expand Down
Loading