Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2b57211
feat(screenshot): preview-deploy screenshot pipeline (no stack wiring…
isadeks May 20, 2026
ca5ab14
feat(screenshot): GitHubScreenshotIntegration construct + stack wiring
isadeks May 20, 2026
8138e86
fix(screenshot): suppress AwsSolutions-S2 on the public-read screensh…
isadeks May 20, 2026
235710e
fix(screenshot): private S3 bucket + CloudFront distribution
isadeks May 20, 2026
36e8d14
fix(waf): exempt /v1/github/webhook from CRS like /v1/linear/webhook
isadeks May 21, 2026
bb5e5d1
fix(screenshot): read environment_url from deployment_status, not dep…
isadeks May 21, 2026
8b7adf4
fix(agentcore-browser): use ws package for SigV4-signed WebSocket han…
isadeks May 21, 2026
043cb84
fix(agentcore-browser): SigV4-presign WSS URL instead of signing headers
isadeks May 21, 2026
a2466cb
fix(iam): grant bedrock-agentcore:* to the screenshot processor
isadeks May 21, 2026
7bd6412
feat(screenshot): also post screenshot comment to linked Linear issue
isadeks May 21, 2026
e7d3a19
fix(screenshot): retry PR lookup to handle deploy-before-PR race
isadeks May 21, 2026
b81eee6
fix(linear): silent label gate + default to 'abca' to stop unlabeled-…
May 21, 2026
bce3aa6
docs(screenshots): add the screenshot pipeline guide
isadeks May 21, 2026
62829a0
feat(github): bgagent github webhook-info + set-webhook-secret
isadeks May 27, 2026
734c124
docs/code(screenshots): de-Vercel-ize the screenshot pipeline
isadeks May 27, 2026
1ce013d
docs(screenshots): drop redundant Step 3 + condescending hardening pr…
isadeks May 27, 2026
99e2b06
docs(screenshots): drop 'followup' framing — describe gaps as current…
isadeks May 27, 2026
a444266
docs(screenshots): de-Linear-ize — Linear is opt-in, not required
isadeks May 27, 2026
6e57515
feat(screenshot): hide URL behind 'preview link' label in comments
isadeks May 28, 2026
7d994b8
docs(screenshots): add USER_GUIDE / COST_MODEL / ROADMAP coverage
isadeks Jun 1, 2026
f9824f4
docs(linear): clarify teammate-onboarding handshake
isadeks Jun 2, 2026
d4c3aa0
fix(github-cli): de-Vercel-ize webhook-info / set-webhook-secret strings
isadeks Jun 2, 2026
dac4e31
fix(github-cli): replace template literal with single quotes (eslint …
isadeks Jun 2, 2026
8c8b7e3
Merge branch 'main' into feat/240-agentcore-screenshots
krokoko Jun 2, 2026
e791e62
fix(screenshot): krokoko PR-241 review — scope IAM + cosmetic Vercel …
isadeks Jun 4, 2026
059450e
feat(linear): prefix-route multi-workspace issue lookup by team key
isadeks Jun 5, 2026
5d64b0d
Merge branch 'main' into feat/96-linear-prefix-routing
isadeks Jun 5, 2026
98f4b51
test(linear): cover queryLinearTeamKeys to clear coverage gate
isadeks Jun 8, 2026
e092a63
chore(cli): apply eslint formatting to queryLinearTeamKeys tests
isadeks Jun 8, 2026
36fe8e5
Merge remote-tracking branch 'upstream/main' into feat/96-linear-pref…
isadeks Jun 8, 2026
36e118f
Merge branch 'main' into feat/96-linear-prefix-routing
isadeks Jun 10, 2026
74d33ab
Merge branch 'main' into feat/96-linear-prefix-routing
isadeks Jun 10, 2026
b38406f
Merge remote-tracking branch 'upstream/main' into feat/96-linear-pref…
Jun 10, 2026
d884d1d
Merge branch 'main' into feat/96-linear-prefix-routing
isadeks Jun 10, 2026
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
50 changes: 42 additions & 8 deletions cdk/src/handlers/shared/linear-issue-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,17 @@ query IssueByIdentifier($identifier: String!) {
`.trim();

/**
* Look up a Linear issue by identifier (e.g. `ABCA-42`) by iterating
* over every active workspace in the registry until one returns a
* match. Returns the first hit.
* Look up a Linear issue by identifier (e.g. `ABCA-42`).
*
* For v1 this scan is cheap — typical deployments have 1-2 workspaces.
* If a stack ever onboards many workspaces sharing identifier prefixes,
* a followup can store team_key prefixes on the registry row and route
* directly. Until then, linear-time iteration is fine.
* Routing strategy:
* 1. Scan active workspaces (one round-trip — typical stacks have 1–2).
* 2. If any row's `team_keys` contains the identifier's team key (`ABCA`),
* query that workspace directly and return on hit.
* 3. Otherwise fall back to iterating every active workspace until one
* returns a match. This handles legacy rows missing `team_keys` (the
* column was added in #96 and back-fills only on next `setup` /
* `add-workspace` re-run) and the rare case where a team was added in
* Linear after the workspace was registered.
*
* @param identifier `ABCA-42`-style Linear issue identifier
* @param registryTableName name of LinearWorkspaceRegistryTable
Expand All @@ -90,7 +93,11 @@ export async function findLinearIssueByIdentifier(
identifier: string,
registryTableName: string,
): Promise<LinearIssueLocation | null> {
let active: Array<{ linear_workspace_id: string; workspace_slug: string }> = [];
let active: Array<{
linear_workspace_id: string;
workspace_slug: string;
team_keys: string[] | null;
}> = [];
try {
const scanResp = await ddb.send(new ScanCommand({
TableName: registryTableName,
Expand All @@ -101,6 +108,7 @@ export async function findLinearIssueByIdentifier(
active = (scanResp.Items ?? []).map((item) => ({
linear_workspace_id: item.linear_workspace_id as string,
workspace_slug: item.workspace_slug as string,
team_keys: Array.isArray(item.team_keys) ? (item.team_keys as string[]) : null,
}));
} catch (err) {
logger.warn('Linear issue lookup: failed to scan workspace registry', {
Expand All @@ -114,7 +122,33 @@ export async function findLinearIssueByIdentifier(
return null;
}

// Identifier prefix is the part before the first dash (`ABCA-42` → `ABCA`).
// Compare uppercase since Linear team keys are upper-case but inbound text
// (PR titles, branch names) is mixed-case.
const teamKey = identifier.split('-', 1)[0]?.toUpperCase();
const prefixMatch = teamKey
? active.find((ws) => ws.team_keys?.some((k) => k.toUpperCase() === teamKey))
: undefined;

// Try the prefix-matched workspace first.
if (prefixMatch) {
const resolved = await resolveLinearOauthToken(prefixMatch.linear_workspace_id, registryTableName);
if (resolved) {
const found = await queryIssueByIdentifier(resolved.accessToken, identifier);
if (found) {
return {
issueId: found,
linearWorkspaceId: prefixMatch.linear_workspace_id,
workspaceSlug: prefixMatch.workspace_slug,
};
}
}
}

// Fallback: iterate workspaces NOT already tried via prefix-match.
// Covers legacy rows without `team_keys` and post-registration team adds.
for (const ws of active) {
if (prefixMatch && ws.linear_workspace_id === prefixMatch.linear_workspace_id) continue;
const resolved = await resolveLinearOauthToken(ws.linear_workspace_id, registryTableName);
if (!resolved) continue;

Expand Down
54 changes: 52 additions & 2 deletions cli/src/commands/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,10 @@ export function makeLinearCommand(): Command {
// ─── Step 5: Persist registry + user-mapping rows ─────────────
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region }));

// Best-effort: fetch team keys so the screenshot processor can
// prefix-route Linear issue lookups (e.g. ABCA-42 → workspace
// owning ABCA) instead of scanning every active workspace.
const teamKeys = await queryLinearTeamKeys(`Bearer ${tokenResponse.access_token}`);
await ddb.send(new PutCommand({
TableName: workspaceRegistryTable!,
Item: {
Expand All @@ -637,9 +641,14 @@ export function makeLinearCommand(): Command {
installed_at: now,
updated_at: now,
status: 'active',
...(teamKeys.length > 0 ? { team_keys: teamKeys } : {}),
},
}));
console.log(' ✓ Recorded workspace in registry');
console.log(
teamKeys.length > 0
? ` ✓ Recorded workspace in registry (team keys: ${teamKeys.join(', ')})`
: ' ✓ Recorded workspace in registry',
);

// We deliberately do NOT auto-link a user-mapping row here.
// With actor=app, Linear's `viewer` query returns the OAuth
Expand Down Expand Up @@ -979,6 +988,8 @@ export function makeLinearCommand(): Command {
console.log(` ✓ (${secretName})`);

// ─── Persist registry + user-mapping rows ──────────────────────
// Fetch team keys for prefix-routing (see same call in `setup`).
const teamKeys = await queryLinearTeamKeys(`Bearer ${tokenResponse.access_token}`);
await ddb.send(new PutCommand({
TableName: workspaceRegistryTable!,
Item: {
Expand All @@ -989,9 +1000,14 @@ export function makeLinearCommand(): Command {
installed_at: now,
updated_at: now,
status: 'active',
...(teamKeys.length > 0 ? { team_keys: teamKeys } : {}),
},
}));
console.log(' ✓ Recorded workspace in registry');
console.log(
teamKeys.length > 0
? ` ✓ Recorded workspace in registry (team keys: ${teamKeys.join(', ')})`
: ' ✓ Recorded workspace in registry',
);

// No auto-link — see the same comment in `setup` above. With
// actor=app, Linear's `viewer` returns the bot user; auto-
Expand Down Expand Up @@ -1638,6 +1654,40 @@ interface LinearWorkspaceMember {
readonly email?: string;
}

/**
* Query the workspace's team keys (e.g. `["ABCA", "PLAT"]`). Persisted on
* the registry row so the screenshot processor can prefix-route Linear
* issue lookups to the owning workspace instead of scanning every
* workspace's tokens. Returns an empty array on failure — callers persist
* what they got and the lookup falls back to scanning if `team_keys` is
* absent or stale.
*/
export async function queryLinearTeamKeys(authorizationHeader: string): Promise<string[]> {
try {
const res = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': authorizationHeader,
},
// first:100 caps at 100 teams. Workspaces with more are rare for
// ABCA's target use case; pagination is a v1.x followup.
body: JSON.stringify({
query: '{ teams(first: 100) { nodes { key } } }',
}),
});
if (!res.ok) return [];
const body = await res.json() as { data?: { teams?: { nodes?: Array<{ key?: string }> } } };
const keys = (body.data?.teams?.nodes ?? [])
.map((t) => t.key)
.filter((k): k is string => typeof k === 'string' && k.length > 0)
.map((k) => k.toUpperCase());
return Array.from(new Set(keys)).sort();
} catch {
return [];
}
}

/**
* Query the workspace's human members. Used by the inline self-link picker
* in `setup` / `add-workspace` — surfaces the list of Linear users the
Expand Down
88 changes: 88 additions & 0 deletions cli/test/commands/linear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
generateInviteCode,
INVITE_CODE_ALPHABET,
isWebhookSecretConfigured,
queryLinearTeamKeys,
renderLinearAppTemplate,
} from '../../src/commands/linear';
import * as config from '../../src/config';
Expand Down Expand Up @@ -372,3 +373,90 @@ describe('generateInviteCode', () => {
expect(seen.size).toBe(200);
});
});

describe('queryLinearTeamKeys', () => {
// Returned keys are persisted on the registry row at install time and
// drive prefix-routing inside the screenshot processor — see #96. The
// helper intentionally swallows every failure path (returns []) so a
// transient Linear outage during `setup` doesn't abort the OAuth
// dance. Coverage verifies (a) the happy-path normalization and (b)
// every failure mode collapses to [].
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});

test('uppercases, dedupes, and sorts the team keys returned by Linear', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: {
teams: {
nodes: [
{ key: 'plat' },
{ key: 'ABCA' },
{ key: 'PLAT' }, // dedup case-insensitive
{ key: 'web' },
],
},
},
}),
}) as unknown as typeof fetch;

const keys = await queryLinearTeamKeys('Bearer tok');

expect(keys).toEqual(['ABCA', 'PLAT', 'WEB']);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.linear.app/graphql',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer tok' }),
}),
);
});

test('drops empty / non-string key entries', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: {
teams: {
nodes: [
{ key: 'ABCA' },
{ key: '' },
{ key: undefined },
{}, // missing key entirely
],
},
},
}),
}) as unknown as typeof fetch;

expect(await queryLinearTeamKeys('Bearer tok')).toEqual(['ABCA']);
});

test('returns [] when Linear responds non-2xx', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({}),
}) as unknown as typeof fetch;

expect(await queryLinearTeamKeys('Bearer tok')).toEqual([]);
});

test('returns [] when fetch itself throws (network failure)', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('ECONNRESET')) as unknown as typeof fetch;

expect(await queryLinearTeamKeys('Bearer tok')).toEqual([]);
});

test('returns [] when GraphQL response shape is missing teams.nodes', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: {} }),
}) as unknown as typeof fetch;

expect(await queryLinearTeamKeys('Bearer tok')).toEqual([]);
});
});
Loading