Skip to content

Commit 890ee00

Browse files
isadeksclaude
andcommitted
fix(cli): bgagent linear list-projects on the OAuth secret model
The command still pulled from the parked PAK secret (`LinearApiTokenSecretArn`), which we removed in Phase 2.0b. Symptom: `Could not find LinearApiTokenSecretArn in stack outputs.` Rewrite to scan Secrets Manager for `bgagent-linear-oauth-*` secrets and query each workspace's projects with its own OAuth token. Supports `--slug <slug>` to scope to one workspace; without it, queries every installed workspace and labels each project with its source. Also: switch to the `Bearer <token>` auth header and the `teams(first: 1) { nodes { name } }` shape (the old `team` field on Project no longer exists in Linear's GraphQL). Adds a `LINEAR_OAUTH_SECRET_PREFIX` const in `linear-oauth.ts` to keep the secret-name contract in one place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a852361 commit 890ee00

2 files changed

Lines changed: 94 additions & 43 deletions

File tree

cli/src/commands/linear.ts

Lines changed: 85 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2424
import {
2525
CreateSecretCommand,
2626
GetSecretValueCommand,
27+
ListSecretsCommand,
2728
PutSecretValueCommand,
2829
ResourceExistsException,
2930
SecretsManagerClient,
@@ -39,6 +40,7 @@ import {
3940
computeExpiresAt,
4041
exchangeAuthorizationCode,
4142
generatePkce,
43+
LINEAR_OAUTH_SECRET_PREFIX,
4244
linearOauthSecretName,
4345
StoredLinearOauthToken,
4446
} from '../linear-oauth';
@@ -617,67 +619,108 @@ export function makeLinearCommand(): Command {
617619

618620
linear.addCommand(
619621
new Command('list-projects')
620-
.description('List Linear projects visible to the stored API token (with full UUIDs)')
622+
.description('List Linear projects visible to the OAuth-installed workspace (with full UUIDs)')
621623
.option('--region <region>', 'AWS region (defaults to configured region)')
622-
.option('--stack-name <name>', 'CloudFormation stack name', 'backgroundagent-dev')
624+
.option('--slug <slug>', 'Linear workspace slug (urlKey). If omitted, queries every active workspace in the registry.')
623625
.option('--output <format>', 'Output format (text or json)', 'text')
624626
.action(async (opts) => {
625627
const config = loadConfig();
626628
const region = opts.region || config.region;
629+
const sm = new SecretsManagerClient({ region });
627630

628-
const apiTokenSecretArn = await getStackOutput(region, opts.stackName, 'LinearApiTokenSecretArn');
629-
if (!apiTokenSecretArn) {
630-
console.error('Could not find LinearApiTokenSecretArn in stack outputs. Deploy the stack first.');
631-
process.exit(1);
631+
// Resolve the set of workspace slugs to query. Either an
632+
// explicit `--slug` (one workspace) or every Linear workspace
633+
// we have an OAuth secret for (list every `bgagent-linear-oauth-*`).
634+
let slugs: string[];
635+
if (opts.slug) {
636+
slugs = [opts.slug];
637+
} else {
638+
const listed = await sm.send(new ListSecretsCommand({
639+
Filters: [{ Key: 'name', Values: [LINEAR_OAUTH_SECRET_PREFIX] }],
640+
}));
641+
slugs = (listed.SecretList ?? [])
642+
.map((s) => s.Name ?? '')
643+
.filter((n) => n.startsWith(LINEAR_OAUTH_SECRET_PREFIX))
644+
.map((n) => n.slice(LINEAR_OAUTH_SECRET_PREFIX.length));
645+
if (slugs.length === 0) {
646+
console.error('No Linear OAuth installs found. Run `bgagent linear setup <slug>` first.');
647+
process.exit(1);
648+
}
632649
}
633650

634-
const sm = new SecretsManagerClient({ region });
635-
const secret = await sm.send(new GetSecretValueCommand({ SecretId: apiTokenSecretArn }));
636-
const apiToken = secret.SecretString;
637-
if (!apiToken || apiToken === ' ') {
638-
console.error('Linear API token is not populated. Run `bgagent linear setup` first.');
639-
process.exit(1);
640-
}
651+
type ProjectRow = {
652+
slug: string;
653+
id: string;
654+
name: string;
655+
team?: string;
656+
};
657+
const rows: ProjectRow[] = [];
658+
659+
for (const slug of slugs) {
660+
const secretName = linearOauthSecretName(slug);
661+
let accessToken: string;
662+
try {
663+
const resp = await sm.send(new GetSecretValueCommand({ SecretId: secretName }));
664+
const stored = JSON.parse(resp.SecretString ?? '{}') as { access_token?: string };
665+
if (!stored.access_token) {
666+
console.error(`Secret ${secretName} is missing access_token; skipping.`);
667+
continue;
668+
}
669+
accessToken = stored.access_token;
670+
} catch (err) {
671+
console.error(`Failed to read ${secretName}: ${err instanceof Error ? err.message : String(err)}`);
672+
continue;
673+
}
641674

642-
let projects: Array<{ id: string; name: string; teams?: { nodes?: Array<{ id: string; name: string }> } }>;
643-
try {
644-
const res = await fetch('https://api.linear.app/graphql', {
645-
method: 'POST',
646-
headers: {
647-
'Content-Type': 'application/json',
648-
'Authorization': apiToken,
649-
},
650-
body: JSON.stringify({
651-
query: '{ projects { nodes { id name teams { nodes { id name } } } } }',
652-
}),
653-
});
654-
if (!res.ok) {
655-
throw new Error(`Linear API returned ${res.status}`);
675+
try {
676+
const res = await fetch('https://api.linear.app/graphql', {
677+
method: 'POST',
678+
headers: {
679+
'Content-Type': 'application/json',
680+
'Authorization': `Bearer ${accessToken}`,
681+
},
682+
body: JSON.stringify({
683+
query: '{ projects(first: 100) { nodes { id name teams(first: 1) { nodes { name } } } } }',
684+
}),
685+
});
686+
if (!res.ok) {
687+
console.error(`Linear API returned ${res.status} for workspace '${slug}'`);
688+
continue;
689+
}
690+
const body = await res.json() as {
691+
data?: { projects?: { nodes?: Array<{ id: string; name: string; teams?: { nodes?: Array<{ name: string }> } }> } };
692+
};
693+
for (const p of body.data?.projects?.nodes ?? []) {
694+
rows.push({
695+
slug,
696+
id: p.id,
697+
name: p.name,
698+
team: p.teams?.nodes?.[0]?.name,
699+
});
700+
}
701+
} catch (err) {
702+
console.error(`Failed to fetch projects for '${slug}': ${err instanceof Error ? err.message : String(err)}`);
703+
continue;
656704
}
657-
const body = await res.json() as { data?: { projects?: { nodes?: typeof projects } } };
658-
projects = body.data?.projects?.nodes ?? [];
659-
} catch (err) {
660-
console.error(`Failed to fetch Linear projects: ${err instanceof Error ? err.message : String(err)}`);
661-
process.exit(1);
662705
}
663706

664707
if (opts.output === 'json') {
665-
console.log(formatJson(projects));
708+
console.log(formatJson(rows));
666709
return;
667710
}
668711

669-
if (projects.length === 0) {
670-
console.log('No Linear projects visible to the stored API token.');
712+
if (rows.length === 0) {
713+
console.log('No Linear projects visible to any installed workspace.');
671714
return;
672715
}
673716

674-
console.log(`Found ${projects.length} Linear project(s):\n`);
675-
for (const p of projects) {
676-
const team = p.teams?.nodes?.[0];
677-
console.log(` ${p.name}`);
678-
console.log(` id: ${p.id}`);
679-
if (team) {
680-
console.log(` team: ${team.name} (${team.id})`);
717+
console.log(`Found ${rows.length} Linear project(s):\n`);
718+
for (const r of rows) {
719+
console.log(` ${r.name}`);
720+
console.log(` id: ${r.id}`);
721+
console.log(` workspace: ${r.slug}`);
722+
if (r.team) {
723+
console.log(` team: ${r.team}`);
681724
}
682725
console.log('');
683726
}

cli/src/linear-oauth.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,21 @@ export interface StoredLinearOauthToken {
8888
readonly installed_by_platform_user_id: string;
8989
}
9090

91+
/**
92+
* Common prefix for all per-workspace Linear OAuth secrets. The full
93+
* secret name is `${LINEAR_OAUTH_SECRET_PREFIX}<slug>`. Use this when
94+
* scanning Secrets Manager for every workspace install (e.g. the CLI's
95+
* `list-projects` command queries every workspace it can find).
96+
*/
97+
export const LINEAR_OAUTH_SECRET_PREFIX = 'bgagent-linear-oauth-';
98+
9199
/**
92100
* Build the secret name for a given Linear workspace slug. Matches the
93101
* naming convention encoded in the runtime's IAM policy resource pattern,
94102
* so changes here MUST be matched by the IAM resource pattern in CDK.
95103
*/
96104
export function linearOauthSecretName(workspaceSlug: string): string {
97-
return `bgagent-linear-oauth-${workspaceSlug}`;
105+
return `${LINEAR_OAUTH_SECRET_PREFIX}${workspaceSlug}`;
98106
}
99107

100108
/**

0 commit comments

Comments
 (0)