Skip to content

Commit a852361

Browse files
isadeksclaude
andcommitted
feat(screenshot): also post screenshot comment to linked Linear issue
Extends the screenshot processor to find a Linear issue via the PR's title/body and post the same image comment there. Approach (no GSI write-back needed): - Regex-extract Linear identifier (e.g. `ABCA-42`) from PR title/body. These are present whether the agent put them there (`task_description` carries the identifier) or Linear's own GitHub integration auto-injected the back-reference on PR open. - Scan `LinearWorkspaceRegistryTable` for `status=active` workspaces. Per-workspace, query Linear's `issueVcsBranchSearch` (which accepts the human-readable identifier) and accept the first exact-match hit. - Post the markdown image comment via the existing `postIssueComment` helper from Phase 2.0b. The Linear post is best-effort — if the registry table isn't wired, the identifier doesn't extract, or the lookup misses, the GitHub PR comment still lands. New env var `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME` is optional on the processor; the construct only sets it when the prop is provided. CDK: `GitHubScreenshotIntegrationProps` gains an optional `linearWorkspaceRegistryTable`. When provided, the processor's IAM grows: ReadData on the registry, GetSecretValue+PutSecretValue on `bgagent-linear-oauth-*`. `agent.ts` wires `linearIntegration.workspaceRegistryTable` into the screenshot construct. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fbf0de0 commit a852361

4 files changed

Lines changed: 322 additions & 11 deletions

File tree

cdk/src/constructs/github-screenshot-integration.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*/
1919

2020
import * as path from 'path';
21-
import { Duration, RemovalPolicy } from 'aws-cdk-lib';
21+
import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
2222
import * as apigw from 'aws-cdk-lib/aws-apigateway';
2323
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
2424
import * as iam from 'aws-cdk-lib/aws-iam';
@@ -44,6 +44,15 @@ export interface GitHubScreenshotIntegrationProps {
4444
*/
4545
readonly githubTokenSecret: secretsmanager.ISecret;
4646

47+
/**
48+
* Optional — when provided, the processor also tries to post the
49+
* screenshot to a linked Linear issue. Resolved from the GitHub PR
50+
* title/body via a Linear-identifier regex (e.g. `ABCA-42`), then
51+
* looked up across all `status='active'` workspaces in the registry
52+
* via Linear's `issueVcsBranchSearch` GraphQL.
53+
*/
54+
readonly linearWorkspaceRegistryTable?: dynamodb.ITable;
55+
4756
/**
4857
* Removal policy for the dedup table + screenshot bucket. Defaults
4958
* to DESTROY so dev stacks don't accumulate orphans on `cdk destroy`.
@@ -142,13 +151,36 @@ export class GitHubScreenshotIntegration extends Construct {
142151
SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName,
143152
SCREENSHOT_PUBLIC_HOST: this.screenshotBucket.distribution.domainName,
144153
GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn,
154+
...(props.linearWorkspaceRegistryTable && {
155+
LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: props.linearWorkspaceRegistryTable.tableName,
156+
}),
145157
},
146158
bundling: commonBundling,
147159
});
148160

149161
this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn);
150162
props.githubTokenSecret.grantRead(this.webhookProcessorFn);
151163

164+
// Optional Linear feedback path. Wired only when a registry table
165+
// is provided. The processor scans the registry for active
166+
// workspaces, then per-workspace looks up the OAuth token from
167+
// Secrets Manager (`bgagent-linear-oauth-*` prefix, written by
168+
// `bgagent linear setup`).
169+
if (props.linearWorkspaceRegistryTable) {
170+
props.linearWorkspaceRegistryTable.grantReadData(this.webhookProcessorFn);
171+
this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({
172+
actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'],
173+
resources: [
174+
Stack.of(this).formatArn({
175+
service: 'secretsmanager',
176+
resource: 'secret',
177+
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
178+
resourceName: 'bgagent-linear-oauth-*',
179+
}),
180+
],
181+
}));
182+
}
183+
152184
// AgentCore Browser session lifecycle + automation-stream connect.
153185
// The data-plane API doesn't support per-resource ARNs (sessions
154186
// are ephemeral), so wildcards are required — annotated with a

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

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
2121
import { captureScreenshot } from './shared/agentcore-browser';
2222
import { resolveGitHubToken } from './shared/context-hydration';
2323
import { upsertTaskComment } from './shared/github-comment';
24+
import { postIssueComment } from './shared/linear-feedback';
25+
import { extractLinearIdentifier, findLinearIssueByIdentifier } from './shared/linear-issue-lookup';
2426
import { logger } from './shared/logger';
2527

2628
const s3 = new S3Client({});
@@ -32,6 +34,11 @@ const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!;
3234
// behalf.
3335
const SCREENSHOT_PUBLIC_HOST = process.env.SCREENSHOT_PUBLIC_HOST!;
3436
const GITHUB_TOKEN_SECRET_ARN = process.env.GITHUB_TOKEN_SECRET_ARN!;
37+
// Optional — when set, the processor also tries to post the
38+
// screenshot comment onto a linked Linear issue. Resolved from the
39+
// GitHub PR title/body via a Linear-identifier regex (e.g. `ABCA-42`),
40+
// then looked up across all active workspaces in the registry.
41+
const LINEAR_WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME;
3542

3643
interface GitHubDeploymentStatusPayload {
3744
readonly action?: string;
@@ -122,8 +129,8 @@ export async function handler(event: ProcessorEvent): Promise<void> {
122129
return;
123130
}
124131

125-
const prNumber = await findPullRequestForSha(repo, sha, token);
126-
if (!prNumber) {
132+
const pr = await findPullRequestForSha(repo, sha, token);
133+
if (!pr) {
127134
logger.info('No open PR found for SHA — skipping screenshot post', { repo, sha });
128135
return;
129136
}
@@ -169,7 +176,7 @@ export async function handler(event: ProcessorEvent): Promise<void> {
169176
try {
170177
const result = await upsertTaskComment({
171178
repo,
172-
issueOrPrNumber: prNumber,
179+
issueOrPrNumber: pr.number,
173180
body: commentBody,
174181
token,
175182
// Always POST fresh — a single PR can have multiple preview screenshots
@@ -179,32 +186,83 @@ export async function handler(event: ProcessorEvent): Promise<void> {
179186
});
180187
logger.info('Posted screenshot comment to PR', {
181188
repo,
182-
pr_number: prNumber,
189+
pr_number: pr.number,
183190
comment_id: result.commentId,
184191
public_url: publicUrl,
185192
});
186193
} catch (err) {
187194
logger.warn('Failed to post screenshot PR comment (non-fatal)', {
188195
repo,
189-
pr_number: prNumber,
196+
pr_number: pr.number,
190197
error: err instanceof Error ? err.message : String(err),
191198
});
192199
}
200+
201+
// Best-effort Linear comment. The GitHub PR comment above is the
202+
// load-bearing artifact; the Linear comment is bonus surface for
203+
// reviewers who live in Linear. Only fires when the registry table
204+
// is configured AND the PR title/body carries a Linear identifier.
205+
if (LINEAR_WORKSPACE_REGISTRY_TABLE) {
206+
const identifier = extractLinearIdentifier(pr.title) ?? extractLinearIdentifier(pr.body);
207+
if (identifier) {
208+
const linearIssue = await findLinearIssueByIdentifier(identifier, LINEAR_WORKSPACE_REGISTRY_TABLE);
209+
if (linearIssue) {
210+
const ok = await postIssueComment(
211+
{
212+
linearWorkspaceId: linearIssue.linearWorkspaceId,
213+
registryTableName: LINEAR_WORKSPACE_REGISTRY_TABLE,
214+
},
215+
linearIssue.issueId,
216+
renderLinearCommentBody(publicUrl, previewUrl),
217+
);
218+
if (ok) {
219+
logger.info('Posted screenshot comment to Linear issue', {
220+
identifier,
221+
linear_issue_id: linearIssue.issueId,
222+
workspace_slug: linearIssue.workspaceSlug,
223+
});
224+
} else {
225+
logger.warn('Failed to post screenshot Linear comment (non-fatal)', {
226+
identifier,
227+
linear_issue_id: linearIssue.issueId,
228+
});
229+
}
230+
} else {
231+
logger.info('Linear identifier did not resolve to an issue — skipping Linear post', {
232+
identifier,
233+
repo,
234+
pr_number: pr.number,
235+
});
236+
}
237+
}
238+
}
239+
}
240+
241+
/**
242+
* Open PR shape we extract from the GitHub commit-pulls API. Title +
243+
* body are used downstream by the Linear issue lookup; the others go
244+
* into log lines for debugging.
245+
*/
246+
interface OpenPr {
247+
readonly number: number;
248+
readonly title: string;
249+
readonly body: string;
193250
}
194251

195252
/**
196253
* Look up an open PR associated with `sha`. Uses the
197254
* "List pull requests associated with a commit" GitHub API
198255
* (https://docs.github.com/rest/commits/commits#list-pull-requests-associated-with-a-commit).
199256
*
200-
* Returns the first OPEN PR's number, or null if none. Closed/merged
201-
* PRs are filtered out — v1 only screenshots active reviews.
257+
* Returns the first OPEN PR (with title/body), or null if none.
258+
* Closed/merged PRs are filtered out — v1 only screenshots active
259+
* reviews.
202260
*/
203261
async function findPullRequestForSha(
204262
repo: string,
205263
sha: string,
206264
token: string,
207-
): Promise<number | null> {
265+
): Promise<OpenPr | null> {
208266
const url = `https://api.github.com/repos/${repo}/commits/${sha}/pulls`;
209267
let res: Response;
210268
try {
@@ -234,9 +292,19 @@ async function findPullRequestForSha(
234292
return null;
235293
}
236294

237-
const pulls = (await res.json()) as Array<{ number?: number; state?: string }>;
295+
const pulls = (await res.json()) as Array<{
296+
number?: number;
297+
state?: string;
298+
title?: string;
299+
body?: string | null;
300+
}>;
238301
const open = pulls.find((p) => p.state === 'open' && typeof p.number === 'number');
239-
return open?.number ?? null;
302+
if (!open) return null;
303+
return {
304+
number: open.number!,
305+
title: open.title ?? '',
306+
body: open.body ?? '',
307+
};
240308
}
241309

242310
/** Build the S3 key for a screenshot. */
@@ -256,3 +324,21 @@ function renderCommentBody(publicUrl: string, previewUrl: string): string {
256324
`_From [${previewUrl}](${previewUrl}) — captured automatically by ABCA after the deploy finished._`,
257325
].join('\n');
258326
}
327+
328+
/**
329+
* Linear comment body. Linear's markdown renders image embeds the
330+
* same way GitHub does, but Linear collapses linked-image syntax —
331+
* use the simpler `![alt](url)` form so it renders inline rather than
332+
* as a clickable link with a tiny preview.
333+
*/
334+
function renderLinearCommentBody(publicUrl: string, previewUrl: string): string {
335+
return [
336+
'🖼️ **Preview screenshot**',
337+
'',
338+
`![preview](${publicUrl})`,
339+
'',
340+
`Live preview: [${previewUrl}](${previewUrl})`,
341+
'',
342+
'_Captured automatically by ABCA after the Vercel preview deploy finished._',
343+
].join('\n');
344+
}

0 commit comments

Comments
 (0)