Skip to content

Commit 53057b1

Browse files
committed
fix(linear): match angle-bracket autolink URL form in attachment/image scan (ABCA-744)
Live-caught on ABCA-744: Linear normalizes a plain `[f](https://…)` link into the CommonMark autolink form `[f](<https://…>)` on round-trip. The un-bracketed pattern didn't match, so the uploads.linear.app attachment was silently dropped — the task ran without it, no error. Fix both scan patterns to accept optional `<`/`>` around the URL and exclude `>` from the URL body: - linear-attachments.MARKDOWN_LINK_OR_IMAGE_PATTERN (authenticated fetch) - linear-webhook-processor.extractImageUrlAttachments (public-CDN image path) Adds a regression test asserting the angle-bracket form is matched and the captured URL excludes the trailing '>'.
1 parent ccc66d4 commit 53057b1

3 files changed

Lines changed: 28 additions & 5 deletions

File tree

cdk/src/handlers/linear-webhook-processor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3210,7 +3210,10 @@ async function screenCommentsOrDrop(
32103210
function extractImageUrlAttachments(description: string | undefined): Attachment[] {
32113211
if (!description) return [];
32123212

3213-
const imagePattern = /!\[[^\]]*\]\((https:\/\/[^)]+)\)/g;
3213+
// Angle-bracket URL form `![alt](<https://…>)` is the CommonMark autolink
3214+
// Linear normalizes links into (see linear-attachments.MARKDOWN_LINK_OR_IMAGE_PATTERN,
3215+
// ABCA-744). Optional `<`/`>`, excluded from the capture.
3216+
const imagePattern = /!\[[^\]]*\]\(<?(https:\/\/[^)>]+)>?\)/g;
32143217
const attachments: Attachment[] = [];
32153218
let skippedLinearUploads = 0;
32163219
let match: RegExpExecArray | null;

cdk/src/handlers/shared/linear-attachments.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,16 @@ const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d] as const; // %PDF-
8080
* Markdown reference to a `uploads.linear.app` file. Matches BOTH the image form
8181
* `![alt](url)` AND the plain link form `[label](url)` — Linear embeds uploaded
8282
* images inline (`!`) but attaches uploaded files (PDFs, logs, specs) as plain
83-
* links. The leading `!` is optional so both are captured. Mirrors the image
84-
* pattern in `linear-webhook-processor.extractImageUrlAttachments` (which only
85-
* needs the `!` form for the public-CDN URL path).
83+
* links. The leading `!` is optional so both are captured.
84+
*
85+
* The URL may be wrapped in angle brackets — `[label](<https://…>)` — which is
86+
* the CommonMark autolink form Linear NORMALIZES uploaded-file links into (live-
87+
* caught on ABCA-744: a plain `[f](https://…)` link round-tripped through Linear
88+
* comes back as `(<https://…>)`, and the un-bracketed pattern silently dropped
89+
* it). The `<`/`>` are optional and excluded from the captured URL, and `>` is
90+
* excluded from the URL body so the closing bracket can't leak in.
8691
*/
87-
const MARKDOWN_LINK_OR_IMAGE_PATTERN = /!?\[[^\]]*\]\((https:\/\/[^)]+)\)/g;
92+
const MARKDOWN_LINK_OR_IMAGE_PATTERN = /!?\[[^\]]*\]\(<?(https:\/\/[^)>]+)>?\)/g;
8893

8994
/** Extension → MIME for typing a Linear upload when the response content-type
9095
* is generic (e.g. application/octet-stream). Only platform-allowed types. */

cdk/test/handlers/shared/linear-attachments.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,21 @@ describe('downloadScreenAndStoreLinearAttachments', () => {
220220
expect(putSendMock).toHaveBeenCalledTimes(1);
221221
});
222222

223+
test('matches the angle-bracket autolink URL form Linear normalizes links into (ABCA-744)', async () => {
224+
// Linear round-trips `[f](https://…)` into `[f](<https://…>)`. The un-bracketed
225+
// pattern dropped it silently (live-caught on ABCA-744) — the attachment
226+
// never reached S3 and the task ran without it.
227+
(global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PDF_BYTES, 200, 'application/pdf'));
228+
const desc = 'See [design.pdf](<https://uploads.linear.app/u/p/design.pdf>) attached.';
229+
const records = await downloadScreenAndStoreLinearAttachments(desc, 10, storageCtx());
230+
expect(records).toHaveLength(1);
231+
expect(records[0].type).toBe('file');
232+
expect(records[0].content_type).toBe('application/pdf');
233+
// The captured URL must NOT include the trailing '>' (the fetch must hit the real URL).
234+
const fetchedUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string;
235+
expect(fetchedUrl).toBe('https://uploads.linear.app/u/p/design.pdf');
236+
});
237+
223238
test('types a generic octet-stream response by its .log extension and screens as text', async () => {
224239
(global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(TEXT_BYTES, 200, 'application/octet-stream'));
225240
const records = await downloadScreenAndStoreLinearAttachments(

0 commit comments

Comments
 (0)