Skip to content

Commit 059450e

Browse files
committed
feat(linear): prefix-route multi-workspace issue lookup by team key
Closes #96. The screenshot processor's findLinearIssueByIdentifier scanned every active workspace until one returned a hit. Cheap for 1-2 workspaces, but wasteful for stacks onboarding several Linear workspaces. Cache each workspace's team keys (e.g. ['ABCA', 'PLAT']) on the registry row at install time. The lookup now prefix-matches the identifier's team key (ABCA-42 -> ABCA) and queries that workspace first; iterates the rest only on miss or when team_keys is absent (legacy rows / stale cache). - linear-issue-lookup.ts: split into prefix-match + fallback-iterate - cli/commands/linear.ts: queryLinearTeamKeys helper, called from setup and add-workspace; team_keys persisted on registry row - DynamoDB schemaless — no CDK schema change. Legacy rows back-fill on next 'bgagent linear setup' / 'add-workspace' re-run.
1 parent e791e62 commit 059450e

2 files changed

Lines changed: 94 additions & 10 deletions

File tree

cdk/src/handlers/shared/linear-issue-lookup.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,17 @@ query IssueByIdentifier($identifier: String!) {
7373
`.trim();
7474

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

125+
// Identifier prefix is the part before the first dash (`ABCA-42` → `ABCA`).
126+
// Compare uppercase since Linear team keys are upper-case but inbound text
127+
// (PR titles, branch names) is mixed-case.
128+
const teamKey = identifier.split('-', 1)[0]?.toUpperCase();
129+
const prefixMatch = teamKey
130+
? active.find((ws) => ws.team_keys?.some((k) => k.toUpperCase() === teamKey))
131+
: undefined;
132+
133+
// Try the prefix-matched workspace first.
134+
if (prefixMatch) {
135+
const resolved = await resolveLinearOauthToken(prefixMatch.linear_workspace_id, registryTableName);
136+
if (resolved) {
137+
const found = await queryIssueByIdentifier(resolved.accessToken, identifier);
138+
if (found) {
139+
return {
140+
issueId: found,
141+
linearWorkspaceId: prefixMatch.linear_workspace_id,
142+
workspaceSlug: prefixMatch.workspace_slug,
143+
};
144+
}
145+
}
146+
}
147+
148+
// Fallback: iterate workspaces NOT already tried via prefix-match.
149+
// Covers legacy rows without `team_keys` and post-registration team adds.
117150
for (const ws of active) {
151+
if (prefixMatch && ws.linear_workspace_id === prefixMatch.linear_workspace_id) continue;
118152
const resolved = await resolveLinearOauthToken(ws.linear_workspace_id, registryTableName);
119153
if (!resolved) continue;
120154

cli/src/commands/linear.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,10 @@ export function makeLinearCommand(): Command {
623623
// ─── Step 5: Persist registry + user-mapping rows ─────────────
624624
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region }));
625625

626+
// Best-effort: fetch team keys so the screenshot processor can
627+
// prefix-route Linear issue lookups (e.g. ABCA-42 → workspace
628+
// owning ABCA) instead of scanning every active workspace.
629+
const teamKeys = await queryLinearTeamKeys(`Bearer ${tokenResponse.access_token}`);
626630
await ddb.send(new PutCommand({
627631
TableName: workspaceRegistryTable!,
628632
Item: {
@@ -633,9 +637,14 @@ export function makeLinearCommand(): Command {
633637
installed_at: now,
634638
updated_at: now,
635639
status: 'active',
640+
...(teamKeys.length > 0 ? { team_keys: teamKeys } : {}),
636641
},
637642
}));
638-
console.log(' ✓ Recorded workspace in registry');
643+
console.log(
644+
teamKeys.length > 0
645+
? ` ✓ Recorded workspace in registry (team keys: ${teamKeys.join(', ')})`
646+
: ' ✓ Recorded workspace in registry',
647+
);
639648

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

977986
// ─── Persist registry + user-mapping rows ──────────────────────
987+
// Fetch team keys for prefix-routing (see same call in `setup`).
988+
const teamKeys = await queryLinearTeamKeys(`Bearer ${tokenResponse.access_token}`);
978989
await ddb.send(new PutCommand({
979990
TableName: workspaceRegistryTable!,
980991
Item: {
@@ -985,9 +996,14 @@ export function makeLinearCommand(): Command {
985996
installed_at: now,
986997
updated_at: now,
987998
status: 'active',
999+
...(teamKeys.length > 0 ? { team_keys: teamKeys } : {}),
9881000
},
9891001
}));
990-
console.log(' ✓ Recorded workspace in registry');
1002+
console.log(
1003+
teamKeys.length > 0
1004+
? ` ✓ Recorded workspace in registry (team keys: ${teamKeys.join(', ')})`
1005+
: ' ✓ Recorded workspace in registry',
1006+
);
9911007

9921008
// No auto-link — see the same comment in `setup` above. With
9931009
// actor=app, Linear's `viewer` returns the bot user; auto-
@@ -1634,6 +1650,40 @@ interface LinearWorkspaceMember {
16341650
readonly email?: string;
16351651
}
16361652

1653+
/**
1654+
* Query the workspace's team keys (e.g. `["ABCA", "PLAT"]`). Persisted on
1655+
* the registry row so the screenshot processor can prefix-route Linear
1656+
* issue lookups to the owning workspace instead of scanning every
1657+
* workspace's tokens. Returns an empty array on failure — callers persist
1658+
* what they got and the lookup falls back to scanning if `team_keys` is
1659+
* absent or stale.
1660+
*/
1661+
async function queryLinearTeamKeys(authorizationHeader: string): Promise<string[]> {
1662+
try {
1663+
const res = await fetch('https://api.linear.app/graphql', {
1664+
method: 'POST',
1665+
headers: {
1666+
'Content-Type': 'application/json',
1667+
'Authorization': authorizationHeader,
1668+
},
1669+
// first:100 caps at 100 teams. Workspaces with more are rare for
1670+
// ABCA's target use case; pagination is a v1.x followup.
1671+
body: JSON.stringify({
1672+
query: '{ teams(first: 100) { nodes { key } } }',
1673+
}),
1674+
});
1675+
if (!res.ok) return [];
1676+
const body = await res.json() as { data?: { teams?: { nodes?: Array<{ key?: string }> } } };
1677+
const keys = (body.data?.teams?.nodes ?? [])
1678+
.map((t) => t.key)
1679+
.filter((k): k is string => typeof k === 'string' && k.length > 0)
1680+
.map((k) => k.toUpperCase());
1681+
return Array.from(new Set(keys)).sort();
1682+
} catch {
1683+
return [];
1684+
}
1685+
}
1686+
16371687
/**
16381688
* Query the workspace's human members. Used by the inline self-link picker
16391689
* in `setup` / `add-workspace` — surfaces the list of Linear users the

0 commit comments

Comments
 (0)