Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
319 changes: 316 additions & 3 deletions cli/src/commands/jira.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
ResourceExistsException,
SecretsManagerClient,
} from '@aws-sdk/client-secrets-manager';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
import { Command } from 'commander';
import { ApiClient } from '../api-client';
import { loadConfig, loadCredentials } from '../config';
Expand All @@ -40,7 +40,9 @@ import {
exchangeAuthorizationCode,
fetchAccessibleResources,
generatePkce,
isAccessTokenExpiring,
jiraOauthSecretName,
refreshAccessToken,
StoredJiraOauthToken,
} from '../jira-oauth';
import { awaitOauthCallback, CALLBACK_URL } from '../oauth-callback-server';
Expand All @@ -54,6 +56,9 @@ const DEFAULT_LABEL_FILTER = 'bgagent';
* accepts at creation time. */
const PROJECT_KEY_RE = /^[A-Z][A-Z0-9_]{1,99}$/;

/** Alphabet used by {@link generateInviteCode}. */
export const INVITE_CODE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';

/** Width of the ═ rule used to frame the setup banner. */
const BANNER_WIDTH = 72;

Expand Down Expand Up @@ -258,6 +263,182 @@ function isWebhookSecretPlaceholder(value: string): boolean {
}
}

export function generateInviteCode(): string {
const INVITE_CODE_RANDOM_BYTES = 8;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { randomBytes } = require('crypto') as typeof import('crypto');
const bytes = randomBytes(INVITE_CODE_RANDOM_BYTES);
let out = 'link-';
for (const b of bytes) {
out += INVITE_CODE_ALPHABET[b % INVITE_CODE_ALPHABET.length];
}
return out;
}

interface JiraUserSearchResult {
readonly accountId?: string;
readonly displayName?: string;
readonly emailAddress?: string;
readonly active?: boolean;
readonly accountType?: string;
}

function parseStoredJiraOauthToken(secretString: string | undefined, secretId: string): StoredJiraOauthToken {
if (!secretString) {
throw new CliError(`OAuth secret '${secretId}' has no SecretString. Re-run \`bgagent jira setup\`.`);
}
let parsed: unknown;
try {
parsed = JSON.parse(secretString);
} catch {
throw new CliError(`OAuth secret '${secretId}' is not valid JSON. Re-run \`bgagent jira setup\`.`);
}
if (typeof parsed !== 'object' || parsed === null) {
throw new CliError(`OAuth secret '${secretId}' has an unexpected shape. Re-run \`bgagent jira setup\`.`);
}
const obj = parsed as Record<string, unknown>;
const required = [
'access_token',
'refresh_token',
'expires_at',
'client_id',
'client_secret',
'cloud_id',
'site_url',
];
const missing = required.filter((key) => typeof obj[key] !== 'string' || (obj[key] as string).length === 0);
if (missing.length > 0) {
throw new CliError(
`OAuth secret '${secretId}' is missing required field${missing.length === 1 ? '' : 's'} ${missing.join(', ')}. `
+ `Re-run \`bgagent jira setup\` for tenant '${String(obj.cloud_id ?? 'unknown')}'.`,
);
}
return parsed as StoredJiraOauthToken;
}

function isJiraUser(value: unknown): value is JiraUserSearchResult {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return typeof obj.accountId === 'string' && obj.accountId.length > 0;
}

function isLinkableJiraUser(user: JiraUserSearchResult): boolean {
return user.active !== false && user.accountType !== 'app';
}

function formatJiraUserLabel(user: JiraUserSearchResult): string {
const name = user.displayName || user.accountId || '(unknown)';
return `${name}${user.emailAddress ? ` (${user.emailAddress})` : ''}`;
}

async function parseJiraJson(response: Response, context: string): Promise<unknown> {
try {
return await response.json();
} catch {
throw new CliError(`Atlassian ${context} returned non-JSON: HTTP ${response.status}.`);
}
}

async function fetchJiraUserByAccountId(
accessToken: string,
cloudId: string,
accountId: string,
fetchImpl: typeof fetch,
): Promise<JiraUserSearchResult | null> {
const url = new URL(`https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3/user`);
url.searchParams.set('accountId', accountId);
const response = await fetchImpl(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (response.status === 404) return null;
if (!response.ok) {
throw new CliError(
`Atlassian Jira user lookup failed: HTTP ${response.status}. `
+ 'The stored OAuth token may be expired/revoked or missing read:jira-user.',
);
}
const parsed = await parseJiraJson(response, 'Jira user lookup');
if (!isJiraUser(parsed)) {
throw new CliError(`Atlassian Jira user lookup returned an unexpected shape: ${JSON.stringify(parsed).slice(0, 200)}`);
}
return parsed;
}

async function searchJiraUsers(
accessToken: string,
cloudId: string,
query: string,
fetchImpl: typeof fetch,
): Promise<JiraUserSearchResult[]> {
const url = new URL(`https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3/user/search`);
url.searchParams.set('query', query);
url.searchParams.set('maxResults', '10');
const response = await fetchImpl(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
throw new CliError(
`Atlassian Jira user search failed: HTTP ${response.status}. `
+ 'The stored OAuth token may be expired/revoked or missing read:jira-user.',
);
}
const parsed = await parseJiraJson(response, 'Jira user search');
if (!Array.isArray(parsed)) {
throw new CliError(`Atlassian Jira user search returned an unexpected shape: ${JSON.stringify(parsed).slice(0, 200)}`);
}
return parsed.filter(isJiraUser);
}

async function resolveJiraUser(
accessToken: string,
cloudId: string,
accountIdOrEmail: string,
fetchImpl: typeof fetch = fetch,
): Promise<JiraUserSearchResult> {
const query = accountIdOrEmail.trim();
if (!query) {
throw new CliError('Jira account id or email is required.');
}

if (!query.includes('@')) {
const direct = await fetchJiraUserByAccountId(accessToken, cloudId, query, fetchImpl);
if (direct) {
if (!isLinkableJiraUser(direct)) {
throw new CliError(`Jira account '${query}' is inactive or is an app account.`);
}
return direct;
}
}

const users = (await searchJiraUsers(accessToken, cloudId, query, fetchImpl)).filter(isLinkableJiraUser);
const exactAccountId = users.find((user) => user.accountId === query);
if (exactAccountId) return exactAccountId;

if (query.includes('@')) {
const lowerQuery = query.toLowerCase();
const exactEmail = users.find((user) => (user.emailAddress ?? '').toLowerCase() === lowerQuery);
if (exactEmail) return exactEmail;
}

if (users.length === 1) return users[0];
if (users.length === 0) {
throw new CliError(`No active Jira user found for '${query}' in tenant '${cloudId}'.`);
}

const candidates = users.map((user) => `- ${formatJiraUserLabel(user)} [${user.accountId}]`).join('\n');
throw new CliError(
`Jira user lookup for '${query}' returned multiple users. Re-run with the accountId for the intended user:\n${candidates}`,
);
}

function promptLine(label: string): Promise<string> {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
Expand Down Expand Up @@ -576,12 +757,144 @@ export function makeJiraCommand(): Command {
console.log(' 1. Map a Jira project to a GitHub repo:');
console.log(` bgagent jira map ${cloudId} <PROJECT-KEY> --repo owner/repo`);
console.log(' 2. Link your Jira account so triggered tasks attribute to your platform user:');
console.log(' (an admin runs `bgagent jira invite-user` to issue you a code; this command');
console.log(' is not yet implemented — populate the user-mapping row manually for now.)');
console.log(` bgagent jira invite-user ${cloudId} <account-id-or-email>`);
console.log(' bgagent jira link <code>');
console.log(` 3. Add the trigger label '${DEFAULT_LABEL_FILTER}' to a Jira issue in a mapped project.`);
}),
);

// ─── invite-user ──────────────────────────────────────────────────────────
jira.addCommand(
new Command('invite-user')
.description('Generate a one-time code for a Jira teammate to redeem via `bgagent jira link <code>`')
.argument('<cloud-id>', 'Atlassian tenant cloudId (UUID)')
.argument('<account-id-or-email>', 'Jira accountId or email address for the teammate')
.option('--region <region>', 'AWS region (defaults to configured region)')
.option('--stack-name <name>', 'CloudFormation stack name', 'backgroundagent-dev')
.action(async (cloudId: string, accountIdOrEmail: string, opts) => {
const config = loadConfig();
const region = opts.region || config.region;
const stackName = opts.stackName;

const [workspaceRegistryTable, userMappingTable] = await Promise.all([
getStackOutput(region, stackName, 'JiraWorkspaceRegistryTableName'),
getStackOutput(region, stackName, 'JiraUserMappingTableName'),
]);
const missing: string[] = [];
if (!workspaceRegistryTable) missing.push('JiraWorkspaceRegistryTableName');
if (!userMappingTable) missing.push('JiraUserMappingTableName');
if (missing.length > 0) {
throw new CliError(
`Stack '${stackName}' is missing outputs ${missing.join(', ')}. `
+ 'Re-deploy with the JiraIntegration CDK changes (mise //cdk:deploy).',
);
}

const creds = loadCredentials();
if (!creds?.id_token) {
throw new CliError('Not authenticated — run `bgagent login` first.');
}
const callerCognitoSub = extractCognitoSub();

const sm = new SecretsManagerClient({ region });
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region }));

const registry = await ddb.send(new GetCommand({
TableName: workspaceRegistryTable!,
Key: { jira_cloud_id: cloudId },
}));
const registryRow = registry.Item;
if (!registryRow || registryRow.status !== 'active') {
throw new CliError(
`Jira tenant '${cloudId}' is not in the registry (or status != 'active'). `
+ 'Run `bgagent jira setup` for that tenant first.',
);
}
const oauthSecretArn = registryRow.oauth_secret_arn as string | undefined;
if (!oauthSecretArn) {
throw new CliError(`Jira tenant '${cloudId}' registry row is missing oauth_secret_arn. Re-run \`bgagent jira setup\`.`);
}
const siteUrl = (registryRow.site_url as string | undefined) ?? '';

const oauthSecret = await sm.send(new GetSecretValueCommand({ SecretId: oauthSecretArn }));
let stored = parseStoredJiraOauthToken(oauthSecret.SecretString, oauthSecretArn);

console.log(`bgagent jira invite-user — tenant '${cloudId}'`);
console.log(` region: ${region}`);
if (siteUrl) {
console.log(` site: ${siteUrl}`);
}

if (isAccessTokenExpiring(stored.expires_at)) {
process.stdout.write(' → Refreshing Jira OAuth token...');
const refreshed = await refreshAccessToken({
refreshToken: stored.refresh_token,
clientId: stored.client_id,
clientSecret: stored.client_secret,
});
if (!refreshed.refresh_token) {
console.log(' ✗');
throw new CliError('Atlassian refresh_token grant returned no refresh_token. Re-run `bgagent jira setup`.');
}
stored = {
...stored,
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token,
expires_at: computeExpiresAt(refreshed.expires_in),
scope: refreshed.scope,
updated_at: new Date().toISOString(),
};
await sm.send(new PutSecretValueCommand({
SecretId: oauthSecretArn,
SecretString: JSON.stringify(stored),
}));
console.log(' ✓');
}

process.stdout.write(' → Resolving Jira user...');
let picked: JiraUserSearchResult;
try {
picked = await resolveJiraUser(stored.access_token, cloudId, accountIdOrEmail);
console.log(` ✓ (${formatJiraUserLabel(picked)})`);
} catch (err) {
console.log(' ✗');
throw err;
}

const code = generateInviteCode();
const ttl = Math.floor(Date.now() / 1000) + 24 * 60 * 60;
await ddb.send(new PutCommand({
TableName: userMappingTable!,
Item: {
jira_identity: `pending#${code}`,
status: 'pending',
jira_cloud_id: cloudId,
jira_site_url: siteUrl,
jira_account_id: picked.accountId,
jira_user_name: picked.displayName ?? '',
jira_user_email: picked.emailAddress ?? '',
invited_at: new Date().toISOString(),
invited_by_platform_user_id: callerCognitoSub,
ttl,
},
}));

console.log();
console.log('✅ Invite created.');
console.log();
console.log(' Send this to the teammate (Slack/email/etc):');
console.log();
console.log(` bgagent jira link ${code}`);
console.log();
console.log(` Picked Jira user: ${formatJiraUserLabel(picked)}`);
console.log(` Jira tenant: ${siteUrl || cloudId}`);
console.log(` Code expires: ${new Date(ttl * 1000).toISOString()} (24h)`);
console.log();
console.log(' The teammate sees the Jira identity above and confirms before the');
console.log(' mapping is written. If you picked the wrong user, the teammate aborts.');
}),
);

// ─── link ─────────────────────────────────────────────────────────────────
jira.addCommand(
new Command('link')
Expand Down
Loading
Loading