Skip to content

Commit db52d5f

Browse files
authored
feat(cli): add Jira invite-user command (#559)
* feat(cli): add jira invite-user * refactor(cli): address PR review for jira invite-user - Extract generateInviteCode + INVITE_CODE_ALPHABET into shared cli/src/invite-code.ts; import from both jira.ts and linear.ts (drops the require('crypto') + eslint-disable in the Jira copy). - Move parseStoredJiraOauthToken to jira-oauth.ts alongside the other OAuth helpers; jira.ts stays focused on command wiring. - Warn (non-fatal) in `jira invite-user` when the resolved Jira identity is already actively linked, so the admin knows the code will re-link. - Document `bgagent jira setup/map/invite-user/link` in cli/README.md. - Add tests: already-linked warning, inactive/app account rejection, malformed OAuth secret JSON, missing oauth_secret_arn, and missing stack outputs — closing the branches Codecov flagged. * refactor(cli): address second-pass PR review nits for jira invite-user theagenticguy's review (against pre-refactor 08a5d81) raised two nits not covered by the earlier fixes: - Remove modulo bias in generateInviteCode via rejection sampling: bytes >= the largest alphabet multiple under 256 are discarded so the char distribution is uniform (previously indices 0-7 were favored 9/256). - Guard the pending-row PutCommand with ConditionExpression: attribute_not_exists(jira_identity) so a code collision fails loudly with an actionable error instead of silently overwriting a still-valid invite. Tests: new cli/test/invite-code.test.ts (100% cover incl. rejection + top-up branches and a distribution smoke check); jira.test.ts gains the conditional-put assertion and a ConditionalCheckFailedException case. The review's third nit (inline require('crypto') + eslint-disable) was already resolved by the prior extraction to cli/src/invite-code.ts. * chore: re-trigger CI
1 parent b16f062 commit db52d5f

10 files changed

Lines changed: 1003 additions & 69 deletions

File tree

cli/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,25 @@ With no flags, writes to the platform default `GitHubTokenSecretArn` stack outpu
302302

303303
Configure the preview-deploy screenshot pipeline webhook. See [Deploy preview screenshots guide](../docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md).
304304

305+
### `bgagent jira setup` / `map` / `invite-user` / `link`
306+
307+
Manage the Jira Cloud integration. `setup` authorizes a tenant via OAuth (3LO) and stores the token in Secrets Manager; `map` routes a Jira project to a GitHub repo; the two-step `invite-user``link` handshake links a teammate's Jira identity to their platform user. See the [Jira setup guide](../docs/guides/JIRA_SETUP_GUIDE.md) for the full walkthrough.
308+
309+
```
310+
bgagent jira setup \
311+
--stack-name backgroundagent-dev
312+
313+
bgagent jira map <cloud-id> <PROJECT-KEY> --repo owner/repo
314+
315+
bgagent jira invite-user <cloud-id> <account-id-or-email> \
316+
--region <region> AWS region (defaults to configured region) \
317+
--stack-name <name> CloudFormation stack name (default: backgroundagent-dev)
318+
319+
bgagent jira link <code>
320+
```
321+
322+
`invite-user` resolves the teammate's Jira identity through the tenant OAuth token, then writes a `pending#<code>` row (24h TTL) and prints the `bgagent jira link <code>` the teammate runs from their own machine. The teammate previews the Jira identity before confirming, so a wrong pick can be aborted rather than misattributed. If the identity is already linked, the command warns but still issues the code.
323+
305324
### `bgagent admin invite-user` / `list-users` / `delete-user` / `reset-password`
306325

307326
Manage Cognito users with operator AWS credentials (`cognito-idp:Admin*` on the deployment user pool). Works **before** `bgagent configure` when `--stack-name` is passed (reads `UserPoolId` from CloudFormation).

cli/src/commands/jira.ts

Lines changed: 295 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,23 @@ import {
2828
ResourceExistsException,
2929
SecretsManagerClient,
3030
} from '@aws-sdk/client-secrets-manager';
31-
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
31+
import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
3232
import { Command } from 'commander';
3333
import { ApiClient } from '../api-client';
3434
import { loadConfig, loadCredentials } from '../config';
3535
import { CliError } from '../errors';
3636
import { formatJson } from '../format';
37+
import { generateInviteCode } from '../invite-code';
3738
import {
3839
buildAuthorizationUrl,
3940
computeExpiresAt,
4041
exchangeAuthorizationCode,
4142
fetchAccessibleResources,
4243
generatePkce,
44+
isAccessTokenExpiring,
4345
jiraOauthSecretName,
46+
parseStoredJiraOauthToken,
47+
refreshAccessToken,
4448
StoredJiraOauthToken,
4549
} from '../jira-oauth';
4650
import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server';
@@ -258,6 +262,137 @@ function isWebhookSecretPlaceholder(value: string): boolean {
258262
}
259263
}
260264

265+
interface JiraUserSearchResult {
266+
readonly accountId?: string;
267+
readonly displayName?: string;
268+
readonly emailAddress?: string;
269+
readonly active?: boolean;
270+
readonly accountType?: string;
271+
}
272+
273+
function isJiraUser(value: unknown): value is JiraUserSearchResult {
274+
if (typeof value !== 'object' || value === null) return false;
275+
const obj = value as Record<string, unknown>;
276+
return typeof obj.accountId === 'string' && obj.accountId.length > 0;
277+
}
278+
279+
function isLinkableJiraUser(user: JiraUserSearchResult): boolean {
280+
return user.active !== false && user.accountType !== 'app';
281+
}
282+
283+
function formatJiraUserLabel(user: JiraUserSearchResult): string {
284+
const name = user.displayName || user.accountId || '(unknown)';
285+
return `${name}${user.emailAddress ? ` (${user.emailAddress})` : ''}`;
286+
}
287+
288+
async function parseJiraJson(response: Response, context: string): Promise<unknown> {
289+
try {
290+
return await response.json();
291+
} catch {
292+
throw new CliError(`Atlassian ${context} returned non-JSON: HTTP ${response.status}.`);
293+
}
294+
}
295+
296+
async function fetchJiraUserByAccountId(
297+
accessToken: string,
298+
cloudId: string,
299+
accountId: string,
300+
fetchImpl: typeof fetch,
301+
): Promise<JiraUserSearchResult | null> {
302+
const url = new URL(`https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3/user`);
303+
url.searchParams.set('accountId', accountId);
304+
const response = await fetchImpl(url.toString(), {
305+
method: 'GET',
306+
headers: {
307+
Authorization: `Bearer ${accessToken}`,
308+
Accept: 'application/json',
309+
},
310+
});
311+
if (response.status === 404) return null;
312+
if (!response.ok) {
313+
throw new CliError(
314+
`Atlassian Jira user lookup failed: HTTP ${response.status}. `
315+
+ 'The stored OAuth token may be expired/revoked or missing read:jira-user.',
316+
);
317+
}
318+
const parsed = await parseJiraJson(response, 'Jira user lookup');
319+
if (!isJiraUser(parsed)) {
320+
throw new CliError(`Atlassian Jira user lookup returned an unexpected shape: ${JSON.stringify(parsed).slice(0, 200)}`);
321+
}
322+
return parsed;
323+
}
324+
325+
async function searchJiraUsers(
326+
accessToken: string,
327+
cloudId: string,
328+
query: string,
329+
fetchImpl: typeof fetch,
330+
): Promise<JiraUserSearchResult[]> {
331+
const url = new URL(`https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3/user/search`);
332+
url.searchParams.set('query', query);
333+
url.searchParams.set('maxResults', '10');
334+
const response = await fetchImpl(url.toString(), {
335+
method: 'GET',
336+
headers: {
337+
Authorization: `Bearer ${accessToken}`,
338+
Accept: 'application/json',
339+
},
340+
});
341+
if (!response.ok) {
342+
throw new CliError(
343+
`Atlassian Jira user search failed: HTTP ${response.status}. `
344+
+ 'The stored OAuth token may be expired/revoked or missing read:jira-user.',
345+
);
346+
}
347+
const parsed = await parseJiraJson(response, 'Jira user search');
348+
if (!Array.isArray(parsed)) {
349+
throw new CliError(`Atlassian Jira user search returned an unexpected shape: ${JSON.stringify(parsed).slice(0, 200)}`);
350+
}
351+
return parsed.filter(isJiraUser);
352+
}
353+
354+
async function resolveJiraUser(
355+
accessToken: string,
356+
cloudId: string,
357+
accountIdOrEmail: string,
358+
fetchImpl: typeof fetch = fetch,
359+
): Promise<JiraUserSearchResult> {
360+
const query = accountIdOrEmail.trim();
361+
if (!query) {
362+
throw new CliError('Jira account id or email is required.');
363+
}
364+
365+
if (!query.includes('@')) {
366+
const direct = await fetchJiraUserByAccountId(accessToken, cloudId, query, fetchImpl);
367+
if (direct) {
368+
if (!isLinkableJiraUser(direct)) {
369+
throw new CliError(`Jira account '${query}' is inactive or is an app account.`);
370+
}
371+
return direct;
372+
}
373+
}
374+
375+
const users = (await searchJiraUsers(accessToken, cloudId, query, fetchImpl)).filter(isLinkableJiraUser);
376+
const exactAccountId = users.find((user) => user.accountId === query);
377+
if (exactAccountId) return exactAccountId;
378+
379+
if (query.includes('@')) {
380+
const lowerQuery = query.toLowerCase();
381+
const exactEmail = users.find((user) => (user.emailAddress ?? '').toLowerCase() === lowerQuery);
382+
if (exactEmail) return exactEmail;
383+
}
384+
385+
if (users.length === 1) return users[0];
386+
if (users.length === 0) {
387+
throw new CliError(`No active Jira user found for '${query}' in tenant '${cloudId}'.`);
388+
}
389+
390+
const candidates = users.map((user) => `- ${formatJiraUserLabel(user)} [${user.accountId}]`).join('\n');
391+
throw new CliError(
392+
`Jira user lookup for '${query}' returned multiple users. Re-run with the accountId for the intended user:\n${candidates}`,
393+
);
394+
}
395+
261396
function promptLine(label: string): Promise<string> {
262397
return new Promise((resolve) => {
263398
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -576,12 +711,169 @@ export function makeJiraCommand(): Command {
576711
console.log(' 1. Map a Jira project to a GitHub repo:');
577712
console.log(` bgagent jira map ${cloudId} <PROJECT-KEY> --repo owner/repo`);
578713
console.log(' 2. Link your Jira account so triggered tasks attribute to your platform user:');
579-
console.log(' (an admin runs `bgagent jira invite-user` to issue you a code; this command');
580-
console.log(' is not yet implemented — populate the user-mapping row manually for now.)');
714+
console.log(` bgagent jira invite-user ${cloudId} <account-id-or-email>`);
715+
console.log(' bgagent jira link <code>');
581716
console.log(` 3. Add the trigger label '${DEFAULT_LABEL_FILTER}' to a Jira issue in a mapped project.`);
582717
}),
583718
);
584719

720+
// ─── invite-user ──────────────────────────────────────────────────────────
721+
jira.addCommand(
722+
new Command('invite-user')
723+
.description('Generate a one-time code for a Jira teammate to redeem via `bgagent jira link <code>`')
724+
.argument('<cloud-id>', 'Atlassian tenant cloudId (UUID)')
725+
.argument('<account-id-or-email>', 'Jira accountId or email address for the teammate')
726+
.option('--region <region>', 'AWS region (defaults to configured region)')
727+
.option('--stack-name <name>', 'CloudFormation stack name', 'backgroundagent-dev')
728+
.action(async (cloudId: string, accountIdOrEmail: string, opts) => {
729+
const config = loadConfig();
730+
const region = opts.region || config.region;
731+
const stackName = opts.stackName;
732+
733+
const [workspaceRegistryTable, userMappingTable] = await Promise.all([
734+
getStackOutput(region, stackName, 'JiraWorkspaceRegistryTableName'),
735+
getStackOutput(region, stackName, 'JiraUserMappingTableName'),
736+
]);
737+
const missing: string[] = [];
738+
if (!workspaceRegistryTable) missing.push('JiraWorkspaceRegistryTableName');
739+
if (!userMappingTable) missing.push('JiraUserMappingTableName');
740+
if (missing.length > 0) {
741+
throw new CliError(
742+
`Stack '${stackName}' is missing outputs ${missing.join(', ')}. `
743+
+ 'Re-deploy with the JiraIntegration CDK changes (mise //cdk:deploy).',
744+
);
745+
}
746+
747+
const creds = loadCredentials();
748+
if (!creds?.id_token) {
749+
throw new CliError('Not authenticated — run `bgagent login` first.');
750+
}
751+
const callerCognitoSub = extractCognitoSub();
752+
753+
const sm = new SecretsManagerClient({ region });
754+
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region }));
755+
756+
const registry = await ddb.send(new GetCommand({
757+
TableName: workspaceRegistryTable!,
758+
Key: { jira_cloud_id: cloudId },
759+
}));
760+
const registryRow = registry.Item;
761+
if (!registryRow || registryRow.status !== 'active') {
762+
throw new CliError(
763+
`Jira tenant '${cloudId}' is not in the registry (or status != 'active'). `
764+
+ 'Run `bgagent jira setup` for that tenant first.',
765+
);
766+
}
767+
const oauthSecretArn = registryRow.oauth_secret_arn as string | undefined;
768+
if (!oauthSecretArn) {
769+
throw new CliError(`Jira tenant '${cloudId}' registry row is missing oauth_secret_arn. Re-run \`bgagent jira setup\`.`);
770+
}
771+
const siteUrl = (registryRow.site_url as string | undefined) ?? '';
772+
773+
const oauthSecret = await sm.send(new GetSecretValueCommand({ SecretId: oauthSecretArn }));
774+
let stored = parseStoredJiraOauthToken(oauthSecret.SecretString, oauthSecretArn);
775+
776+
console.log(`bgagent jira invite-user — tenant '${cloudId}'`);
777+
console.log(` region: ${region}`);
778+
if (siteUrl) {
779+
console.log(` site: ${siteUrl}`);
780+
}
781+
782+
if (isAccessTokenExpiring(stored.expires_at)) {
783+
process.stdout.write(' → Refreshing Jira OAuth token...');
784+
const refreshed = await refreshAccessToken({
785+
refreshToken: stored.refresh_token,
786+
clientId: stored.client_id,
787+
clientSecret: stored.client_secret,
788+
});
789+
if (!refreshed.refresh_token) {
790+
console.log(' ✗');
791+
throw new CliError('Atlassian refresh_token grant returned no refresh_token. Re-run `bgagent jira setup`.');
792+
}
793+
stored = {
794+
...stored,
795+
access_token: refreshed.access_token,
796+
refresh_token: refreshed.refresh_token,
797+
expires_at: computeExpiresAt(refreshed.expires_in),
798+
scope: refreshed.scope,
799+
updated_at: new Date().toISOString(),
800+
};
801+
await sm.send(new PutSecretValueCommand({
802+
SecretId: oauthSecretArn,
803+
SecretString: JSON.stringify(stored),
804+
}));
805+
console.log(' ✓');
806+
}
807+
808+
process.stdout.write(' → Resolving Jira user...');
809+
let picked: JiraUserSearchResult;
810+
try {
811+
picked = await resolveJiraUser(stored.access_token, cloudId, accountIdOrEmail);
812+
console.log(` ✓ (${formatJiraUserLabel(picked)})`);
813+
} catch (err) {
814+
console.log(' ✗');
815+
throw err;
816+
}
817+
818+
// Warn (don't block) if this Jira identity is already linked, so the
819+
// admin knows a fresh invite will re-link an existing teammate. The
820+
// active row key mirrors the jira-link handler: `<cloudId>#<accountId>`.
821+
const existing = await ddb.send(new GetCommand({
822+
TableName: userMappingTable!,
823+
Key: { jira_identity: `${cloudId}#${picked.accountId}` },
824+
}));
825+
if (existing.Item && existing.Item.status === 'active') {
826+
console.log();
827+
console.log(` ⚠ ${formatJiraUserLabel(picked)} is already linked in this tenant.`);
828+
console.log(' Redeeming this code will re-link them to whoever runs `bgagent jira link`.');
829+
}
830+
831+
const code = generateInviteCode();
832+
const ttl = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
833+
try {
834+
await ddb.send(new PutCommand({
835+
TableName: userMappingTable!,
836+
// Guard against clobbering an existing pending invite on the
837+
// (astronomically unlikely) chance the generated code collides.
838+
// Better to fail loudly and let the admin re-run than silently
839+
// overwrite a still-valid code.
840+
ConditionExpression: 'attribute_not_exists(jira_identity)',
841+
Item: {
842+
jira_identity: `pending#${code}`,
843+
status: 'pending',
844+
jira_cloud_id: cloudId,
845+
jira_site_url: siteUrl,
846+
jira_account_id: picked.accountId,
847+
jira_user_name: picked.displayName ?? '',
848+
jira_user_email: picked.emailAddress ?? '',
849+
invited_at: new Date().toISOString(),
850+
invited_by_platform_user_id: callerCognitoSub,
851+
ttl,
852+
},
853+
}));
854+
} catch (err) {
855+
if ((err as { name?: string }).name === 'ConditionalCheckFailedException') {
856+
throw new CliError(`Invite code '${code}' collided with an existing invite. Re-run the command to mint a fresh code.`);
857+
}
858+
throw err;
859+
}
860+
861+
console.log();
862+
console.log('✅ Invite created.');
863+
console.log();
864+
console.log(' Send this to the teammate (Slack/email/etc):');
865+
console.log();
866+
console.log(` bgagent jira link ${code}`);
867+
console.log();
868+
console.log(` Picked Jira user: ${formatJiraUserLabel(picked)}`);
869+
console.log(` Jira tenant: ${siteUrl || cloudId}`);
870+
console.log(` Code expires: ${new Date(ttl * 1000).toISOString()} (24h)`);
871+
console.log();
872+
console.log(' The teammate sees the Jira identity above and confirms before the');
873+
console.log(' mapping is written. If you picked the wrong user, the teammate aborts.');
874+
}),
875+
);
876+
585877
// ─── link ─────────────────────────────────────────────────────────────────
586878
jira.addCommand(
587879
new Command('link')

0 commit comments

Comments
 (0)