diff --git a/cdk/src/handlers/shared/linear-issue-lookup.ts b/cdk/src/handlers/shared/linear-issue-lookup.ts index 4ce4a6bd..9b37c749 100644 --- a/cdk/src/handlers/shared/linear-issue-lookup.ts +++ b/cdk/src/handlers/shared/linear-issue-lookup.ts @@ -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 @@ -90,7 +93,11 @@ export async function findLinearIssueByIdentifier( identifier: string, registryTableName: string, ): Promise { - 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, @@ -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', { @@ -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; diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 76e5b99f..7c271ca0 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -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: { @@ -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 @@ -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: { @@ -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- @@ -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 { + 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 diff --git a/cli/test/commands/linear.test.ts b/cli/test/commands/linear.test.ts index 9d61df1b..610ac3f2 100644 --- a/cli/test/commands/linear.test.ts +++ b/cli/test/commands/linear.test.ts @@ -24,6 +24,7 @@ import { generateInviteCode, INVITE_CODE_ALPHABET, isWebhookSecretConfigured, + queryLinearTeamKeys, renderLinearAppTemplate, } from '../../src/commands/linear'; import * as config from '../../src/config'; @@ -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([]); + }); +});