@@ -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' ;
3232import { Command } from 'commander' ;
3333import { ApiClient } from '../api-client' ;
3434import { loadConfig , loadCredentials } from '../config' ;
3535import { CliError } from '../errors' ;
3636import { formatJson } from '../format' ;
37+ import { generateInviteCode } from '../invite-code' ;
3738import {
3839 buildAuthorizationUrl ,
3940 computeExpiresAt ,
4041 exchangeAuthorizationCode ,
4142 fetchAccessibleResources ,
4243 generatePkce ,
44+ isAccessTokenExpiring ,
4345 jiraOauthSecretName ,
46+ parseStoredJiraOauthToken ,
47+ refreshAccessToken ,
4448 StoredJiraOauthToken ,
4549} from '../jira-oauth' ;
4650import { 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+
261396function 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