Skip to content

Commit d41ecad

Browse files
committed
fix(linear): wire AttachmentsBucket into LinearIntegration
Issue descriptions can carry markdown image references that `extractImageUrlAttachments` (linear-webhook-processor.ts) turns into URL attachments on the createTaskCore call. The shared `create-task-core` path uploads the screened bytes to `ATTACHMENTS_BUCKET_NAME`, mirroring the TaskApi and Slack flows — but `LinearIntegration` was the only construct that never received the bucket reference, so any Linear trigger with an image in the description failed at task creation with 503 "Attachment storage is not configured." Add `attachmentsBucket` to `LinearIntegrationProps`, set the env var on the webhook processor when present, and grant `grantPut` + `grantDelete` to match the TaskApi pattern. Construct test locks in both the env var and the IAM grants. (cherry picked from commit c59c8a6)
1 parent 200b527 commit d41ecad

3 files changed

Lines changed: 88 additions & 0 deletions

File tree

cdk/src/constructs/linear-integration.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
2525
import * as iam from 'aws-cdk-lib/aws-iam';
2626
import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda';
2727
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs';
28+
import * as s3 from 'aws-cdk-lib/aws-s3';
2829
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
2930
import { NagSuppressions } from 'cdk-nag';
3031
import { Construct } from 'constructs';
@@ -93,6 +94,14 @@ export interface LinearIntegrationProps {
9394
/** Bedrock Guardrail version for input screening. */
9495
readonly guardrailVersion?: string;
9596

97+
/**
98+
* S3 bucket for attachment storage. Required to support image attachments
99+
* extracted from issue descriptions (markdown `![alt](https://…)` images).
100+
* When omitted, Linear-triggered tasks with image attachments fail at
101+
* `createTaskCore` with "Attachment storage is not configured."
102+
*/
103+
readonly attachmentsBucket?: s3.IBucket;
104+
96105
/** Task retention in days for TTL computation. */
97106
readonly taskRetentionDays?: number;
98107

@@ -192,6 +201,9 @@ export class LinearIntegration extends Construct {
192201
createTaskEnv.GUARDRAIL_ID = props.guardrailId;
193202
createTaskEnv.GUARDRAIL_VERSION = props.guardrailVersion;
194203
}
204+
if (props.attachmentsBucket) {
205+
createTaskEnv.ATTACHMENTS_BUCKET_NAME = props.attachmentsBucket.bucketName;
206+
}
195207

196208
// --- Cognito Authorizer (for /linear/link) ---
197209
const cognitoAuthorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'LinearCognitoAuthorizer', {
@@ -291,6 +303,15 @@ export class LinearIntegration extends Construct {
291303
],
292304
}));
293305
}
306+
// Issue descriptions can carry markdown `![alt](https://…)` images, which
307+
// `extractImageUrlAttachments` (linear-webhook-processor.ts) turns into
308+
// URL attachments. `createTaskCore` then uploads the screened bytes to
309+
// `ATTACHMENTS_BUCKET_NAME`, mirroring the TaskApi/Slack paths. Without
310+
// grantPut + grantDelete here, that upload fails closed with 503.
311+
if (props.attachmentsBucket) {
312+
props.attachmentsBucket.grantPut(webhookProcessorFn);
313+
props.attachmentsBucket.grantDelete(webhookProcessorFn);
314+
}
294315

295316
// --- Webhook receiver (verifies HMAC, dedups, invokes processor) ---
296317
const webhookFn = new lambda.NodejsFunction(this, 'WebhookFn', {

cdk/src/stacks/agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,9 @@ export class AgentStack extends Stack {
806806
// the sweep to re-release from).
807807
userConcurrencyTable: userConcurrencyTable.table,
808808
maxConcurrentTasksPerUser,
809+
// Image attachments extracted from issue descriptions upload here
810+
// (otherwise createTaskCore 503s "Attachment storage is not configured").
811+
attachmentsBucket: attachmentsBucket.bucket,
809812
});
810813

811814
// #247 Mode A: the reconciler consumes the TaskTable stream and

cdk/test/constructs/linear-integration.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Template, Match } from 'aws-cdk-lib/assertions';
2222
import * as apigw from 'aws-cdk-lib/aws-apigateway';
2323
import * as cognito from 'aws-cdk-lib/aws-cognito';
2424
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
25+
import * as s3 from 'aws-cdk-lib/aws-s3';
2526
import { LinearIntegration } from '../../src/constructs/linear-integration';
2627

2728
describe('LinearIntegration construct', () => {
@@ -181,3 +182,66 @@ describe('LinearIntegration construct — #331 seed-time root release throttle',
181182
expect(Object.keys(fns)).toHaveLength(0);
182183
});
183184
});
185+
186+
describe('LinearIntegration construct — attachmentsBucket wiring', () => {
187+
// Regression-guard: webhook processor needs ATTACHMENTS_BUCKET_NAME and S3
188+
// Put/Delete on the bucket so `extractImageUrlAttachments` can reach the
189+
// bucket via createTaskCore. Without this, Linear-triggered tasks with
190+
// markdown image attachments fail with 503 ("Attachment storage is not
191+
// configured.") — the symptom that bit `linear-vercel` 2026-05-27.
192+
let template: Template;
193+
194+
beforeAll(() => {
195+
const app = new App();
196+
const stack = new Stack(app, 'TestStack');
197+
198+
const api = new apigw.RestApi(stack, 'TestApi');
199+
const userPool = new cognito.UserPool(stack, 'TestUserPool');
200+
const taskTable = new dynamodb.Table(stack, 'TaskTable', {
201+
partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING },
202+
});
203+
const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', {
204+
partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING },
205+
sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING },
206+
});
207+
const attachmentsBucket = new s3.Bucket(stack, 'AttachmentsBucket');
208+
209+
new LinearIntegration(stack, 'LinearIntegration', {
210+
api,
211+
userPool,
212+
taskTable,
213+
taskEventsTable,
214+
attachmentsBucket,
215+
});
216+
217+
template = Template.fromStack(stack);
218+
});
219+
220+
test('processor env includes ATTACHMENTS_BUCKET_NAME when bucket provided', () => {
221+
template.hasResourceProperties('AWS::Lambda::Function', {
222+
Environment: {
223+
Variables: Match.objectLike({
224+
ATTACHMENTS_BUCKET_NAME: Match.anyValue(),
225+
LINEAR_PROJECT_MAPPING_TABLE_NAME: Match.anyValue(),
226+
}),
227+
},
228+
});
229+
});
230+
231+
test('processor role can PutObject and DeleteObject on the attachments bucket', () => {
232+
template.hasResourceProperties('AWS::IAM::Policy', {
233+
PolicyDocument: {
234+
Statement: Match.arrayWith([
235+
Match.objectLike({
236+
Action: Match.arrayWith(['s3:PutObject']),
237+
Effect: 'Allow',
238+
}),
239+
Match.objectLike({
240+
Action: 's3:DeleteObject*',
241+
Effect: 'Allow',
242+
}),
243+
]),
244+
},
245+
});
246+
});
247+
});

0 commit comments

Comments
 (0)